Vodiči

Native PHP 8.3: Weekly Website Security Score Alerts for Small Business Owners

Izvorni PHP 8.3: tjedna upozorenja o sigurnosnoj ocjeni web-stranice za vlasnike malih poduzeća

Web-mjesto može ostati na mreži dok tiho postaje manje sigurno. Obnova certifikata se promijeni, sigurnosno zaglavlje nestane tijekom redizajna ili ažuriranje hostinga oslabi zaštite preglednika. Vlasnicima malih poduzeća rijetko treba još jedna nadzorna ploča; treba im jasno upozorenje kada se nešto pogoršalo.

Ovaj vodič izrađuje to upozorenje kao aplikaciju Native PHP 8.3. Jednom tjedno šalje jedan javni HTTPS URL analizatoru sigurnosti web-mjesta, mapira vraćeni rezultat, nalaze grupirane prema ozbiljnosti, TLS pojedinosti i preporuke, uspoređuje rezultat s prethodnim uspješnim rezultatom te vlasniku šalje e-poruku samo kada rezultat padne.

Analiza je ograničena i neinvazivna. Procjenjuje javni HTTPS i sigurnosni položaj preglednika; nije penetracijski test, iskorištavanje ranjivosti, autentificirano skeniranje ni jamstvo sigurnosti.

Dobijte pristup i kopirajte token usluge

Registrirajte se na https://ai.mihajlo.mk/register, ili upotrijebite https://ai.mihajlo.mk/login ako već imate račun.

  1. Otvorite stranicu usluge Website Security Analyzer.
  2. Odaberite dostupan Free, Plus ili Pro plan 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. Odmah premjestite token u konfiguraciju koja se oslanja na varijable okruženja.

Ponovno generiranje tokena usluge opoziva prethodno aktivni token, stoga uskladite rotaciju s implementacijom. Ovo nije usluga bez tokena: svaki zahtjev mora se autentificirati pomoću Bearer tokena, zaglavlja X-API-Token ili dokumentiranog parametra tokena u upitu. Donja implementacija koristi Bearer token jer vjerodajnicu zadržava izvan URL-ova i uobičajenih zapisnika pristupa.

Potvrdite API ugovor prije izrade raspoređivača

Točan zahtjev je POST https://ai.mihajlo.mk/api/website-security-analyzer-api/v1/analyze-website. Njegovo JSON tijelo sadržava url. Testirajte račun jednim minimalnim zahtjevom, zamjenjujući kopirani token samo u svojoj privatnoj terminalskoj sesiji:

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

Ne lijepite odgovor ni token u zahtjeve za podršku, snimke zaslona, fixtureove ili kontrolu izvornog koda. Izradite lokalnu datoteku .env, isključite je iz Gita i koristite rezervirana mjesta u .env.example:

SECURITY_ANALYZER_TOKEN=YOUR_SERVICE_TOKEN
WEBSITE_URL=https://www.example.com
[email protected]
[email protected]
STATE_FILE=/srv/site-watch/var/security-score.json

Zadržite arhitekturu malom i eksplicitnom

Aplikaciji su potrebne samo četiri odgovornosti u produkciji: cURL transport, API granica koja validira vanjske podatke, tjedni slučaj uporabe te prilagodnici za stanje i e-poštu. Baza podataka i red poruka dodali bi operativno opterećenje bez poboljšanja tjedne provjere jednog web-mjesta.

Datoteka stanja prikladna je jer samo jedan zakazani proces zapisuje jedan rezultat. Zaključavanje procesa sprečava preklapanje pokretanja, dok atomsko preimenovanje sprečava čitatelje da vide djelomično zapisanu datoteku. Ako će nekoliko aplikacijskih čvorova izvršavati provjeru, zamijenite oba mehanizma zajedničkom pohranom i distribuiranom bravom.

Upotrijebite Composer za automatsko učitavanje, učitavanje okruženja i PHPUnit:

{
  "name": "example/site-watch",
  "require": {
    "php": "^8.3",
    "ext-curl": "*",
    "vlucas/phpdotenv": "^5.6"
  },
  "require-dev": {
    "phpunit/phpunit": "^11.0"
  },
  "autoload": {
    "classmap": ["src/"]
  },
  "autoload-dev": {
    "classmap": ["tests/"]
  },
  "scripts": {
    "test": "phpunit"
  }
}
composer install
mkdir -p src bin tests var
chmod 700 var
printf '%s\n' '.env' '/var/' >> .gitignore
composer dump-autoload

Dobiveni projekt sadržava src/SecurityAnalyzer.php, src/WeeklyCheck.php, bin/check-security.php, tests/SecurityAnalyzerTest.php, composer.json i privatnu datoteku .env.

Izradite obrambenu API granicu

Vanjski JSON je nepouzdan ulaz čak i kada dolazi od usluge kojom namjerno upravljate. Mapper u nastavku pretražuje ograničenu dubinu ugniježđivanja za dokumentirana polja analize, zatim validira njihove općenite oblike bez izmišljanja TLS potpolja ili atributa nalaza. Time se čuva odgovor usluge, a neispravni podaci sprječavaju da prodru dublje u aplikaciju.

<?php
declare(strict_types=1);

namespace App;

use Closure;
use JsonException;
use RuntimeException;
use UnexpectedValueException;

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

interface HttpTransport
{
    public function postJson(string $url, string $token, array $body): HttpResponse;
}

final class TransportException extends RuntimeException {}

final class CurlTransport implements HttpTransport
{
    public function postJson(string $url, string $token, array $body): HttpResponse
    {
        $headers = [];
        $handle = curl_init($url);

        curl_setopt_array($handle, [
            CURLOPT_POST => true,
            CURLOPT_RETURNTRANSFER => true,
            CURLOPT_CONNECTTIMEOUT_MS => 3000,
            CURLOPT_TIMEOUT_MS => 15000,
            CURLOPT_HTTPHEADER => [
                'Accept: application/json',
                'Authorization: Bearer ' . $token,
                'Content-Type: application/json',
            ],
            CURLOPT_POSTFIELDS => json_encode($body, JSON_THROW_ON_ERROR),
            CURLOPT_HEADERFUNCTION => static function ($curl, string $line) use (&$headers): int {
                $parts = explode(':', $line, 2);
                if (count($parts) === 2) {
                    $headers[strtolower(trim($parts[0]))] = trim($parts[1]);
                }
                return strlen($line);
            },
        ]);

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

        return new HttpResponse(
            (int) curl_getinfo($handle, CURLINFO_RESPONSE_CODE),
            $bodyText,
            $headers,
        );
    }
}

final readonly class Analysis
{
    public function __construct(
        public float $score,
        public array $findingsBySeverity,
        public array $tls,
        public array $recommendations,
    ) {}
}

interface Analyzer
{
    public function analyze(string $url): Analysis;
}

final class AnalyzerException extends RuntimeException
{
    public function __construct(
        string $message,
        public readonly string $kind,
        public readonly ?int $status = null,
    ) {
        parent::__construct($message);
    }
}

final class SecurityAnalyzerClient implements Analyzer
{
    private const ENDPOINT =
        'https://ai.mihajlo.mk/api/website-security-analyzer-api/v1/analyze-website';

    private readonly Closure $sleep;

    public function __construct(
        private readonly HttpTransport $transport,
        private readonly string $token,
        ?callable $sleep = null,
    ) {
        if ($token === '') {
            throw new RuntimeException('SECURITY_ANALYZER_TOKEN is empty');
        }

        $this->sleep = Closure::fromCallable(
            $sleep ?? static fn (int $milliseconds) => usleep($milliseconds * 1000)
        );
    }

    public function analyze(string $url): Analysis
    {
        if (filter_var($url, FILTER_VALIDATE_URL) === false
            || parse_url($url, PHP_URL_SCHEME) !== 'https') {
            throw new RuntimeException('WEBSITE_URL must be a valid HTTPS URL');
        }

        for ($attempt = 1; $attempt <= 3; $attempt++) {
            try {
                $response = $this->transport->postJson(
                    self::ENDPOINT,
                    $this->token,
                    ['url' => $url],
                );
            } catch (TransportException $exception) {
                if ($attempt === 3) {
                    throw new AnalyzerException(
                        'Network failure after retries',
                        'network',
                        previous: $exception,
                    );
                }

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

            if ($response->status >= 200 && $response->status < 300) {
                return $this->map($response->body);
            }

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

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

            $kind = match (true) {
                in_array($response->status, [401, 403], true) => 'authentication',
                in_array($response->status, [400, 422], true) => 'validation',
                $response->status === 429 => 'quota',
                $response->status >= 500 => 'upstream',
                default => 'http',
            };

            throw new AnalyzerException(
                'Analyzer request failed with HTTP ' . $response->status,
                $kind,
                $response->status,
            );
        }

        throw new AnalyzerException('Analyzer retry loop exhausted', 'internal');
    }

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

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

    private function map(string $body): Analysis
    {
        try {
            $decoded = json_decode($body, true, flags: JSON_THROW_ON_ERROR);
        } catch (JsonException $exception) {
            throw new AnalyzerException('Analyzer returned invalid JSON', 'response', previous: $exception);
        }

        if (!is_array($decoded)) {
            throw new AnalyzerException('Analyzer response is not an object', 'response');
        }

        $data = $this->locateAnalysis($decoded);
        if (!is_numeric($data['score'])
            || !is_array($data['findings'])
            || !is_array($data['tls'])
            || !is_array($data['recommendations'])) {
            throw new AnalyzerException('Analyzer response has invalid field types', 'response');
        }

        $groups = [];
        foreach ($data['findings'] as $severity => $items) {
            if (!is_string($severity) || $severity === '' || !is_array($items)) {
                throw new AnalyzerException('Findings are not grouped by severity', 'response');
            }
            $groups[$severity] = array_values($items);
        }

        return new Analysis(
            (float) $data['score'],
            $groups,
            $data['tls'],
            array_values($data['recommendations']),
        );
    }

    private function locateAnalysis(array $node, int $depth = 0): array
    {
        $required = ['score', 'findings', 'tls', 'recommendations'];
        if (count(array_intersect($required, array_keys($node))) === count($required)) {
            return $node;
        }

        if ($depth < 4) {
            foreach ($node as $value) {
                if (is_array($value)) {
                    try {
                        return $this->locateAnalysis($value, $depth + 1);
                    } catch (UnexpectedValueException) {
                    }
                }
            }
        }

        throw new UnexpectedValueException('Analysis fields were not found');
    }
}

Politika ponovnih pokušaja namjerno je uska. Neuspjesi povezivanja, vremenska ograničenja, HTTP 408, HTTP 429 i neuspjesi poslužitelja mogu biti prolazni. Neuspjesi validacije i autentifikacije ne ponavljaju se naslijepo. Numerička vrijednost Retry-After poštuje se, ali je ograničena na 30 sekundi kako se jedan zakazani proces ne bi neograničeno zaustavio.

Usporedite rezultate i pošaljite jednu korisnu e-poruku

Slučaj uporabe sprema novi rezultat nakon uspješne obavijesti. Ako slanje e-pošte ne uspije, stari rezultat ostaje na mjestu, što omogućuje sljedećem pokretanju da pokuša ponovno. Ovaj redoslijed ovisi o zaključavanju jednog procesa na razini implementacije, prikazanom kasnije.

<?php
declare(strict_types=1);

namespace App;

use RuntimeException;

interface ScoreStore
{
    public function load(string $url): ?float;
    public function save(string $url, float $score): void;
}

final readonly class FileScoreStore implements ScoreStore
{
    public function __construct(private string $path) {}

    public function load(string $url): ?float
    {
        if (!is_file($this->path)) {
            return null;
        }

        $data = json_decode((string) file_get_contents($this->path), true);
        return is_array($data)
            && ($data['url'] ?? null) === $url
            && is_numeric($data['score'] ?? null)
                ? (float) $data['score']
                : null;
    }

    public function save(string $url, float $score): void
    {
        $directory = dirname($this->path);
        $temporary = tempnam($directory, 'score-');
        if ($temporary === false) {
            throw new RuntimeException('Cannot create temporary state file');
        }

        try {
            $json = json_encode([
                'url' => $url,
                'score' => $score,
                'checked_at' => gmdate(DATE_ATOM),
            ], JSON_THROW_ON_ERROR | JSON_PRETTY_PRINT);

            if (file_put_contents($temporary, $json, LOCK_EX) === false) {
                throw new RuntimeException('Cannot write score state');
            }

            chmod($temporary, 0600);
            if (!rename($temporary, $this->path)) {
                throw new RuntimeException('Cannot replace score state');
            }
        } finally {
            if (is_file($temporary)) {
                unlink($temporary);
            }
        }
    }
}

interface OwnerMailer
{
    public function scoreDropped(
        string $recipient,
        string $url,
        float $previous,
        Analysis $analysis,
    ): void;
}

final readonly class NativeOwnerMailer implements OwnerMailer
{
    public function __construct(private string $from) {}

    public function scoreDropped(
        string $recipient,
        string $url,
        float $previous,
        Analysis $analysis,
    ): void {
        foreach ([$recipient, $this->from] as $address) {
            if (filter_var($address, FILTER_VALIDATE_EMAIL) === false
                || preg_match('/[\r\n]/', $address)) {
                throw new RuntimeException('Invalid email configuration');
            }
        }

        $counts = [];
        foreach ($analysis->findingsBySeverity as $severity => $items) {
            $counts[] = $severity . ': ' . count($items);
        }

        $message = implode("\n", [
            'The website security score dropped.',
            'Website: ' . $url,
            'Previous score: ' . $previous,
            'Current score: ' . $analysis->score,
            'Finding counts: ' . ($counts ? implode(', ', $counts) : 'none'),
            'Review the analyzer recommendations before making changes.',
            '',
            'This is a bounded public HTTPS posture analysis, not a penetration test.',
        ]);

        if (!mail(
            $recipient,
            'Website security score dropped',
            $message,
            ['From' => $this->from, 'Content-Type' => 'text/plain; charset=UTF-8'],
        )) {
            throw new RuntimeException('Mail handoff failed');
        }
    }
}

final readonly class WeeklyCheck
{
    public function __construct(
        private Analyzer $analyzer,
        private ScoreStore $store,
        private OwnerMailer $mailer,
    ) {}

    public function run(string $url, string $owner): string
    {
        $analysis = $this->analyzer->analyze($url);
        $previous = $this->store->load($url);

        if ($previous !== null && $analysis->score < $previous) {
            $this->mailer->scoreDropped($owner, $url, $previous, $analysis);
            $result = 'drop_notified';
        } else {
            $result = $previous === null ? 'baseline_created' : 'no_drop';
        }

        $this->store->save($url, $analysis->score);
        return $result;
    }
}

PHP-ov mail() prijavljuje samo je li lokalni sustav za e-poštu prihvatio poruku; ne dokazuje njezinu isporuku. Produkcijski hostovi stoga trebaju konfigurirani MTA ili relej s praćenjem odbijenih poruka. Granica OwnerMailer također olakšava kasniju zamjenu odobrenim prilagodnikom za transakcijsku e-poštu bez izmjene logike rezultata.

Povežite izvršnu datoteku i strukturirane zapisnike

<?php
declare(strict_types=1);

use App\CurlTransport;
use App\FileScoreStore;
use App\NativeOwnerMailer;
use App\SecurityAnalyzerClient;
use App\WeeklyCheck;
use Dotenv\Dotenv;

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

Dotenv::createImmutable(dirname(__DIR__))->safeLoad();

$required = static function (string $key): string {
    $value = $_ENV[$key] ?? $_SERVER[$key] ?? null;
    if (!is_string($value) || $value === '') {
        throw new RuntimeException("Missing environment value: {$key}");
    }
    return $value;
};

try {
    $url = $required('WEBSITE_URL');
    $check = new WeeklyCheck(
        new SecurityAnalyzerClient(
            new CurlTransport(),
            $required('SECURITY_ANALYZER_TOKEN'),
        ),
        new FileScoreStore($required('STATE_FILE')),
        new NativeOwnerMailer($required('MAIL_FROM')),
    );

    $result = $check->run($url, $required('OWNER_EMAIL'));

    fwrite(STDOUT, json_encode([
        'event' => 'security_check_completed',
        'url_host' => parse_url($url, PHP_URL_HOST),
        'result' => $result,
        'time' => gmdate(DATE_ATOM),
    ], JSON_THROW_ON_ERROR) . PHP_EOL);
    exit(0);
} catch (Throwable $exception) {
    fwrite(STDERR, json_encode([
        'event' => 'security_check_failed',
        'exception' => $exception::class,
        'message' => $exception->getMessage(),
        'time' => gmdate(DATE_ATOM),
    ], JSON_THROW_ON_ERROR) . PHP_EOL);
    exit(1);
}

Zapisnici sadržavaju host, ishod, klasu iznimke i vrijeme, ali nikada token, autorizacijsko zaglavlje, tijelo odgovora ni potpune nalaze. Operativno upozoravajte na izlaze koji nisu nula ili nedostajuće događaje tjednog dovršetka. E-poruka o sigurnosnom rezultatu ne bi smjela služiti i kao nadzor neuspjeha.

Testirajte ponovne pokušaje i pravilo pada bez mreže ili e-pošte

Deterministički lažni transport omogućuje testiranje ponašanja ponovnih pokušaja bez čekanja ili trošenja kvote. Dodajte ovaj reprezentativni PHPUnit test:

<?php
declare(strict_types=1);

use App\AnalyzerException;
use App\HttpResponse;
use App\HttpTransport;
use App\SecurityAnalyzerClient;
use PHPUnit\Framework\TestCase;

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

    public function __construct(private array $responses) {}

    public function postJson(string $url, string $token, array $body): HttpResponse
    {
        $response = $this->responses[$this->calls++] ?? null;
        if (!$response instanceof HttpResponse) {
            throw new RuntimeException('Fake response queue exhausted');
        }
        return $response;
    }
}

final class SecurityAnalyzerTest extends TestCase
{
    public function testRetriesRateLimitThenMapsAnalysis(): void
    {
        $transport = new FakeTransport([
            new HttpResponse(429, '{}', ['retry-after' => '1']),
            new HttpResponse(200, json_encode([
                'score' => 82,
                'findings' => ['high' => [], 'medium' => [['id' => 'example']]],
                'tls' => ['present' => true],
                'recommendations' => ['Review the reported medium finding'],
            ], JSON_THROW_ON_ERROR)),
        ]);
        $delays = [];

        $client = new SecurityAnalyzerClient(
            $transport,
            'test-token',
            static function (int $milliseconds) use (&$delays): void {
                $delays[] = $milliseconds;
            },
        );

        $analysis = $client->analyze('https://www.example.com');

        self::assertSame(2, $transport->calls);
        self::assertSame([1000], $delays);
        self::assertSame(82.0, $analysis->score);
        self::assertCount(1, $analysis->findingsBySeverity['medium']);
    }

    public function testDoesNotRetryAuthenticationFailure(): void
    {
        $transport = new FakeTransport([new HttpResponse(401, '{}')]);
        $client = new SecurityAnalyzerClient($transport, 'test-token', static fn () => null);

        try {
            $client->analyze('https://www.example.com');
            self::fail('Expected AnalyzerException');
        } catch (AnalyzerException $exception) {
            self::assertSame('authentication', $exception->kind);
            self::assertSame(1, $transport->calls);
        }
    }
}
vendor/bin/phpunit --testdox
php bin/check-security.php

Dodajte zasebne jedinične slučajeve WeeklyCheck s implementacijama u memoriji za Analyzer, ScoreStore i OwnerMailer. Provjerite da prvi rezultat stvara početnu referentnu vrijednost bez e-pošte, jednak ili viši rezultat ne šalje ništa, niži rezultat šalje točno jednu poruku, a iznimka pri slanju e-pošte ostavlja prethodni rezultat nepromijenjenim.

Implementirajte jedan zaključani tjedni proces

Implementirajte s composer install --no-dev --classmap-authoritative, držite .env izvan kontrole izvornog koda s načinom rada 0600 i korisniku usluge dajte pristup za pisanje samo u var. Potvrdite da host vjeruje trenutačnim tijelima za izdavanje certifikata, dopušta odlazni HTTPS i ima funkcionalnu isporuku e-pošte.

systemd timer pruža trajno raspoređivanje i zapisnike journal-a. Timer u nastavku pokreće se svakog ponedjeljka ujutro u vremenskoj zoni konfiguriranoj za upravitelj sustava, dodaje malo nasumično kašnjenje, nadoknađuje propuštena pokretanja nakon zastoja i koristi flock za odbijanje preklapanja:

# /etc/systemd/system/site-watch.service
[Unit]
Description=Weekly website security score check
After=network-online.target

[Service]
Type=oneshot
User=site-watch
WorkingDirectory=/srv/site-watch
EnvironmentFile=/srv/site-watch/.env
ExecStart=/usr/bin/flock -n /srv/site-watch/var/process.lock /usr/bin/php /srv/site-watch/bin/check-security.php

# /etc/systemd/system/site-watch.timer
[Unit]
Description=Run the website security score check weekly

[Timer]
OnCalendar=Mon *-*-* 08:00:00
RandomizedDelaySec=15m
Persistent=true

[Install]
WantedBy=timers.target
sudo systemctl daemon-reload
sudo systemctl enable --now site-watch.timer
sudo systemctl start site-watch.service
sudo systemctl status site-watch.service
sudo journalctl -u site-watch.service

Prepoznajte uobičajene produkcijske neuspjehe

  • HTTP 401 ili 403: provjerite aktivaciju plana i implementaciju tokena. Ako je token ponovno generiran, svugdje zamijenite opozvanu vrijednost.
  • HTTP 400 ili 422: provjerite je li WEBSITE_URL javni HTTPS URL. Ne pokušavajte ponovno s nepromijenjenim zahtjevom.
  • HTTP 429: poštujte ograničeno kašnjenje ponovnog pokušaja, zatim trajno odbijanje tretirajte kao neuspjeh kvote. Potvrdite da odabrani plan podržava predviđeni raspored.
  • Vremenska ograničenja ili neuspjesi poslužitelja: dopustite tri ograničena pokušaja, zadržite prethodni rezultat, završite izlazom različitim od nule i upozorite putem operativnog nadzora.
  • Nema e-pošte: pregledajte journal usluge i lokalne zapisnike e-pošte. Uspješna predaja funkcijom mail() nije dokaz da je pružatelj usluga vlasnika prihvatio poruku.
  • Početna referentna vrijednost neočekivano je ponovno stvorena: provjerite vlasništvo datoteke stanja, trajnost, integritet JSON-a i je li se WEBSITE_URL promijenio.

Završni popis za provjeru

  • Plan usluge aktivan je, a njegov token ograničenog opsega postoji samo u zaštićenoj konfiguraciji okruženja.
  • Točna POST krajnja točka uspješno radi s JSON tijelom koje sadržava url.
  • Konfigurirani cilj koristi javni HTTPS.
  • Početno pokretanje zapisuje zaštićenu referentnu vrijednost i ne šalje upozorenje o padu.
  • Testovi dokazuju ponavljanje prolaznih neuspjeha, neponavljanje neuspjeha autentifikacije i obrambeno mapiranje odgovora.
  • Kontrolirani test s nižim rezultatom šalje jednu e-poruku prije zamjene pohranjenog stanja.
  • Timer je omogućen, preklapanje je zaključano, neuspjesi završavaju izlazom različitim od nule, a zapisnici ne izlažu vjerodajnice.
  • Vlasnik razumije da izvješće pokriva javni HTTPS položaj i da nije penetracijski test.

Najbolja automatizacija za mala poduzeća često je tiha: jedan ograničeni zahtjev, jedna trajna usporedba i jedna poruka samo kada je potrebna pozornost. Uz strogu validaciju granica, pažljive ponovne pokušaje, zaštićeno stanje i vidljivo raspoređivanje, tjedni rezultat postaje više od broja. Postaje signal ranog upozorenja koji vlasnik doista može upotrijebiti.

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.