Symfony: Automatizirajte sigurnosna skeniranja web-mjesta nakon implementacije uz analizu umjetnom inteligencijom
Implementacija može biti tehnički uspješna, a da pritom neprimjetno oslabi web-mjesto koje je isporučila. Promjena proxyja može ukloniti sigurnosno zaglavlje, lanac certifikata može biti nepotpun ili nova politika resursa može pregledniku pružiti manju zaštitu nego što se očekivalo.
Praktičan odgovor je učiniti vanjsku sigurnosnu provjeru dijelom same implementacije. U ovom vodiču izgradit ćemo Symfony naredbu koja šalje produkcijski URL u Website Security Analyzer, preslikava rezultat u stabilan objekt domene, ponovno pokušava samo oporavljive neuspjehe i daje strukturirani izlaz prikladan za CI zapise.
Analizator provodi ograničenu, neinvazivnu analizu javne HTTPS krajnje točke i njezina sigurnosnog položaja u pregledniku. Koristan je za osiguranje implementacije, ali nije penetracijski test i nikada ga se ne smije predstavljati kao takvog.
Dobijte pristup alatu Website Security Analyzer
Za ovu je uslugu potrebna autentifikacija; u ovoj integraciji ne postoji način rada bez tokena. Dovršite sljedeći postupak uvođenja prije pisanja aplikacijskog koda:
- Registrirajte se na https://ai.mihajlo.mk/register ili se prijavite putem https://ai.mihajlo.mk/login.
- Otvorite stranicu usluge Website Security Analyzer.
- Odaberite dostupni plan Free, Plus ili Pro i dovršite njegovu aktivaciju.
- Otvorite službenu dokumentaciju usluge.
- Pronađite ploču Service token i kopirajte token ograničen na uslugu koji je tamo prikazan.
Ponovno generiranje ovog tokena opoziva prethodno aktivni token. Rotaciju tretirajte kao koordiniranu promjenu implementacije: instalirajte novu vrijednost u produkcijsku pohranu tajni, ponovno implementirajte svakog potrošača, provjerite skeniranje i tek tada uklonite svu zastarjelu CI konfiguraciju.
API prihvaća Bearer token, zaglavlje X-API-Token ili parametar tokena u upitu. Upotrijebit ćemo Bearer zaglavlje jer čuva vjerodajnicu izvan URL-ova, povijesti proxyja i uobičajenih zapisnika pristupa.
Najprije potvrdite HTTP ugovor
Točan zahtjev je POST https://ai.mihajlo.mk/api/website-security-analyzer-api/v1/analyze-website. Njegovo JSON tijelo sadrži jednu obaveznu vrijednost, url.
Pošaljite minimalan zahtjev iz kontroliranog terminala prije integracije sa Symfonyjem:
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"}'
Odgovor sadrži rezultat, nalaze grupirane prema ozbiljnosti, TLS pojedinosti i preporuke. Naša će aplikacija provjeriti te odjeljke bez pretpostavljanja nedokumentiranih polja unutar pojedinačnog nalaza ili TLS zapisa.
Pohranite lokalne vjerodajnice u .env.local, koji mora ostati izvan kontrole verzija:
WEBSITE_SECURITY_TOKEN=YOUR_SERVICE_TOKEN
WEBSITE_SECURITY_TARGET_URL=https://www.example.com
U produkciji postavite iste nazive varijabli okruženja putem hosting platforme ili CI pohrane tajni, umjesto kopiranja lokalne datoteke okruženja na poslužitelj.
Arhitektura i preduvjeti projekta
Ova implementacija zahtijeva PHP 8.3 ili noviji, postojeću Symfony aplikaciju, Composer, odlazni HTTPS pristup API-ju i javno dostupni produkcijski HTTPS URL. Instalirajte HTTP klijent prve strane i uobičajenu podršku za testiranje ako ih projekt već nema:
composer require symfony/http-client symfony/console symfony/monolog-bundle
composer require --dev symfony/test-pack
Projekt upotrebljava četiri usredotočene komponente:
SecurityReportupravlja rubnom validacijom i preslikavanjem odgovora na razini domene.AnalyzerExceptionizlaže strukturirane kategorije neuspjeha bez otkrivanja tijela odgovora.WebsiteSecurityAnalyzerupravlja autentifikacijom, HTTP vremenskim ograničenjima, pravilima ponovnog pokušaja i zapisivanjem.WebsiteSecurityScanCommandpostavlja ciljni URL putem konfiguracije i cjevovodu implementacije daje deterministički izlazni kod.
Messenger bi dodao infrastrukturu reda bez pomoći ovom konkretnom tijeku rada. Provjera nakon implementacije zahtijeva trenutan rezultat, stoga je sinkrona konzolna naredba jasniji kompromis. API poziv ostaje dovoljno izoliran da se kasnije premjesti iza Messengera ako skeniranja postanu zakazani pozadinski posao.
Preslikajte odgovor na granici aplikacije
Vanjski JSON ne bi trebao teći kroz aplikaciju bez validacije. Sljedeći nepromjenjivi DTO zahtijeva četiri dokumentirana odjeljka odgovora, a namjerno čuva njihov ugniježđeni sadržaj:
<?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,
];
}
}
Ovaj preslikavač ne izmišlja polja poput naziva izdavatelja certifikata, identifikatora zaglavlja ili URL-ova za sanaciju. Potrošači kasnije mogu dodati tipizirane ugniježđene objekte kada te strukture budu potvrđene prema službenoj dokumentaciji i stvarnim fiksnim podacima.
Operativne neuspjehe prikažite zasebno od nalaza analize:
<?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);
}
}
Izgradite ograničeni API klijent svjestan ponovnih pokušaja
Klijent dopušta ukupno tri pokušaja. Ponovno pokušava prijenosne neuspjehe, HTTP 429 odgovore i neuspjehe na strani poslužitelja. Autentifikacija i druge uobičajene pogreške klijenta ne uspijevaju odmah jer ponavljani identični zahtjevi neće ispraviti nevažeći token ili zahtjev.
<?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);
}
}
Odgoda Retry-After ograničena je na dvije sekunde kako odgovor pružatelja ne bi neograničeno zadržao radnika implementacije. Ograničeni su i vremensko ograničenje po operaciji i ukupno trajanje zahtjeva. Zapisi sadrže status, pokušaj i klasu iznimke, ali nikada token, zaglavlja zahtjeva, tijelo odgovora ni punu poruku iznimke.
Povežite konfiguraciju sa Symfonyjem
Izričito povežite dvije vrijednosti podržane varijablama okruženja. Time se tajne drže izvan izvornog koda, uz dopuštanje uobičajenog automatskog povezivanja za HTTP klijent i zapisivač:
# 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)%'
Stvorite naredbu nakon implementacije
Ciljni URL je konfiguracija, a ne argument naredbe. To sprječava kompromitirani ili pogrešni parametar cjevovoda da plaćenu integraciju pretvori u općeniti skener URL-ova.
<?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;
}
}
}
Uspješna naredba zapisuje jedan JSON dokument koji sadrži rezultat, grupirane nalaze, TLS pojedinosti i preporuke. Izbjegavajte automatski neuspjeh na temelju pretpostavljenog praga rezultata ili pretpostavljenog rječnika ozbiljnosti. Uspostavite politiku implementacije tek nakon potvrđivanja semantike bodovanja usluge i dogovora koji bi dokumentirani nalazi trebali blokirati izdanje.
Testirajte bez pozivanja stvarne usluge
MockHttpClient čini ponašanje ponovnog pokušaja i autentifikacije determinističkim. Sljedeći testovi dokazuju da se privremeni neuspjeh poslužitelja ponovno pokušava, a neuspjeh autentifikacije ne:
<?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);
}
}
}
Pokrenite usmjereni paket testova s php bin/phpunit tests/Security. Ovi fiksni podaci sadrže lažni token i sintetičke odgovore; ni stvarne vjerodajnice ni kopirana produkcijska opterećenja ne pripadaju testnim fiksnim podacima.
Pokrenite ga nakon svake produkcijske implementacije
Izvršite skeniranje tek nakon što produkcijski promet pokaže na novo izdanje. Skeniranje prije izdanja samo bi provjerilo staru javnu verziju. Mali završetak implementacije može provjeriti dostupnost, a zatim pozvati 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
Neka sustav implementacije zabilježi JSON izlaz naredbe kao artefakt ili strukturirani zapis. Zadržite aplikaciju aktivnom ako vanjski analizator privremeno nije dostupan, ali označite provjeru nakon implementacije neuspješnom i upozorite odgovornog programera. Automatsko vraćanje na prethodnu verziju isključivo na temelju vremenskog ograničenja treće strane može zdravu objavu učiniti manje pouzdanom.
Iscrpljivanje kvote i ograničavanje brzine pojavljuju se kao strukturirani neuspjeh rate_limited nakon ograničenih ponovnih pokušaja. Smanjite duplicirane okidače implementacije ili odaberite odgovarajući plan umjesto dodavanja neograničenih ponovnih pokušaja.
Uobičajeni produkcijski neuspjesi
- Neuspjeh autentifikacije: potvrdite da tajna pripada ovoj usluzi i da je posvuda ažurirana nakon ponovnog generiranja.
- Zahtjev odbijen: provjerite da je konfigurirani cilj valjan javni HTTPS URL i da JSON tijelo i dalje sadrži
url. - Neispravan odgovor: zadržite sigurnu kategoriju neuspjeha i usporedite ugovor sa službenom dokumentacijom prije promjene preslikavača.
- Prijenosni neuspjeh: provjerite odlazni DNS, TLS, pravila vatrozida i mrežnu rutu radnika implementacije.
- Neočekivani rezultati cilja: potvrdite da su DNS i produkcijski promet bili preusmjereni prije pokretanja naredbe i da URL predstavlja implementirano web-mjesto.
Završni kontrolni popis za provjeru
- Plan usluge je aktivan, a token ograničen na uslugu pohranjen je samo u konfiguraciji tajni podržanoj varijablama okruženja.
WEBSITE_SECURITY_TARGET_URLsadrži kanonski javni HTTPS URL.- Deterministički testovi prolaze bez vanjskog mrežnog pristupa.
- Produkcijska naredba vraća nulu i ispisuje četiri preslikana odjeljka izvješća.
- Ponovni pokušaji događaju se samo za prijenosne pogreške, ograničenja brzine i neuspjehe poslužitelja.
- Zapisi i CI artefakti ne sadrže token, zaglavlje autorizacije ni neobrađeni osjetljivi odgovor.
- Naredba se pokreće nakon što svako produkcijsko izdanje postane javno dostupno.
- Rezultat se opisuje kao ograničena analiza sigurnosti web-mjesta, a ne kao penetracijski test.
Najjače provjere implementacije rijetko su najsloženije. To su one postavljene na ispravnoj granici, s čvrstim vremenskim ograničenjima, učinjene vidljivima i dosljedno pokretane. S ovom naredbom povezanom s putanjom izdanja, regresije usmjerene na preglednik i TLS postaju vidljive odmah nakon implementacije — dok implementacija ostaje dovoljno mala da je programer ili mali tim može razumjeti, testirati i smatrati pouzdanom.