Symfony sigurnosni nadzor: e-poštom obavijestite male tvrtke kada ocjene web-mjesta padnu
Web-mjesto male tvrtke može nastaviti posluživati stranice dok se njegova sigurnosna pozicija neprimjetno pogoršava. Promjena certifikata, uklonjeno zaglavlje preglednika ili pogreška pri implementaciji možda neće uzrokovati vidljivi prekid rada, no i dalje mogu smanjiti sigurnosnu ocjenu web-mjesta.
Ovaj vodič izrađuje Symfony aplikaciju usmjerenu na produkciju koja svaki tjedan provjerava jedno javno web-mjesto, pamti prethodnu ocjenu i vlasniku šalje e-poštu samo kada je nova ocjena niža. Integracija koristi ograničenu, neinvazivnu analizu javnog HTTPS-a i sigurnosne pozicije preglednika. To je signal za nadzor, a ne penetracijski test, procjena ranjivosti ili zamjena za ovlašteno sigurnosno testiranje.
Dobijte pristup i kopirajte servisni token
Dovršite uvođenje u uslugu prije pisanja integracijskog koda:
- Registrirajte se na https://ai.mihajlo.mk/register ili upotrijebite https://ai.mihajlo.mk/login ako već imate račun.
- 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 za određenu uslugu.
Ponovno generiranje tog tokena opoziva prethodno aktivni token. Koordinirajte rotaciju s implementacijom kako bi aplikacija dobila zamjenu prije ili odmah nakon ponovnog generiranja.
API prihvaća Bearer token, zaglavlje X-API-Token ili parametar tokena u upitu. Ovaj projekt koristi Bearer autentikaciju jer je manje vjerojatno da će se zaglavlja, za razliku od nizova upita, pojaviti u zapisnicima proxyja i pristupa.
Potvrdite krajnju točku prije izrade funkcionalnosti
Točna operacija je POST https://ai.mihajlo.mk/api/website-security-analyzer-api/v1/analyze-website. Njezin JSON zahtjev sadrži url. Testirajte token javnom HTTPS adresom kojom upravljate:
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"}'
Uspješan odgovor sadrži ocjenu, nalaze grupirane prema ozbiljnosti, TLS pojedinosti i preporuke. Te ćemo vrijednosti provjeriti na granici aplikacije, umjesto da pretpostavimo kako svaki ugniježđeni nalaz ima nedokumentiranu strukturu.
Instalirajte prve Symfony komponente koje projekt upotrebljava:
composer require symfony/http-client symfony/mailer symfony/cache symfony/lock
composer require --dev symfony/test-pack
Stavite vjerodajnice i adrese specifične za implementaciju u .env.local za lokalni razvoj. Nemojte potvrđivati ovu datoteku u repozitorij. U produkciji iste varijable unesite putem hosting platforme ili upravitelja tajni.
SECURITY_ANALYZER_TOKEN=YOUR_SERVICE_TOKEN
SECURITY_WEBSITE_URL=https://www.example.com
[email protected]
[email protected]
MAILER_DSN=smtp://USERNAME:[email protected]:587
LOCK_DSN=flock
Namjerno mala arhitektura
Tjednoj provjeri nije potreban posrednik poruka samo kako bi djelovala sofisticirano. Symfony konzolna naredba dobro radi s cronom ili raspoređivačem platforme, dok zaključavanje sprječava preklapajuća izvršavanja.
WebsiteSecurityAnalyzerupravlja granicom udaljenog HTTP-a i pravilima ponovnih pokušaja.SecuritySnapshotpreslikava i provjerava odgovor domene.SecurityMonitoruspoređuje ocjene, šalje e-poštu i pohranjuje novu osnovnu vrijednost.CheckWebsiteSecurityCommandpruža zaključavanje, izlazni status i izlaz za operatera.
Primjer pohranjuje stanje u Symfony predmemorijski skup. Skup datotečnog sustava i flock prikladni su za jedan trajni poslužitelj. Više replika aplikacije zahtijeva dijeljene pozadinske sustave predmemorije i zaključavanja; inače bi dva hosta mogla uspoređivati različite osnovne vrijednosti i slati dvostruka upozorenja.
Relevantna struktura projekta je:
src/
Command/CheckWebsiteSecurityCommand.php
Security/AnalyzerException.php
Security/SecurityMonitor.php
Security/SecuritySnapshot.php
Security/WebsiteAnalyzer.php
Security/WebsiteSecurityAnalyzer.php
tests/
Security/WebsiteSecurityAnalyzerTest.php
Security/SecurityMonitorTest.php
Konfigurirajte Symfonyjevu infrastrukturu
Izradite ograničenog klijenta kako bi token, osnovni URL i vremenska ograničenja ostali izvan logike aplikacije. Vremensko ograničenje neaktivnosti i ukupno trajanje sprječavaju da tjedni proces visi neograničeno.
# config/packages/security_monitor.yaml
framework:
http_client:
scoped_clients:
security_analyzer.client:
base_uri: 'https://ai.mihajlo.mk/api/website-security-analyzer-api'
headers:
Authorization: 'Bearer %env(SECURITY_ANALYZER_TOKEN)%'
timeout: 10
max_duration: 20
cache:
pools:
security_monitor.cache:
adapter: cache.adapter.filesystem
default_lifetime: 0
lock: '%env(LOCK_DSN)%'
mailer:
dsn: '%env(MAILER_DSN)%'
Povežite imenovane usluge i vrijednosti okruženja putem ubrizgavanja ovisnosti:
# config/services.yaml
services:
_defaults:
autowire: true
autoconfigure: true
bind:
$securityAnalyzerClient: '@security_analyzer.client'
$securityMonitorCache: '@security_monitor.cache'
$websiteUrl: '%env(SECURITY_WEBSITE_URL)%'
$ownerEmail: '%env(SECURITY_OWNER_EMAIL)%'
$fromEmail: '%env(SECURITY_FROM_EMAIL)%'
App\:
resource: '../src/'
App\Security\WebsiteAnalyzer:
alias: App\Security\WebsiteSecurityAnalyzer
Preslikajte odgovor na granici API-ja
DTO prihvaća numeričke ocjene, ali ne izmišlja raspon ocjena. Provjerava četiri obećana područja odgovora i čuva ugniježđene podatke čiji se točan oblik može mijenjati.
<?php
// src/Security/SecuritySnapshot.php
namespace App\Security;
final readonly class SecuritySnapshot
{
public function __construct(
public float $score,
public array $findings,
public array $tls,
public array $recommendations,
) {}
public static function fromArray(array $data): self
{
$score = $data['score'] ?? null;
if ((!is_int($score) && !is_float($score))
|| !is_finite((float) $score)
|| !is_array($data['findings'] ?? null)
|| !is_array($data['tls'] ?? null)
|| !is_array($data['recommendations'] ?? null)
) {
throw new AnalyzerException('The analyzer returned an invalid response.');
}
foreach ($data['findings'] as $severity => $items) {
if (!is_string($severity) || !is_array($items)) {
throw new AnalyzerException('Findings are not grouped by severity.');
}
}
return new self(
(float) $score,
$data['findings'],
$data['tls'],
$data['recommendations'],
);
}
}
Definirajte usko sučelje kako bi se pravilo nadzora moglo testirati bez HTTP poziva:
<?php
namespace App\Security;
interface WebsiteAnalyzer
{
public function analyze(string $url): SecuritySnapshot;
}
final class AnalyzerException extends \RuntimeException
{
public function __construct(
string $message,
public readonly bool $temporary = false,
?\Throwable $previous = null,
) {
parent::__construct($message, 0, $previous);
}
}
Izradite ograničen, selektivan HTTP klijent
Klijent ponavlja pokušaje samo kod transportnih kvarova, ograničenja stope i malog skupa prolaznih kvarova pristupnika. Pogreške autentikacije i provjere odmah ne uspijevaju. Tri ukupna pokušaja s ograničenim odgodama dovoljna su za tjedni zadatak bez pretvaranja jedne provjere u nekontroliranog potrošača kvote.
<?php
// src/Security/WebsiteSecurityAnalyzer.php
namespace App\Security;
use Psr\Log\LoggerInterface;
use Symfony\Contracts\HttpClient\Exception\DecodingExceptionInterface;
use Symfony\Contracts\HttpClient\Exception\TransportExceptionInterface;
use Symfony\Contracts\HttpClient\HttpClientInterface;
final class WebsiteSecurityAnalyzer implements WebsiteAnalyzer
{
public function __construct(
private HttpClientInterface $securityAnalyzerClient,
private LoggerInterface $logger,
private ?\Closure $sleep = null,
) {}
public function analyze(string $url): SecuritySnapshot
{
for ($attempt = 1; $attempt <= 3; $attempt++) {
try {
$response = $this->securityAnalyzerClient->request(
'POST',
'/v1/analyze-website',
['json' => ['url' => $url]],
);
$status = $response->getStatusCode();
} catch (TransportExceptionInterface $e) {
if ($attempt === 3) {
throw new AnalyzerException(
'The analyzer could not be reached.',
true,
$e,
);
}
$this->pause($attempt, null);
continue;
}
if ($status >= 200 && $status < 300) {
try {
$payload = $response->toArray(false);
} catch (DecodingExceptionInterface $e) {
throw new AnalyzerException(
'The analyzer returned invalid JSON.',
false,
$e,
);
}
return SecuritySnapshot::fromArray($payload);
}
if ($status === 401 || $status === 403) {
throw new AnalyzerException('Analyzer authentication failed.');
}
if ($status === 400 || $status === 422) {
throw new AnalyzerException('The website request was rejected.');
}
$retryable = $status === 429
|| in_array($status, [502, 503, 504], true);
if ($retryable && $attempt < 3) {
$headers = $response->getHeaders(false);
$retryAfter = $headers['retry-after'][0] ?? null;
$this->logger->warning('Security analysis will be retried.', [
'status' => $status,
'attempt' => $attempt,
]);
$this->pause($attempt, $retryAfter);
continue;
}
throw new AnalyzerException(
'Analyzer request failed with HTTP '.$status.'.',
$retryable || $status >= 500,
);
}
throw new AnalyzerException('Analyzer attempts were exhausted.', true);
}
private function pause(int $attempt, ?string $retryAfter): void
{
$milliseconds = is_string($retryAfter) && ctype_digit($retryAfter)
? min(30000, (int) $retryAfter * 1000)
: min(2000, 250 * (2 ** ($attempt - 1)));
if ($this->sleep !== null) {
($this->sleep)($milliseconds);
return;
}
usleep($milliseconds * 1000);
}
}
Primijetite što nedostaje u zapisnicima: token, tijelo odgovora, TLS pojedinosti i nalazi. Operativni zapisnici trebaju informacije o statusu i pokušajima, a ne vjerodajnice ili duplicirano sigurnosno izvješće.
Usporedite ocjene i pošaljite upozorenje
Prvo uspješno pokretanje uspostavlja osnovnu vrijednost bez slanja e-pošte. Kasnija uspješna pokretanja ažuriraju tu osnovnu vrijednost. Ako ocjena padne, e-pošta se šalje prije spremanja stanja. To stvara ponašanje upozorenja barem jednom: neuspjeh slanja pošte ostavlja staru osnovnu vrijednost netaknutom kako bi kasnije pokretanje moglo pokušati ponovno.
<?php
// src/Security/SecurityMonitor.php
namespace App\Security;
use Psr\Cache\CacheItemPoolInterface;
use Psr\Log\LoggerInterface;
use Symfony\Component\Mailer\MailerInterface;
use Symfony\Component\Mime\Email;
final class SecurityMonitor
{
public function __construct(
private WebsiteAnalyzer $analyzer,
private CacheItemPoolInterface $securityMonitorCache,
private MailerInterface $mailer,
private LoggerInterface $logger,
private string $websiteUrl,
private string $ownerEmail,
private string $fromEmail,
) {}
public function run(): string
{
$snapshot = $this->analyzer->analyze($this->websiteUrl);
$key = 'website_security_'.hash('sha256', $this->websiteUrl);
$item = $this->securityMonitorCache->getItem($key);
$stored = $item->isHit() ? $item->get() : null;
$previous = is_array($stored) && is_numeric($stored['score'] ?? null)
? (float) $stored['score']
: null;
$outcome = $previous === null ? 'baseline' : 'steady';
if ($previous !== null && $snapshot->score < $previous) {
$body = sprintf(
"Website: %s\nPrevious score: %s\nCurrent score: %s\n\n".
"Findings by severity:\n%s\n\nTLS details:\n%s\n\n".
"Recommendations:\n%s\n",
$this->websiteUrl,
$previous,
$snapshot->score,
json_encode($snapshot->findings, JSON_PRETTY_PRINT | JSON_THROW_ON_ERROR),
json_encode($snapshot->tls, JSON_PRETTY_PRINT | JSON_THROW_ON_ERROR),
json_encode($snapshot->recommendations, JSON_PRETTY_PRINT | JSON_THROW_ON_ERROR),
);
$this->mailer->send(
(new Email())
->from($this->fromEmail)
->to($this->ownerEmail)
->subject('Website security score dropped')
->text($body),
);
$outcome = 'alerted';
}
$item->set([
'score' => $snapshot->score,
'checked_at' => gmdate(DATE_ATOM),
]);
if (!$this->securityMonitorCache->save($item)) {
throw new \RuntimeException('The security baseline could not be saved.');
}
$this->logger->info('Weekly website security check completed.', [
'website' => $this->websiteUrl,
'score' => $snapshot->score,
'outcome' => $outcome,
]);
return $outcome;
}
}
Izložite tijek rada kao zaključanu naredbu
<?php
// src/Command/CheckWebsiteSecurityCommand.php
namespace App\Command;
use App\Security\SecurityMonitor;
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\Lock\LockFactory;
#[AsCommand(name: 'app:security:check')]
final class CheckWebsiteSecurityCommand extends Command
{
public function __construct(
private SecurityMonitor $monitor,
private LockFactory $lockFactory,
private LoggerInterface $logger,
) {
parent::__construct();
}
protected function execute(
InputInterface $input,
OutputInterface $output,
): int {
$lock = $this->lockFactory->createLock(
'weekly-website-security',
300.0,
);
if (!$lock->acquire()) {
$output->writeln('A security check is already running.');
return Command::SUCCESS;
}
try {
$outcome = $this->monitor->run();
$output->writeln('Security check completed: '.$outcome);
return Command::SUCCESS;
} catch (\Throwable $e) {
$this->logger->error('Website security check failed.', [
'exception' => $e::class,
'message' => $e->getMessage(),
]);
$output->writeln('Security check failed; inspect application logs.');
return Command::FAILURE;
} finally {
$lock->release();
}
}
}
Testirajte i granicu i poslovno pravilo
MockHttpClient čini API test determinističkim i provjerava točnu metodu, krajnju točku i tijelo zahtjeva.
<?php
namespace App\Tests\Security;
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
{
public function testItMapsAValidResponse(): void
{
$client = 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(
['url' => 'https://www.example.com'],
json_decode($options['body'], true, 512, JSON_THROW_ON_ERROR),
);
return new MockResponse(json_encode([
'score' => 84,
'findings' => ['high' => [], 'medium' => [['id' => 'header']]],
'tls' => ['available' => true],
'recommendations' => ['Review browser security headers.'],
], JSON_THROW_ON_ERROR));
},
'https://ai.mihajlo.mk/api/website-security-analyzer-api',
);
$snapshot = (new WebsiteSecurityAnalyzer(
$client,
new NullLogger(),
static function (int $milliseconds): void {},
))->analyze('https://www.example.com');
self::assertSame(84.0, $snapshot->score);
self::assertArrayHasKey('medium', $snapshot->findings);
}
}
Drugi test trebao bi obuhvatiti važan ishod: uspostavljanje osnovne vrijednosti i slanje točno jedne poruke nakon pada.
<?php
public function testItEmailsOnlyAfterTheScoreDrops(): void
{
$analyzer = $this->createMock(\App\Security\WebsiteAnalyzer::class);
$analyzer->method('analyze')->willReturnOnConsecutiveCalls(
new \App\Security\SecuritySnapshot(90, ['high' => []], [], []),
new \App\Security\SecuritySnapshot(82, ['high' => [['id' => 'x']]], [], ['Review finding x.']),
);
$mailer = $this->createMock(\Symfony\Component\Mailer\MailerInterface::class);
$mailer->expects(self::once())->method('send');
$monitor = new \App\Security\SecurityMonitor(
$analyzer,
new \Symfony\Component\Cache\Adapter\ArrayAdapter(),
$mailer,
new \Psr\Log\NullLogger(),
'https://www.example.com',
'[email protected]',
'[email protected]',
);
self::assertSame('baseline', $monitor->run());
self::assertSame('alerted', $monitor->run());
}
Također dodajte slučajeve za neispravan JSON, nedostajuća polja odgovora, HTTP 401 bez ponovnih pokušaja, HTTP 429 s ograničenim ponovnim pokušajima, neuspjeh prijenosa e-pošte bez ažuriranja osnovne vrijednosti i oštećeno stanje predmemorije.
Implementirajte i rasporedite tjednu provjeru
Pokrenite testove, zagrijte produkcijsku predmemoriju, provjerite isporuku e-pošte i jednom ručno izvršite naredbu. To prvo izvršavanje trebalo bi prijaviti baseline i ne poslati e-poruku o padu ocjene.
php bin/phpunit
APP_ENV=prod APP_DEBUG=0 php bin/console cache:warmup
APP_ENV=prod APP_DEBUG=0 php bin/console app:security:check
Na jednom poslužitelju, cron unos za ponedjeljak može pokrenuti provjeru u mirno vrijeme:
17 3 * * 1 cd /srv/example-app && APP_ENV=prod APP_DEBUG=0 php bin/console app:security:check
Konfigurirajte raspoređivač da zabilježi nenulti izlazni status naredbe. Upozorite na ponovljene neuspjehe jer pogreške autentikacije, iscrpljena kvota plana, DNS poteškoće, neuspjesi prijenosa e-pošte i spremište predmemorije bez mogućnosti pisanja sprječavaju koristan nadzor.
Za implementacije u spremnicima ili s više replika nemojte osnovnu vrijednost držati isključivo unutar efemernog spremnika. Upotrijebite trajnu dijeljenu pohranu za predmemoriju i dijeljeni pozadinski sustav zaključavanja. Pokrenite raspored s jednog određenog radnika ili se oslonite na distribuirano zaključavanje.
Uobičajeni produkcijski neuspjesi
- 401 ili 403: potvrdite varijablu okruženja, aktivaciju plana i rotaciju tokena. Nemojte naslijepo ponavljati te odgovore.
- 400 ili 422: provjerite da je
SECURITY_WEBSITE_URLvaljan javni HTTPS URL i da JSON polje ostajeurl. - 429: poštujte ograničeno ponašanje ponovnih pokušaja, provjerite korištenje plana i ponovno pokrenite kasnije. Nikada ne pretvarajte ograničavanje stope u beskonačnu petlju.
- Nema e-pošte nakon prvog pokretanja: to je namjerno. Prva ocjena stvara osnovnu vrijednost za usporedbu.
- Ponovljena upozorenja: provjerite uspijeva li slanje e-pošte, ali se trajno stanje ne može spremiti, ili replike koriste odvojene skupove predmemorije.
- Naredba uspijeva, ali cron ne: cron često ima manje varijabli okruženja. Upotrijebite apsolutne putanje i osigurajte da su potrebne varijable dostupne zakazanom procesu.
Završni kontrolni popis za provjeru
- Token postoji samo u konfiguraciji podržanoj varijablama okruženja i nije prisutan u kontroli izvornog koda ni zapisnicima.
- Klijent poziva točnu POST krajnju točku s JSON poljem
url. - Vremena povezivanja i ukupnog odgovora su ograničena.
- Samo prolazni kvarovi i ograničenja stope dobivaju ograničene ponovne pokušaje.
- Prvi uspješan rezultat stvara trajnu osnovnu vrijednost bez slanja e-pošte.
- Niža kasnija ocjena šalje jedno korisno izvješće koje sadrži nalaze, TLS pojedinosti i preporuke.
- Neuspjesi e-pošte ili pohrane stanja proizvode nenulti rezultat naredbe.
- Raspoređivač se pokreće tjedno, a preklapajuća izvršavanja su zaključana.
Vrijedan dio ovog nadzora nije cron izraz, pa čak ni e-pošta. To je pažljiva granica oko promjene: provjerite nesigurne vanjske podatke, zapamtite posljednji pouzdan rezultat, obavijestite samo kada se signal pogorša i vidljivo ne uspijte kada sam sustav nadzora prestane raditi. To jednostavan tjedni API poziv pretvara u pouzdan mehanizam ranog upozoravanja koji mala tvrtka stvarno može koristiti.