Vodiči

Native PHP 8.3: Automate Website Stack Audits for Redesign Quotes

Nativni PHP 8.3: automatizirajte revizije web-stacka za ponude redizajna

Ponuda za redizajn može se raspasti kada se ispostavi da „jednostavna brošurna web-stranica” sadržava naslijeđeni CMS, nekoliko analitičkih proizvoda, CDN, ugrađeni sustav e-trgovine i JavaScript ovisnosti koje nitko nije spomenuo. Provjera tehnološkog sklopa prije procjene posla zamjenjuje nagađanja dokazima.

Ovaj vodič izrađuje produkcijski usmjerenu Native PHP 8.3 aplikaciju naredbenog retka koja šalje javni URL klijenta API-ju Website Technology Detector, pretvara rezultate s ocjenom pouzdanosti u domenske objekte i izrađuje ponovno upotrebljiv JSON audit za ponudu za redizajn. Dizajn drži HTTP aspekte izoliranima, promišljeno obrađuje prolazne neuspjehe i ostaje jednostavan za testiranje i implementaciju.

Dobijte pristup i kopirajte servisni token

Najprije izradite račun na https://ai.mihajlo.mk/register ili upotrijebite https://ai.mihajlo.mk/login ako ga već imate.

  1. Otvorite stranicu usluge Website Technology Detector.
  2. Odaberite dostupni plan Free, Plus ili Pro i dovršite njegovu aktivaciju.
  3. Otvorite službenu dokumentaciju usluge.
  4. Pronađite ploču Service token i kopirajte token ograničen na uslugu.
  5. Pohranite ga u konfiguraciju okruženja projekta, nikada u PHP izvorni kôd.

Ova usluga zahtijeva autentikaciju; nema način rada bez tokena. Prihvaća Bearer token, zaglavlje X-API-Token ili parametar upita token. Upotrijebit ćemo Bearer oblik jer autentikaciju drži izvan URL-ova i zapisnika pristupa.

Ponovno generiranje servisnog tokena opoziva prethodno aktivni token. Rotaciju tretirajte kao operaciju implementacije: ažurirajte tajnu svugdje gdje se izvršava naredba za audit, provjerite novi token i tek tada uklonite svu zastarjelu konfiguraciju.

Potvrdite HTTP ugovor

Točan poziv je POST https://ai.mihajlo.mk/api/website-technology-detector/v1/detect-technologies. Njegovo JSON tijelo sadržava url.

Prije pisanja aplikacijskog kôda napravite jedan minimalni zahtjev. Zamijenite rezervirano mjesto lokalno, ali nemojte spremati dobivenu naredbu u datoteke povijesti ljuske ni u projektnu dokumentaciju sa stvarnim tokenom.

curl --request POST \
  --url https://ai.mihajlo.mk/api/website-technology-detector/v1/detect-technologies \
  --header "Authorization: Bearer YOUR_SERVICE_TOKEN" \
  --header "Accept: application/json" \
  --header "Content-Type: application/json" \
  --data '{"url":"https://example.com"}'

Odgovor sadržava detekcije tehnologija s pouzdanošću, dokazima, informacijama o verziji i informacijama o preusmjeravanju. Udaljeni JSON i dalje se mora tretirati kao nepouzdan ulaz: polja mogu nedostajati, biti prazna ili neispravna tijekom incidenta uzvodnog sustava ili budućeg prijelaza ugovora.

Oblikujte Native PHP projekt

Aplikacija je namjerno CLI naredba. Razvojni programer koji priprema ponudu može je pokrenuti prema potrebi, spremiti JSON uz ostale bilješke prikupljene tijekom istraživanja i izbjeći izlaganje javne krajnje točke koju bi bilo tko mogao koristiti za trošenje kvote računa.

Potrebni su vam PHP 8.3 ili noviji, ekstenzije cURL i JSON, Composer te PHPUnit 11 za testove. API granica ima četiri sloja: cURL transport, uslugu detektora s pravilima ponavljanja, domenske objekte odgovora i mali prilagodnik naredbe.

quote-stack-audit/
├── bin/audit-site
├── src/
│   ├── AuditReport.php
│   ├── DetectionResult.php
│   ├── HttpResponse.php
│   ├── Technology.php
│   ├── Transport.php
│   ├── TransportException.php
│   ├── CurlTransport.php
│   └── WebsiteDetector.php
├── tests/WebsiteDetectorTest.php
├── .env.example
├── .gitignore
├── composer.json
└── phpunit.xml

Najprije izradite datoteke ovisnosti i okruženja:

{
  "require": {
    "php": "^8.3",
    "ext-curl": "*",
    "ext-json": "*"
  },
  "require-dev": {
    "phpunit/phpunit": "^11.0"
  },
  "autoload": {
    "psr-4": {
      "QuoteAudit\\": "src/"
    }
  },
  "autoload-dev": {
    "psr-4": {
      "QuoteAudit\\Tests\\": "tests/"
    }
  },
  "scripts": {
    "test": "phpunit"
  }
}
# .env.example
WEBSITE_DETECTOR_TOKEN=YOUR_SERVICE_TOKEN

# .gitignore
.env.local
vendor/
build/

cp .env.example .env.local
composer install

Stvarni token stavite samo u .env.local. Predani primjer sadrži rezervirano mjesto. Produkcija treba ubrizgati istu varijablu putem upravitelja tajnama hosting platforme, umjesto implementiranja datoteke s tajnom.

Izgradite ograničeni cURL transport

Transport se povezuje samo na fiksnu HTTPS API krajnju točku. Koristi vremensko ograničenje povezivanja od tri sekunde i ukupno vremensko ograničenje od petnaest sekundi, čuva zaglavlja odgovora za obradu ograničenja stope i ostavlja provjeru certifikata uključenom.

<?php
// src/Transport.php
namespace QuoteAudit;

interface Transport
{
    public function send(string $json, string $token): HttpResponse;
}

// src/HttpResponse.php
namespace QuoteAudit;

final readonly class HttpResponse
{
    public function __construct(
        public int $status,
        public array $headers,
        public string $body,
    ) {}
}

// src/TransportException.php
namespace QuoteAudit;

final class TransportException extends \RuntimeException {}

// src/CurlTransport.php
namespace QuoteAudit;

final class CurlTransport implements Transport
{
    private const ENDPOINT =
        'https://ai.mihajlo.mk/api/website-technology-detector/v1/detect-technologies';

    public function send(string $json, string $token): HttpResponse
    {
        $handle = curl_init(self::ENDPOINT);

        curl_setopt_array($handle, [
            CURLOPT_POST => true,
            CURLOPT_POSTFIELDS => $json,
            CURLOPT_HTTPHEADER => [
                'Authorization: Bearer ' . $token,
                'Accept: application/json',
                'Content-Type: application/json',
            ],
            CURLOPT_RETURNTRANSFER => true,
            CURLOPT_HEADER => true,
            CURLOPT_CONNECTTIMEOUT_MS => 3000,
            CURLOPT_TIMEOUT_MS => 15000,
            CURLOPT_FOLLOWLOCATION => false,
            CURLOPT_PROTOCOLS => CURLPROTO_HTTPS,
            CURLOPT_SSL_VERIFYPEER => true,
            CURLOPT_SSL_VERIFYHOST => 2,
        ]);

        $raw = curl_exec($handle);

        if ($raw === false) {
            throw new TransportException(curl_error($handle));
        }

        $status = curl_getinfo($handle, CURLINFO_RESPONSE_CODE);
        $headerSize = curl_getinfo($handle, CURLINFO_HEADER_SIZE);
        $headerText = substr($raw, 0, $headerSize);
        $body = substr($raw, $headerSize);
        $headers = [];

        foreach (preg_split('/\r\n|\r|\n/', $headerText) as $line) {
            if (!str_contains($line, ':')) {
                continue;
            }

            [$name, $value] = explode(':', $line, 2);
            $headers[strtolower(trim($name))] = trim($value);
        }

        return new HttpResponse($status, $headers, $body);
    }
}

Preslikajte udaljeni JSON u domenske rezultate

Ponuda ne bi trebala izravno ovisiti o proizvoljnom nizu odgovora. Domenski model zadržava korisne koncepte, a istodobno omogućuje dokazima, verzijama i informacijama o preusmjeravanju da sačuvaju strukturirane vrijednosti umjesto prisilnog pretvaranja u obmanjujuće nizove.

<?php
// src/Technology.php
namespace QuoteAudit;

final readonly class Technology implements \JsonSerializable
{
    public function __construct(
        public string $name,
        public ?float $confidence,
        public array $evidence,
        public array $versions,
    ) {}

    public function jsonSerialize(): array
    {
        return get_object_vars($this);
    }
}

// src/AuditReport.php
namespace QuoteAudit;

final readonly class AuditReport implements \JsonSerializable
{
    public function __construct(
        public string $requestedUrl,
        public array $technologies,
        public array $redirectInformation,
    ) {}

    public function jsonSerialize(): array
    {
        return get_object_vars($this);
    }
}

// src/DetectionResult.php
namespace QuoteAudit;

final readonly class DetectionResult implements \JsonSerializable
{
    private function __construct(
        public bool $ok,
        public ?AuditReport $report,
        public ?string $error,
        public ?int $httpStatus,
    ) {}

    public static function success(AuditReport $report): self
    {
        return new self(true, $report, null, null);
    }

    public static function failure(string $error, ?int $status = null): self
    {
        return new self(false, null, $error, $status);
    }

    public function jsonSerialize(): array
    {
        return get_object_vars($this);
    }
}

Usluga ponavlja samo greške transporta, HTTP 408, HTTP 429 i odgovore poslužitelja 5xx. Neuspjesi autentikacije i validacije ne ponavljaju se. Odgoda je ograničena, poštuje cjelobrojnu vrijednost Retry-After kada je prisutna te inače dodaje malu slučajnu varijaciju.

<?php
// src/WebsiteDetector.php
namespace QuoteAudit;

final class WebsiteDetector
{
    private readonly \Closure $sleep;
    private readonly \Closure $log;

    public function __construct(
        private readonly Transport $transport,
        private readonly string $token,
        ?\Closure $sleep = null,
        ?\Closure $log = null,
    ) {
        $this->sleep = $sleep ?? static fn(int $ms) => usleep($ms * 1000);
        $this->log = $log ?? static function (array $context): void {};
    }

    public function detect(string $url): DetectionResult
    {
        $parts = parse_url($url);
        $scheme = strtolower((string) ($parts['scheme'] ?? ''));

        if (
            filter_var($url, FILTER_VALIDATE_URL) === false ||
            !in_array($scheme, ['http', 'https'], true) ||
            isset($parts['user']) ||
            isset($parts['pass'])
        ) {
            return DetectionResult::failure('invalid_url');
        }

        $body = json_encode(['url' => $url], JSON_THROW_ON_ERROR);

        for ($attempt = 1; $attempt <= 3; $attempt++) {
            try {
                $response = $this->transport->send($body, $this->token);
            } catch (TransportException $exception) {
                ($this->log)([
                    'event' => 'detector_transport_error',
                    'attempt' => $attempt,
                    'target_host' => $parts['host'] ?? null,
                ]);

                if ($attempt === 3) {
                    return DetectionResult::failure('transport_error');
                }

                ($this->sleep)($this->backoff($attempt, null));
                continue;
            }

            ($this->log)([
                'event' => 'detector_response',
                'attempt' => $attempt,
                'status' => $response->status,
                'target_host' => $parts['host'] ?? null,
            ]);

            if ($response->status >= 200 && $response->status < 300) {
                try {
                    $payload = json_decode(
                        $response->body,
                        true,
                        512,
                        JSON_THROW_ON_ERROR
                    );

                    if (!is_array($payload)) {
                        throw new \UnexpectedValueException();
                    }

                    return DetectionResult::success($this->map($url, $payload));
                } catch (\JsonException|\UnexpectedValueException) {
                    return DetectionResult::failure(
                        'malformed_response',
                        $response->status
                    );
                }
            }

            $retryable = $response->status === 408 ||
                $response->status === 429 ||
                $response->status >= 500;

            if ($retryable && $attempt < 3) {
                ($this->sleep)($this->backoff(
                    $attempt,
                    $response->headers['retry-after'] ?? null
                ));
                continue;
            }

            if (in_array($response->status, [401, 403], true)) {
                return DetectionResult::failure(
                    'authentication_failed',
                    $response->status
                );
            }

            if ($response->status === 429) {
                return DetectionResult::failure(
                    'rate_limited',
                    $response->status
                );
            }

            return DetectionResult::failure(
                $response->status >= 500
                    ? 'upstream_unavailable'
                    : 'request_rejected',
                $response->status
            );
        }

        return DetectionResult::failure('unexpected_failure');
    }

    private function map(string $url, array $payload): AuditReport
    {
        $rows = $this->findCollection($payload);

        if ($rows === null) {
            throw new \UnexpectedValueException();
        }

        $technologies = [];

        foreach ($rows as $row) {
            if (!is_array($row) || !is_string($row['name'] ?? null)) {
                continue;
            }

            $confidence = $row['confidence'] ?? null;

            $technologies[] = new Technology(
                trim($row['name']),
                is_numeric($confidence) ? (float) $confidence : null,
                $this->asList($row['evidence'] ?? null),
                $this->asList($row['versions'] ?? null),
            );
        }

        if ($rows !== [] && $technologies === []) {
            throw new \UnexpectedValueException();
        }

        return new AuditReport(
            $url,
            $technologies,
            $this->collectRedirectInformation($payload)
        );
    }

    private function findCollection(array $node): ?array
    {
        foreach ($node as $key => $value) {
            if (
                is_string($key) &&
                in_array(strtolower($key), ['technologies', 'detections'], true) &&
                is_array($value)
            ) {
                return $value;
            }

            if (is_array($value)) {
                $found = $this->findCollection($value);

                if ($found !== null) {
                    return $found;
                }
            }
        }

        return null;
    }

    private function collectRedirectInformation(
        array $node,
        string $path = ''
    ): array {
        $result = [];

        foreach ($node as $key => $value) {
            $current = $path === '' ? (string) $key : $path . '.' . $key;

            if (is_string($key) && str_contains(strtolower($key), 'redirect')) {
                $result[$current] = $value;
            } elseif (is_array($value)) {
                $result += $this->collectRedirectInformation($value, $current);
            }
        }

        return $result;
    }

    private function asList(mixed $value): array
    {
        if ($value === null) {
            return [];
        }

        return is_array($value) ? array_values($value) : [$value];
    }

    private function backoff(int $attempt, ?string $retryAfter): int
    {
        if ($retryAfter !== null && ctype_digit(trim($retryAfter))) {
            return min(2000, (int) $retryAfter * 1000);
        }

        return min(2000, 250 * (2 ** ($attempt - 1)) + random_int(0, 100));
    }
}

Dodajte naredbu za audit

Ulazna točka učitava jednostavnu lokalnu datoteku okruženja, zapisuje strukturirane operativne događaje na standardni izlaz za pogreške i rezervira standardni izlaz za konačni JSON artefakt. Nikada ne zapisuje token, tijelo odgovora ni potpuni URL klijenta.

<?php
// bin/audit-site
declare(strict_types=1);

use QuoteAudit\CurlTransport;
use QuoteAudit\WebsiteDetector;

require dirname(__DIR__) . '/vendor/autoload.php';

function loadEnvironment(string $file): void
{
    if (!is_file($file)) {
        return;
    }

    foreach (file($file, FILE_IGNORE_NEW_LINES | FILE_SKIP_EMPTY_LINES) as $line) {
        $line = trim($line);

        if ($line === '' || str_starts_with($line, '#')) {
            continue;
        }

        [$key, $value] = array_pad(explode('=', $line, 2), 2, '');

        if (!preg_match('/^[A-Z_][A-Z0-9_]*$/', $key)) {
            continue;
        }

        $value = trim($value);
        if (
            strlen($value) >= 2 &&
            (($value[0] === '"' && str_ends_with($value, '"')) ||
             ($value[0] === "'" && str_ends_with($value, "'")))
        ) {
            $value = substr($value, 1, -1);
        }

        if (getenv($key) === false) {
            putenv($key . '=' . $value);
        }
    }
}

loadEnvironment(dirname(__DIR__) . '/.env.local');

$url = $argv[1] ?? '';
$token = getenv('WEBSITE_DETECTOR_TOKEN');

if (!is_string($token) || $token === '' || $token === 'YOUR_SERVICE_TOKEN') {
    fwrite(STDERR, "WEBSITE_DETECTOR_TOKEN is not configured.\n");
    exit(78);
}

$logger = static function (array $context): void {
    fwrite(STDERR, json_encode($context, JSON_UNESCAPED_SLASHES) . PHP_EOL);
};

$result = (new WebsiteDetector(
    new CurlTransport(),
    $token,
    log: $logger
))->detect($url);

echo json_encode(
    $result,
    JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES | JSON_THROW_ON_ERROR
) . PHP_EOL;

exit($result->ok ? 0 : 1);

Pokrenite je s javnim URL-om klijenta i samo uspješan izlaz spremite kao artefakt ponude:

chmod +x bin/audit-site
php bin/audit-site https://example.com > build/example-com-stack.json

Testirajte ponavljanja bez pozivanja usluge

Deterministički lažni transport čini putanje neuspjeha brzima i bez trošenja kvote. Najvažniji testovi dokazuju da se ograničenja stope ponavljaju, neuspjesi autentikacije ne ponavljaju te da neispravna uspješna tijela postaju strukturirani neuspjesi.

<?php
// tests/WebsiteDetectorTest.php
namespace QuoteAudit\Tests;

use PHPUnit\Framework\TestCase;
use QuoteAudit\HttpResponse;
use QuoteAudit\Transport;
use QuoteAudit\WebsiteDetector;

final class FakeTransport implements Transport
{
    public int $calls = 0;

    public function __construct(private array $responses) {}

    public function send(string $json, string $token): HttpResponse
    {
        return $this->responses[$this->calls++];
    }
}

final class WebsiteDetectorTest extends TestCase
{
    public function testRetriesRateLimitThenMapsDetection(): void
    {
        $fake = new FakeTransport([
            new HttpResponse(429, ['retry-after' => '0'], '{}'),
            new HttpResponse(200, [], json_encode([
                'technologies' => [[
                    'name' => 'PHP',
                    'confidence' => 95,
                    'evidence' => ['response evidence'],
                    'versions' => ['8.3'],
                ]],
                'redirects' => [],
            ], JSON_THROW_ON_ERROR)),
        ]);

        $result = (new WebsiteDetector(
            $fake,
            'test-token',
            static function (int $milliseconds): void {}
        ))->detect('https://example.com');

        self::assertTrue($result->ok);
        self::assertSame(2, $fake->calls);
        self::assertSame('PHP', $result->report->technologies[0]->name);
    }

    public function testDoesNotRetryAuthenticationFailure(): void
    {
        $fake = new FakeTransport([
            new HttpResponse(401, [], '{}'),
        ]);

        $result = (new WebsiteDetector(
            $fake,
            'invalid-token',
            static function (int $milliseconds): void {}
        ))->detect('https://example.com');

        self::assertFalse($result->ok);
        self::assertSame('authentication_failed', $result->error);
        self::assertSame(1, $fake->calls);
    }

    public function testRejectsMalformedSuccessBody(): void
    {
        $fake = new FakeTransport([
            new HttpResponse(200, [], '{"unexpected":true}'),
        ]);

        $result = (new WebsiteDetector(
            $fake,
            'test-token'
        ))->detect('https://example.com');

        self::assertSame('malformed_response', $result->error);
    }
}
<!-- phpunit.xml -->
<?xml version="1.0" encoding="UTF-8"?>
<phpunit bootstrap="vendor/autoload.php" colors="true">
    <testsuites>
        <testsuite name="quote-audit">
            <directory>tests</directory>
        </testsuite>
    </testsuites>
</phpunit>
composer test

Sigurnost, opažljivost i implementacija

Poslani URL otkriva se vanjskoj usluzi, stoga izbjegavajte URL-ove koji sadržavaju privatne parametre upita, tokene za pregled, identifikatore kupaca ili vjerodajnice. Ovaj projekt prihvaća samo HTTP i HTTPS URL-ove bez ugrađenih korisničkih imena ili lozinki. Ako naredba postane web-funkcionalnost, prije njezina izlaganja dodajte autorizaciju, kvote, pravilo odobrenih hostova i pozadinsko izvršavanje.

Zapisnike držite operativnima, a ne forenzičkima. Zabilježite naziv hosta cilja, broj pokušaja, HTTP status, trajanje i kategoriju završnog neuspjeha. Nikada ne bilježite autorizacijska zaglavlja ni potpuna tijela odgovora. Zasebno nadzirite ponovljene ishode authentication_failed, rate_limited i upstream_unavailable jer zahtijevaju različite odgovore.

Implementirajte s uključenim PHP ekstenzijama cURL i JSON, pokrenite composer install --no-dev --classmap-authoritative i ubrizgajte WEBSITE_DETECTOR_TOKEN iz spremišta tajni. Planirajte rotaciju tokena promišljeno jer regeneriranje odmah poništava prethodno aktivni token.

Uobičajeni neuspjesi

  • 401 ili 403: provjerite token ograničen na uslugu, aktivaciju plana i tajnu implementacije. Nemojte automatski ponavljati.
  • 429: poštujte ograničenu politiku ponavljanja, zatim odgodite audit ili pregledajte kapacitet plana.
  • Neispravan odgovor: sačuvajte kategoriju neuspjeha i istražite problem bez tihog tretiranja kao praznog popisa tehnologija.
  • Vremensko ograničenje ili 5xx: dopustite da ograničena ponavljanja završe, zatim pokrenite ponovno kasnije umjesto da pripremu ponude blokirate na neodređeno vrijeme.
  • Dokazi niske pouzdanosti: uključite ih u bilješke istraživanja, ali ih ručno potvrdite prije nego što ih pretvorite u opseg ili cijenu.

Završni kontrolni popis za provjeru

  • Stvarni token postoji samo u konfiguraciji podržanoj okruženjem.
  • Minimalni API zahtjev uspijeva s točnom POST krajnjom točkom.
  • composer test prolazi bez mrežnog pristupa.
  • Uspješan audit sadržava preslikane tehnologije, pouzdanost, dokaze, verzije i dostupne informacije o preusmjeravanju.
  • Neuspjesi autentikacije izvršavaju jedan zahtjev; prolazni neuspjesi ne izvršavaju više od tri.
  • Zapisnici ne sadržavaju token, tijelo odgovora ni osjetljive URL podatke.
  • Generirani JSON pregledava se uz web-stranicu umjesto da se tretira kao nepogrešiv opseg.

Detektor tehnološkog sklopa ne piše ponudu za redizajn umjesto vas. Čini nešto vrjednije: prvi tehnički razgovor pretvara iz nagađanja u pregled potkrijepljen dokazima. Kada je audit ograničen, testabilan i izričit u vezi s neizvjesnošću, mala PHP naredba postaje pouzdan dio profesionalnog istraživanja umjesto još jedne krhke skripte.

Portret autora bloga

Mihajlo

Ja sam Mihajlo — programer vođen znatiželjom, disciplinom i stalnom željom da stvorim nešto smisleno. Dijelim uvide, tutorijale i besplatne usluge kako bih pomogao drugima da pojednostave svoj rad i rastu u svijetu softvera i umjetne inteligencije koji se neprestano razvija.