Nativni PHP 8.3: automatizirajte sigurnosna skeniranja produkcije nakon implementacije
Implementacija može uspjeti, a pritom neprimjetno oslabiti web-mjesto koje isporučuje. Promjena proxyja ukloni sigurnosno zaglavlje, lanac certifikata bude pogrešno konfiguriran ili nova politika odgovora preglednicima pruža manje zaštite nego prethodna verzija. Ti problemi ostaju izvan jediničnih testova jer se pojavljuju tek nakon što je javna HTTPS putanja aktivna.
Ovaj vodič izrađuje Native PHP 8.3 naredbu koja poziva Website Security Analyzer nakon svake produkcijske implementacije. Ona provodi ograničenu, neinvazivnu provjeru javnog HTTPS-a i sigurnosne razine preglednika, mapira rezultat u stabilan objekt domene, zapisuje artefakt implementacije i vraća pošten status procesa za automatizaciju. To je signal sigurnosne regresije, a ne penetracijski test.
Pribavite pristup 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 aktivaciju.
- Otvorite službenu dokumentaciju usluge. Pronađite ploču Service token i kopirajte token ograničen na uslugu.
- Pohranite tu vrijednost u zaštićenu konfiguraciju okruženja projekta. Ponovno generiranje tokena usluge opoziva prethodno aktivni token, stoga rotacija tokena mora ažurirati produkcijsku konfiguraciju prije sljedećeg skeniranja.
Ova usluga zahtijeva autentikaciju. Prihvaća Bearer token, zaglavlje X-API-Token ili parametar upita token. Implementacija u nastavku koristi Bearer token jer vjerodajnice ostaju izvan URL-ova, povijesti proxyja i uobičajenih zapisnika pristupa.
Potvrdite API ugovor
Točan zahtjev je POST https://ai.mihajlo.mk/api/website-security-analyzer-api/v1/analyze-website. Njegovo JSON tijelo sadržava url. Prije izrade naredbe, pošaljite jedan minimalan zahtjev iz kontroliranog terminala:
curl --fail-with-body \
--connect-timeout 5 \
--max-time 30 \
--request POST \
--header "Authorization: Bearer YOUR_SERVICE_TOKEN" \
--header "Content-Type: application/json" \
--data '{"url":"https://www.example.com"}' \
https://ai.mihajlo.mk/api/website-security-analyzer-api/v1/analyze-website
Nemojte lijepiti stvarni token u povijest ljuske na zajedničkim sustavima. Za produkciju izradite datoteku kojom upravlja implementacija izvan direktorija izdanja, primjerice /var/www/example/shared/.env.production:
WEBSITE_SECURITY_TOKEN="YOUR_SERVICE_TOKEN"
PUBLIC_SITE_URL="https://www.example.com"
SECURITY_REPORT_PATH="/var/www/example/shared/security/latest.json"
Ograničite ovu datoteku na račun za implementaciju i nikada je ne predajte u repozitorij. Native PHP ne učitava datoteke okruženja automatski, stoga će ih naša ulazna točka eksplicitno parsirati pomoću INI_SCANNER_RAW.
Arhitektura koja odgovara maloj produkcijskoj aplikaciji
Dizajn ima četiri uska dijela: cURL transport, API klijent s pravilima ponovnog pokušaja, mapiranje domene i konzolnu naredbu. Hook nakon implementacije poziva naredbu tek nakon što je novo izdanje javno dostupno.
Pokretanje nakon implementacije znači da neuspjelo skeniranje ne može pošteno tvrditi da je izdanje spriječeno. Umjesto toga, naredba završava s nenultim kodom, čuva implementirano web-mjesto i dopušta procesu da upozori osobu ili pokrene izričito osmišljen tijek vraćanja na prethodnu verziju. Time se izbjegava povezivanje javne dostupnosti s prolaznim neuspjehom analizatora.
Upotrijebite ovu sažetu strukturu:
security-scan/
├── composer.json
├── src/
│ ├── Http.php
│ └── SecurityAnalyzer.php
├── bin/
│ └── scan-production.php
└── tests/
└── SecurityAnalyzerClientTest.php
PHPUnit je jedina razvojna ovisnost. Sama aplikacija koristi PHP, JSON i cURL:
{
"require": {
"php": "^8.3",
"ext-curl": "*",
"ext-json": "*"
},
"require-dev": {
"phpunit/phpunit": "^11.0"
},
"autoload": {
"psr-4": {
"App\\": "src/"
}
},
"autoload-dev": {
"psr-4": {
"Tests\\": "tests/"
}
},
"scripts": {
"test": "phpunit tests"
}
}
Izradite ograničeni cURL transport
Transport upravlja mrežnom mehanikom, ali ne zna ništa o tumačenju rezultata. Bilježi zaglavlja odgovora kako bi klijent mogao poštovati Retry-After, a nikada ne zapisuje token ni tijelo odgovora.
<?php
// src/Http.php
declare(strict_types=1);
namespace App;
final readonly class HttpResponse
{
public function __construct(
public int $status,
public array $headers,
public string $body,
) {}
}
interface Transport
{
public function send(string $token, array $payload): HttpResponse;
}
final class TransportException extends \RuntimeException {}
final class CurlTransport implements Transport
{
private const ENDPOINT =
'https://ai.mihajlo.mk/api/website-security-analyzer-api/v1/analyze-website';
public function send(string $token, array $payload): HttpResponse
{
$headers = [];
$handle = curl_init(self::ENDPOINT);
if ($handle === false) {
throw new TransportException('Could not initialize cURL');
}
$body = json_encode($payload, JSON_THROW_ON_ERROR);
curl_setopt_array($handle, [
CURLOPT_POST => true,
CURLOPT_RETURNTRANSFER => true,
CURLOPT_CONNECTTIMEOUT_MS => 5_000,
CURLOPT_TIMEOUT_MS => 30_000,
CURLOPT_HTTPHEADER => [
'Authorization: Bearer ' . $token,
'Content-Type: application/json',
'Accept: application/json',
],
CURLOPT_POSTFIELDS => $body,
CURLOPT_HEADERFUNCTION => static function (
\CurlHandle $handle,
string $line
) use (&$headers): int {
$length = strlen($line);
$parts = explode(':', $line, 2);
if (count($parts) === 2) {
$headers[strtolower(trim($parts[0]))] = trim($parts[1]);
}
return $length;
},
]);
$responseBody = curl_exec($handle);
if ($responseBody === false) {
$message = curl_error($handle);
curl_close($handle);
throw new TransportException('Analyzer transport failed: ' . $message);
}
$status = (int) curl_getinfo($handle, CURLINFO_RESPONSE_CODE);
curl_close($handle);
return new HttpResponse($status, $headers, $responseBody);
}
}
Mapirajte odgovor na granici aplikacije
Rezultat usluge uključuje rezultat, nalaze grupirane po ozbiljnosti, TLS pojedinosti i preporuke. Vanjski odgovori nikada ne bi trebali neprovjereni prolaziti kroz aplikaciju. Mapiranje prihvaća samo očekivane oblike i ne uspijeva ako su obvezni podaci ugovora neispravni.
Namjerno čuva nazive grupa nalaza i TLS svojstva umjesto da izmišlja zatvoren skup koji isporučeni ugovor ne jamči.
<?php
// src/SecurityAnalyzer.php
declare(strict_types=1);
namespace App;
final readonly class SecurityReport
{
public function __construct(
public float $score,
public array $findings,
public array $tls,
public array $recommendations,
) {}
public static function fromArray(array $data): self
{
if (!isset($data['score']) || !is_numeric($data['score'])) {
throw new AnalyzerException('contract', 'Missing or invalid score');
}
foreach (['findings', 'tls', 'recommendations'] as $field) {
if (!isset($data[$field]) || !is_array($data[$field])) {
throw new AnalyzerException(
'contract',
'Missing or invalid ' . $field
);
}
}
$findings = [];
foreach ($data['findings'] as $severity => $items) {
if (is_string($severity) && is_array($items)) {
$findings[$severity] = $items;
}
}
$recommendations = array_values(array_filter(
$data['recommendations'],
static fn (mixed $item): bool => is_string($item)
));
return new self(
(float) $data['score'],
$findings,
$data['tls'],
$recommendations,
);
}
public function toArray(): array
{
return [
'score' => $this->score,
'findings' => $this->findings,
'tls' => $this->tls,
'recommendations' => $this->recommendations,
];
}
}
final class AnalyzerException extends \RuntimeException
{
public function __construct(
public readonly string $kind,
string $message,
public readonly ?int $httpStatus = null,
) {
parent::__construct($message);
}
}
final class SecurityAnalyzerClient
{
public function __construct(
private readonly Transport $transport,
private readonly \Closure $sleep =
new \Closure(),
) {}
public function analyze(string $url, string $token): SecurityReport
{
if (filter_var($url, FILTER_VALIDATE_URL) === false
|| parse_url($url, PHP_URL_SCHEME) !== 'https') {
throw new AnalyzerException('validation', 'A public HTTPS URL is required');
}
for ($attempt = 1; $attempt <= 3; $attempt++) {
try {
$response = $this->transport->send($token, ['url' => $url]);
} catch (TransportException $exception) {
if ($attempt === 3) {
throw new AnalyzerException('transport', $exception->getMessage());
}
($this->sleep)(250 * (2 ** ($attempt - 1)));
continue;
}
if ($response->status >= 200 && $response->status < 300) {
try {
$decoded = json_decode(
$response->body,
true,
512,
JSON_THROW_ON_ERROR
);
} catch (\JsonException) {
throw new AnalyzerException('contract', 'Invalid JSON response');
}
if (!is_array($decoded)) {
throw new AnalyzerException('contract', 'Invalid response object');
}
return SecurityReport::fromArray($decoded);
}
$retryable = $response->status === 408
|| $response->status === 429
|| $response->status >= 500;
if ($retryable && $attempt < 3) {
$retryAfter = ctype_digit($response->headers['retry-after'] ?? '')
? (int) $response->headers['retry-after'] * 1_000
: 250 * (2 ** ($attempt - 1));
($this->sleep)(min($retryAfter, 8_000));
continue;
}
$kind = match ($response->status) {
400, 422 => 'validation',
401, 403 => 'authentication',
429 => 'quota',
default => $response->status >= 500 ? 'upstream' : 'http',
};
throw new AnalyzerException(
$kind,
'Analyzer request failed with HTTP ' . $response->status,
$response->status
);
}
throw new AnalyzerException('internal', 'Retry loop ended unexpectedly');
}
}
Zamijenite rezervirano zadano zatvaranje konstruktora eksplicitnim uspavljivačem u trenutku sastavljanja, kao što naredba radi u nastavku. Njegovo ubrizgavanje održava testove ponovnog pokušaja trenutačnima i determinističkima. Pogreške validacije i autentikacije ne pokušavaju se ponovno; ponavljanje nevažećeg URL-a ili opozvanog tokena samo troši kvotu i odgađa dijagnozu.
Izradite produkcijsku naredbu
Naredba učitava zaštićenu konfiguraciju, poziva klijenta, atomski zapisuje izvještaj i emitira jedan JSON događaj zapisnika. Privremena datoteka sprječava čitatelje da vide djelomično zapisan artefakt.
<?php
// bin/scan-production.php
declare(strict_types=1);
use App\AnalyzerException;
use App\CurlTransport;
use App\SecurityAnalyzerClient;
require dirname(__DIR__) . '/vendor/autoload.php';
$envFile = $argv[1] ?? '/var/www/example/shared/.env.production';
$values = parse_ini_file($envFile, false, INI_SCANNER_RAW);
if (!is_array($values)) {
fwrite(STDERR, "Could not load production environment\n");
exit(1);
}
$token = $values['WEBSITE_SECURITY_TOKEN'] ?? '';
$url = $values['PUBLIC_SITE_URL'] ?? '';
$reportPath = $values['SECURITY_REPORT_PATH'] ?? '';
if ($token === '' || $url === '' || $reportPath === '') {
fwrite(STDERR, "Required security scan configuration is missing\n");
exit(1);
}
$client = new SecurityAnalyzerClient(
new CurlTransport(),
static fn (int $milliseconds) => usleep($milliseconds * 1_000)
);
try {
$report = $client->analyze($url, $token);
$document = [
'scanned_at' => gmdate(DATE_ATOM),
'url' => $url,
'report' => $report->toArray(),
];
$json = json_encode(
$document,
JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES | JSON_THROW_ON_ERROR
);
$temporaryPath = $reportPath . '.tmp';
if (file_put_contents($temporaryPath, $json . PHP_EOL, LOCK_EX) === false
|| !rename($temporaryPath, $reportPath)) {
throw new RuntimeException('Could not persist the scan report');
}
fwrite(STDOUT, json_encode([
'event' => 'website_security_scan_completed',
'url' => $url,
'score' => $report->score,
'finding_groups' => array_map('count', $report->findings),
], JSON_THROW_ON_ERROR) . PHP_EOL);
exit(0);
} catch (AnalyzerException $exception) {
fwrite(STDERR, json_encode([
'event' => 'website_security_scan_failed',
'url' => $url,
'kind' => $exception->kind,
'http_status' => $exception->httpStatus,
], JSON_THROW_ON_ERROR) . PHP_EOL);
exit(2);
} catch (Throwable $exception) {
fwrite(STDERR, json_encode([
'event' => 'website_security_scan_failed',
'url' => $url,
'kind' => 'local',
], JSON_THROW_ON_ERROR) . PHP_EOL);
exit(3);
}
Zapisnici namjerno izostavljaju token, tijelo odgovora i detaljne nalaze. Pošaljite strukturirane događaje na isto odredište zapisnika kao i događaje implementacije, a zatim upozorite na nenulti izlaz ili nedostajući događaj završetka. Detaljne izvještaje čuvajte u pohrani s kontroliranim pristupom jer nalazi mogu opisivati slabosti korisne napadaču.
Testirajte ponovne pokušaje i mapiranje odgovora bez mreže
Deterministički lažni transport dokazuje izgradnju payloada, mapiranje i ponašanje pri neuspjehu bez trošenja kvote ili ovisnosti o dostupnosti usluge.
<?php
// tests/SecurityAnalyzerClientTest.php
declare(strict_types=1);
namespace Tests;
use App\HttpResponse;
use App\SecurityAnalyzerClient;
use App\Transport;
use PHPUnit\Framework\TestCase;
final class FakeTransport implements Transport
{
public array $requests = [];
public function __construct(private array $responses) {}
public function send(string $token, array $payload): HttpResponse
{
$this->requests[] = compact('token', 'payload');
return array_shift($this->responses);
}
}
final class SecurityAnalyzerClientTest extends TestCase
{
public function testMapsSuccessfulReport(): void
{
$transport = new FakeTransport([
new HttpResponse(200, [], json_encode([
'score' => 91,
'findings' => ['high' => [], 'low' => [['name' => 'example']]],
'tls' => ['enabled' => true],
'recommendations' => ['Review browser policy.'],
], JSON_THROW_ON_ERROR)),
]);
$client = new SecurityAnalyzerClient($transport, static fn (int $ms) => null);
$report = $client->analyze('https://www.example.com', 'test-token');
self::assertSame(91.0, $report->score);
self::assertCount(1, $report->findings['low']);
self::assertSame(
['url' => 'https://www.example.com'],
$transport->requests[0]['payload']
);
}
public function testRetriesRateLimitThenSucceeds(): void
{
$transport = new FakeTransport([
new HttpResponse(429, ['retry-after' => '1'], ''),
new HttpResponse(200, [], json_encode([
'score' => 80,
'findings' => [],
'tls' => [],
'recommendations' => [],
], JSON_THROW_ON_ERROR)),
]);
$delays = [];
$client = new SecurityAnalyzerClient(
$transport,
static function (int $ms) use (&$delays): void {
$delays[] = $ms;
}
);
$client->analyze('https://www.example.com', 'test-token');
self::assertCount(2, $transport->requests);
self::assertSame([1_000], $delays);
}
}
Pokrenite composer install, a zatim composer test. Vrijednosti fixturea sintetički su podaci na granici aplikacije, a ne navodna mjerenja usluge, i lažni token se nikada ne može autentikirati.
Sigurno ga povežite s implementacijom
Nakon prebacivanja simboličke poveznice izdanja i dovršetka javne provjere spremnosti, pozovite:
php /var/www/example/current/bin/scan-production.php \
/var/www/example/shared/.env.production
Omogućite procesu otprilike kombinirano vremensko ograničenje i ograničeni prozor ponovnih pokušaja. Nemojte ga pokretati u nepromatranoj pozadinskoj ljusci. Zabilježite njegov izlazni kod i zapisnike u sustavu implementacije, uz izričitu odluku treba li izlazni kod 2 označiti implementaciju nestabilnom ili pokrenuti obavijest.
Uobičajeni neuspjesi obično su jednostavni:
- HTTP 401 ili 403: provjerite aktivaciju i zamijenite opozvani ili pogrešno ograničeni token. Nemojte automatski pokušavati ponovno.
- HTTP 400 ili 422: potvrdite da je
PUBLIC_SITE_URLjavno HTTPS ishodište, a ne interni naziv hosta. - HTTP 429: klijent poštuje ograničeni numerički
Retry-After, ali trajno iscrpljivanje kvote zahtijeva pregled plana ili učestalosti implementacije. - Neuspjeh transporta ili 5xx: zadržite prethodni izvještaj, emitirajte događaj neuspjeha i pokušajte ponovno samo unutar ograničenih pravila.
- Neuspjeh ugovora: sačuvajte sirovi odgovor samo u sigurnom dijagnostičkom procesu; nemojte tiho pretvarati neispravna polja u uredan izvještaj.
Završni kontrolni popis za provjeru
- Plan usluge je aktivan, a token ograničen na uslugu nalazi se samo u zaštićenoj konfiguraciji okruženja.
- Naredba šalje točno jedno JSON polje
urlna dokumentirani POST endpoint. - Vrijeme povezivanja i ukupno vrijeme odgovora ograničeni su, bez ponovnih pokušaja za pogreške autentikacije ili validacije.
- Testovi prolaze s determinističkim lažnim transportom i ne šalju vanjske zahtjeve.
- Dovršena implementacija pokreće naredbu nad stvarnim javnim HTTPS URL-om.
- Uspjeh stvara atomski izvještaj i strukturirani događaj završetka; neuspjeh stvara kategorizirani nenulti rezultat.
- Operateri razumiju da je izlaz ograničena analiza sigurnosne razine, a ne dokaz da je web-mjesto prošlo penetracijski test.
Trajna vrijednost nije jedan ohrabrujući rezultat. To je navika provjeravanja javnog sustava nakon što se stvarnost promijenila. Kada se svaka produkcijska implementacija završava ograničenom, vidljivom sigurnosnom provjerom, regresije preglednika i TLS-a postaju uobičajeni inženjerski signali: vidljivi, djelotvorni i mnogo je manje vjerojatno da će ostati nezapaženi.