Mihály Tari.

Mintajelentés: AI-kód audit a saját repómon

Egy kész AI-kód audit a saját directory-engine repómon. 50 ezer sor, egy hét, nagyrészt AI. Tíz megállapítás fájlra és sorra pontosan, órabecsléssel és javítási sorrenddel. Úgy tettem ki, ahogy elkészült.

Ez egy valódi audit, és a kód, amit vizsgál, az enyém. A directory-engine egy multi-tenant (több bérlős) katalógusmotor. 2026-08-27 és 2026-09-03 között írtam, 229 commitban, nagyrészt AI-jal. 50 384 sor TypeScript, 169 tesztfájl 279 forrásfájlra. (Tesztfájlnak számítok minden *.test.* és *.spec.* fájlt, plusz mindent, ami test/, tests/, __tests__/ vagy e2e/ könyvtárban van.) Ez pont az a helyzet, amire az AI-kód audit való, ezért ezt választottam mintának. Nem szépítettem rajta. Ami kijött, az van itt.

A felépítés ugyanaz, mint egy ügyfélnek írt jelentésé. Elöl egy oldal annak, aki nem olvas kódot. Utána a megállapítások két részben: ami most fáj, és ami fél év múlva fog. A végén a javítási sorrend.

Egyoldalas összefoglaló

Összkép. Élesben maradhat. Két dolgot érdemes a következő deploy előtt rendbe tenni, de egyik mögött sem támadó áll, hanem egy rosszkor érkező hiba vagy kimaradás. Amin a multi-tenant rendszerek általában elcsúsznak, vagyis hogy az egyik bérlő látja a másik adatát, az itt rendben van. Minden adatbázis-lekérdezés a bérlő azonosítójára szűr, és az auth-adapter is.

Megállapítások. 0 kritikus · 2 magas · 6 közepes · 2 alacsony.

Javítási idő. 9–15 óra a két magas tételre, 17–33 óra a többire.

Ha semmi nem változik. A bejelentkezés rate limitje példányonként számol, nem globálisan. A valódi határ tehát nem 3 levél tíz percenként, hanem háromszor annyi, ahány szerverpéldány éppen fut. Ha egy publikálás a két adatbázis-írása között elhal, marad utána egy hirdetés, ami soha nem jár le, senki nem fizet érte, és semmi nem keresi. Az első adatbázis-kimaradásnál a katalógusoldal 200-as státusszal, üres listával megy ki a látogatóknak és a Google-nek, és erről a Vercel logján kívül sehol nem marad nyom.

Mit néztem, mit nem. Repó: directory-engine, commit f5753ff, 2026-09-03. Elolvastam mind a 8 route handlert, az Auth.js-adaptert és azt, hogyan szűr bérlőre, a hirdetésműveletek tulajdonosellenőrzéseit, a két migrációt az indexeikkel, a levételi tokenek életciklusát, a feltöltési aláírást és a Cloudinary-névterek ellenőrzését, a markdown-sanitizert, a rate limitert, a blokkrenderelőt, az éjszakai cront, és végig a git-történetet, titkokat keresve. Nem néztem a függőségek ismert sérülékenységeit (nem volt része az auditnak, és a jelentés nem is állít róluk semmit, se jót, se rosszat), a futó infrastruktúrát (Vercel-beállítások, DNS, a Postgres- és a Cloudinary-fiók konfigurációja), a teljesítményt terhelés alatt, azt, hogy a Playwright e2e-készlet mit fed le valójában, az akadálymentességet, és a tárolt adatok GDPR-megfelelését.

Egy eredmény, ami nincs a listán. Végignéztem mind a 229 commitot, minden ágon, a merge-diffekkel együtt. Az egyetlen .env-jellegű fájl, ami valaha bekerült, az apps/web/.env.example. Változónevek, kommentek, egy publikus hostnév, egy bérlőazonosító és két példa connection string van benne, amiknek a hostneve szó szerint host. Titkos érték nincs benne, és titoknak kinéző sort máshol sem találtam a git-történetben. Ez azért nem megállapítás, mert rendben van, nem azért, mert nem néztem.

Módszer

Egy gépi körrel kezdtem: néhány szkript, ami megjelöli, hol érdemes keresni. Titkok a teljes git-történetben, függőségi sérülékenységek (ennek a kimenetét nem dolgoztam fel, lásd fent), tesztarány workspace-enként, túl nagy fájlok, ismétlődő kódrészletek, AI-ra jellemző nyomok, és a route handlerek listája egy auth-jelzéssel. A szkriptek nem írnak megállapítást. Minden találatot kézzel néztem meg, és csak az maradt a jelentésben, amihez le tudtam írni a bizonyítékot, egy konkrét hibaforgatókönyvet, a javítást és az időigényt. Ami nem állta meg a helyét, kikerült. Egy példa: a szkript hat route handlert jelölt, mert nem talált bennük auth-idiómát. Kettőt tényleg el kellett olvasni. A másik négy (a robots.txt, a két sitemap-útvonal és az Auth.js saját végpontja) szándékosan nyilvános, ezek nem szerepelnek lent.

Súlyossági fokozatok.

  • Kritikus: ma kihasználható, vagy ma okozhat adatvesztést, külső előfeltétel nélkül.
  • Magas: kihasználható, vagy adatvesztést okozhat egy valószínű feltétel mellett (egy bejelentkezett felhasználó, egy rossz bemenet, egy egyidejű kérés).
  • Közepes: közvetlen kárt nem okoz, de a következő funkciónál vagy a következő fejlesztőnél hibát fog okozni.
  • Alacsony: rendetlenség, ami időt visz, kárt nem okoz.

1. rész — Ami most fáj

1. A rate limiter a processz memóriájában számol, a deploy pedig több példányon fut — Magas

Bizonyíték. packages/engine/src/rate-limit.ts:11: const buckets = new Map<string, Bucket>();. A modul fejléce (1–3. sor) ezt le is írja:

// In-memory fixed-window rate limiter — best-effort defense-in-depth against floods/abuse, not a
// globally enforced quota (state lives in this process only; a multi-instance deployment needs a
// shared store instead). Ported from erdei-fahazak.hu's shared/rate-limit.ts, generalised to the

Erre épül a bejelentkezés mindkét korlátja. apps/web/app/%5Fsites/[tenant]/auth/api/[...nextauth]/route.ts:63: limitByIp(tenant, "sign-in", req.headers, { limit: 5, windowMs: 60_000 }), majd a 74. sorban limitByKey(tenant, "sign-in-email", email.toLowerCase(), { limit: 3, windowMs: 600_000 }).

Forgatókönyv. Aki ezt írta, tudta, mit csinál. A fejlécben ott van, hol a határ, és mi kellene helyette („a multi-instance deployment needs a shared store instead”). Csak közben a deploy többpéldányos lett, a közös tár meg nem készült el. A gyakorlatban: valaki beírja valaki más e-mail-címét a bejelentkezési űrlapba, és percenként újraküldi. A Vercel forgalom alatt több független példányon futtatja az appot, mindegyiknek saját buckets Map-je van, és minden cold start üres Map-pel indul. A „tíz percenként három levél erre a címre” így példányonként három levél lesz. Az áldozat postaládája megtelik bejelentkezési linkekkel, spamnek jelöli őket, és romlik a Postmark-domain kézbesítési reputációja. Ezen a domainen megy az összes bérlő tranzakciós levele. A hirdetésértesítések ugyanezt a limiter-modult használják, külön névtérben (apps/web/lib/actions/listing-internal.ts:41: limitByKey(tenant, "listing-notify", listingId, { limit: 5, windowMs: 3_600_000 })), tehát ott is több megy át, mint amennyit a korlát ígér.

Javítás. A számláló kerüljön közös tárba: egy rate_limits tábla Postgresben, upserttel, vagy Vercel KV / Upstash. A kulcs alakja (<bérlő uuid>:<alany>:<útvonal>) maradhat, csak a tár cserélődik alatta, tehát egy modulhoz kell hozzányúlni. 6–10 óra.

2. A publikálás két írás tranzakció nélkül, és ha a második elmarad, a hirdetés örökre ingyen marad — Magas

Bizonyíték. apps/web/lib/actions/listing-internal.ts:453: await storage.setListingStatus(listing.id, "LISTING_APPROVED");. Majd külön utasításként a 455. sorban: await storage.setListingPlan(listing.id, transition.stamp.plan, transition.stamp.planUntil);.

Forgatókönyv. A 453. sor után a hirdetés már élesben van. Ha a két írás között elhal a kérés (a Postgres átáll másik példányra, a Vercel-függvény túllépi az időkorlátot, megszakad a kapcsolat), marad egy LISTING_APPROVED sor, aminek a plan_until mezője NULL. Az éjszakai takarító cron csak azokat a sorokat nézi, amiknek van lejárata (packages/engine/src/storage/listings.ts:450: isNotNull(listings.planUntil)), ezt tehát soha nem archiválja. Élő marad, ingyen, és nincs olyan lekérdezés, ami a „jóváhagyott, de csomag nélküli” sorokat megtalálná. Tranzakció a repóban sehol nincs: a Storage nem nyit ilyet, a védett adatbázis-handle pedig kifejezetten visszautasítja a transaction hívást (packages/engine/src/db/guard.ts:35).

Javítás. Egy Storage.publishListing(id, stamp) metódus, ami a státuszt és a csomagot egyetlen UPDATE-ben írja, plusz egy egyszeri lekérdezés a már ilyen állapotban lévő sorokra. 3–5 óra.

3. Ha egy blokk hibázik, az oldal 200-zal megy ki, hiányzó tartalommal, és csak egy logsor marad utána — Közepes

Bizonyíték. apps/web/lib/blocks/render.tsx:34-35:

        console.error(`[tenant:${ctx.tenant.id}] block ${b.block} failed`, err);
        return { slot: b.slot, index, block: b.block, node: null };

A null node-ot a renderelő egyszerűen kihagyja az oldalból.

Forgatókönyv. Az adatbázis fél percre nem elérhető (cold start, kapcsolatlimit, átállás, bármi). A listing-grid blokk betöltője hibát dob, a renderPage elkapja, kihagyja a blokkot, és a /hazikok oldal kimegy 200-as státusszal, teljes fejléccel és lábléccel, nulla hirdetéssel. Ha a Google crawlere pont ekkor jár arra, üres katalógust indexel. Aki először jön az oldalra, üres listát lát, és megy tovább. Riasztás nincs: az öt package.json egyikében sincs hibafigyelő függőség, úgyhogy az egészről egyetlen console.error sor tanúskodik a Vercel logjában. A blokk kihagyása egyébként helyes, egy hibás blokk ne vigye el az egész oldalt. Ami hiányzik, hogy erről valaki értesüljön.

Javítás. A kihagyás maradjon, csak legyen mellette jelzés. Egy hibafigyelő (Sentry), vagy legalább egy data-block-failed jelölő a kimenetben és egy külső ellenőrzés, ami elbukik, ha a katalógusoldal a listing-grid blokk nélkül renderelődik. 4–8 óra.

4. A levételi token a slugra mutat, nem a hirdetés azonosítójára — Közepes

Bizonyíték. packages/engine/src/storage/engagement.ts:269: return row ? { userId: row.userId, listingSlug: row.listingSlug } : undefined;. A handler így használja (apps/web/app/%5Fsites/[tenant]/api/takedown/route.ts:131): const listing = await storage.getListingBySlugAnyStatus(consumed.listingSlug);, majd a 134. sorban await storage.setListingStatus(listing.id, "SUSPENDED");.

Forgatókönyv. A token 30 napig érvényes (apps/web/lib/takedown.ts:5). „A” tulajdonos publikálja a „Csendes Tanya” hirdetést, az adminnak küldött levélben ott a levételi link. Pár nap múlva „A” törli a hirdetést. A törlés szándékosan nem takarítja el a tokent, ez le is van írva a packages/engine/src/storage/listings.ts:548 sorban. Még a harminc napon belül „B” tulajdonos felad egy hirdetést ugyanezzel a névvel, amiből ugyanez a slug lesz. Amikor az üzemeltető előveszi a régi levelet és rákattint a linkre, a handler a slug alapján „B” élő hirdetését találja meg, azt függeszti fel, és „B”-nek küldi ki a levételi értesítőt.

Javítás. A tokenre a hirdetés id-ja kerüljön (a slug maradhat mellette, megjelenítésre), és a handler az alapján keresse meg a hirdetést. Vagy a deleteAction törölje a slughoz tartozó tokeneket is. 2–4 óra.

2. rész — Ami fél év múlva fáj

5. A tulajdonosi lekérdezés lower()-t hív az indexelt oszlopon, így az index nem használható — Közepes

Bizonyíték. packages/engine/src/storage/listings.ts:244:

    .where(this.scoped(listings, sql`lower(${listings.ownerEmail}) = lower(${trimmed})`))

Az index viszont a nyers oszlopon van, packages/engine/src/schema/listings.ts:52: tenantOwner: index("listings_tenant_owner_idx").on(t.tenantId, t.ownerEmail),.

Forgatókönyv. A lekérdezés lower()-t hív az oszlopon, az index pedig a nyers oszlopra épül, ezért a Postgres nem tudja használni. A tulajdonosi profiloldal minden betöltéskor végigolvassa a bérlő összes hirdetését. Ma ez pár száz sor, észre sem lehet venni. Az erdei-fahazak.hu régi katalógusának importja után tízezres nagyságrend lesz, és pont akkor lassul be, amikor a tulajdonosok elkezdik használni az oldalt. A lower() olvasáskor egyébként fölösleges is, mert a createListing (:282) és az updateListing (:381) is kisbetűsíti az e-mailt íráskor.

Javítás. Vagy kikerül a lower() a lekérdezésből (a bemenet normalizálása elég), vagy az index épül (tenant_id, lower(owner_email))-re. Egy sor plusz egy migráció. 1–2 óra.

6. Az accounts elsődleges kulcsa természetes kulcs, és nincs benne a bérlőazonosító — Közepes

Bizonyíték. packages/engine/migrations/0000_init.sql:15: CONSTRAINT "accounts_provider_providerAccountId_pk" PRIMARY KEY("provider","providerAccountId").

A sémában négy tábla használ természetes kulcsot elsődleges kulcsnak szintetikus azonosító helyett: az accounts (:15), a sessions (:20), a verification_tokens (:40) és a listing_terms (:172). A listing_terms kulcsa a tenant_id-vel kezdődik, a másik háromban nincs is benne. Oszlopként mindhárom táblában ott van a tenant_id (accounts:3, sessions:19, verification_tokens:36), tehát a sor bérlőhöz van kötve, csak a kulcs nem. A sessions és a verification_tokens kulcsában van egy véletlen érték, ott két bérlő nem ütközhet. Az accounts kulcsa viszont a szolgáltató fiókazonosítója, és ez pont olyan érték, amin két bérlő osztozhat. A séma összes többi bérlőnkénti unique constraintje (users_tenant_email, listings_tenant_slug, taxonomy_terms_tenant_kind_slug) a tenant_id-vel kezdődik. Ez az egy nem.

Forgatókönyv. Ma csak e-mailes bejelentkezés van, az accounts táblába semmi nem ír, a hiba egyelőre alszik. Amint bekerül egy Google-gomb: a felhasználó bejelentkezik az erdei-fahazak.hu-n a Google-fiókjával, létrejön a (google, <fiókazonosító>) sor. Ugyanez az ember ugyanezzel a fiókkal bejelentkezik a kapcsolodjki.hu-n is, a linkAccount ugyanazt a párt próbálja beszúrni, az elsődleges kulcs elutasítja, és a bejelentkezés hibaoldalon végződik. Csak azoknál, akik mindkét oldalt használják, és csak élesben, ahol tényleg két bérlő van. Fejlesztői gépen, egy bérlővel soha nem jön elő.

Javítás. Egy migráció, ami az elsődleges kulcsot (tenant_id, provider, providerAccountId)-ra cseréli, még az első OAuth-sor előtt. 2–4 óra.

7. Két különböző szabály dönti el, hogy egy kép ezé a bérlőé-e — Közepes

Bizonyíték. Írási oldal, apps/web/lib/cloudinary.ts:73:

  return parsed.pathname.startsWith(uploadPrefix) && parsed.pathname.includes(`/${folderFor(tenant)}/`);

Törlési oldal, ugyanabban a fájlban, a 144. sorban: if (!publicId || !publicId.startsWith(prefix)) {.

Forgatókönyv. Az írási kapu átengedi, ha a bérlő mappája bárhol szerepel az útvonalban. A törlési kapu azt várja, hogy az azonosító a bérlő mappájával kezdődjön. Ma a kettő ugyanúgy viselkedik, mert a public_id-t a Cloudinary adja, és a mappa mindig az első szegmens. Akkor válnak szét, ha a bérlő mappája elé kerül egy szegmens. A publicIdFromUrl (cloudinary.ts:82) a vNNN utáni teljes útvonalat megtartja, tehát egy t-<id>/ alá tett almappa még rendben törlődik, egy elé tett szegmens esetén viszont már nem. Ez kétféleképpen fordulhat elő: egy átszervezéssel listings/t-<id>/… alakra, vagy egy verziószegmens nélküli, transzformációt tartalmazó URL-lel. A /upload/w_500/t-<id>/photo.jpg public_id-ja w_500/t-<id>/photo, ezt az írási kapu ma is elfogadja, a törlési kapu viszont skipped-ként csendben átugorja. A tulajdonos kiveszi a fotót a hirdetésből, az adatbázisból eltűnik a hivatkozás, a kép viszont ott marad a CDN-en, és az URL-jével bárki letöltheti. Pont ezt kellett volna a törlésnek megakadályoznia. A repóban egyébként dokumentálva van egy 2026-08-08-i incidens egy félresikerült Cloudinary-takarításról (cloudinary.ts:116), tehát ez a terület egyszer már okozott gondot.

Javítás. Egy exportált predikátum, amit mindkét hely hív. A szabály jó, csak nem kellene kettőnek lennie belőle. 2–4 óra.

8. A markdown-sanitizer 25 sornyi regex, és csak egy komment védi — Közepes

Bizonyíték. packages/ui/src/markdown.ts:77: const rendered = marked.parse(md, { async: false });, utána egy kézzel írt engedélyezőlista. A megkötés a 69–70. sorban van, kommentként:

 * HARD CONSTRAINT: `md` must always be operator-authored (a tenant config value or a blog post
 * only an operator can write) — never a visitor-supplied string. The `sanitize()` allow-list above

Forgatókönyv. Ma ez rendben van. A renderMarkdown bemenete vagy a bérlő konfigurációja, vagy egy blogbejegyzés, és mindkettőt az üzemeltető írja. A megkötést viszont csak egy komment tartja: nincs típus, nincs teszt, nincs lint-szabály, ami megállítaná a következő hívót. A kézenfekvő következő funkció a markdown a hirdetés leírásában. A description oszlop létezik, a tulajdonos szerkeszti, és a legtermészetesebb lépés az lesz, hogy valaki ráhívja a repó saját markdown-segédfüggvényét. Onnantól egy önállóan regisztrált hirdető szövege megy át egy olyan tag-szűrőn, amiről a saját kommentje mondja ki, hogy nem támadó ellen készült.

Javítás. A bemenet legyen branded típus (OperatorMarkdown), amit csak az a két hely tud előállítani, aminek szabad. Így egy sima string át sem megy a típusellenőrzésen. Ha pedig valaha látogatói szöveg is idekerül, karbantartott sanitizerre kell cserélni. 3–6 óra.

9. Ugyanaz a parseArgs négy szkriptben, karakterre pontosan — Alacsony

Bizonyíték. scripts/parity-cron.mjs:38, scripts/parity-from-source.mjs:47, scripts/parity-urls.mjs:71, scripts/preview-smoke.mjs:34. Mind a négyben export function parseArgs(argv) { áll, és a négy törzs bájtra azonos.

Forgatókönyv. A négy szkript egy üzemeltetői runbook négy lépése. A parity-urls.mjs 642 soros, és ezt szokták bővíteni. Amikor valaki hozzáad egy érték nélküli kapcsolót (--dry-run), a másik három szkript megtartja a régi viselkedést, ami az értéket kötelezőnek veszi, és 2-es kóddal kilép. Az üzemeltető így ugyanazon a runbookon belül kétféle kapcsolókezelésbe fut bele, és a hibaüzenetből nem derül ki, melyik szkript melyiket érti.

Javítás. Egy scripts/lib/args.mjs, amiből mind a négy importál. 1–2 óra.

10. A honeypot-konvenció nyolc helyen él, és csak kommentek tartják össze — Alacsony

Bizonyíték. A rejtett mező négy komponensben van (packages/ui/src/pages/contact-form.tsx:49, packages/ui/src/blocks/inquiry-form.tsx:67, packages/ui/src/pages/plan-request-form.tsx:84, packages/ui/src/pages/sign-in.tsx:68), mindegyikben name="website". Az ellenőrzés négy külön server actionben, például apps/web/lib/actions/contact.ts:41: const honeypot = formData.get("website");.

Forgatókönyv. Ma mind a négy pár megvan. Az ötödik űrlapot valaki a meglévő markup másolásával kezdi majd, a hozzá tartozó server actiont viszont nulláról írja. A mező ott lesz az űrlapon, az ellenőrzés nem. És semmi nem jelez hibát: az űrlap működik, a teszt zöld, csak a honeypot nem csinál semmit. Az első jel a spam lesz, hetekkel később, és senki nem fogja a három hiányzó sorhoz kötni.

Javítás. Egy <Honeypot /> komponens és egy isBot(formData) segédfüggvény ugyanabból a modulból, ami a mező nevét is exportálja. Így a két fél egy helyen van, és az ötödik űrlap sem tudja szétszedni őket. 2–3 óra.

Javítási sorrend

#TételÓrasávMire jó
2Publikálás egy UPDATE-be3–5A takarítás innentől minden élő hirdetést lát; nem keletkezik több láthatatlan, örök sor
1Rate limit közös tárba6–10A bejelentkezési és értesítési határok annyit érnek, amennyit a doksijuk ígér
4Levételi token azonosítóra2–4A moderációs link nem tud rossz hirdetést eltalálni
3Riasztás a hibázó blokkra4–8Egy adatbázis-kimaradás nem néma; a hiányzó tartalomról nem a vevő szól
6accounts kulcs migrációja2–4Bevezethető a Google/OAuth bejelentkezés
5Tulajdonosi index1–2A profiloldal bírja az erdei-import utáni forgalmat
7Egy névtér-predikátum2–4Változtatható a Cloudinary-mappaszerkezet
8Branded markdown-típus3–6Bekerülhet látogató által írt szöveg bárhova a rendszerbe
10Honeypot egy modulba2–3Az ötödik űrlap nem tud némán védtelen maradni
9Közös parseArgs1–2A runbook négy lépése ugyanúgy értelmezi a kapcsolókat

Először a 2-es, mert olcsó, és most is termel olyan sorokat, amiket később nehéz lesz megtalálni. Aztán az 1-es. A 2. rész tételei nem sürgősek, de mindegyik egy konkrét következő funkció előfeltétele, ezért közvetlenül az adott funkció előtt érdemes megcsinálni őket, nem utána.

Mit mutat ez a minta

Egy hét alatt 50 ezer sor, és a bérlők szétválasztása, a jogosultságellenőrzések, a tokenek életciklusa és a titkok kezelése mind rendben van. Ez nem véletlen. 169 tesztfájl 279 forrásfájlra (a *.test.* és *.spec.* fájlokat, plusz a tesztkönyvtárak teljes tartalmát számolva) jobb arány, mint amit sok, évek óta épülő kódbázis fel tud mutatni. Amit átnéztem, annak a nagy része azért nem lett megállapítás, mert rendben volt.

Ami mégis kijött, az nem az AI hibája. A két magas tétel közül az egyik onnan jön, hogy a kód egy gépre íródott, de több gépen fut (1-es). A másik onnan, hogy valami egy adatbázis-írásnak látszik, pedig kettő (2-es). Aztán van öt megállapítás (köztük megint az 1-es), ahol valaki végiggondolt egy megkötést vagy korlátot, és le is írta egy kommentben: a memóriában élő rate limiter, a slug alapú token, a névtér-ellenőrzés, a markdown-megkötés, a honeypot-konvenció. Egyiket sem kényszeríti ki típus, teszt vagy index. Ezeket egy AI nem fogja megtalálni, mert egyik sem hibás kód, mindegyik egy leírt, helyes szabály. Az egyiket már utolérte a valóság (az 1-est), a többit a következő funkció fogja. Ehhez valakinek egyben kell elolvasnia az egészet, és meg kell kérdeznie, hogy amit leírtak, az még igaz-e.

Ha az Ön kódbázisáról kellene ugyanez: AI-kód audit, 350 000 Ft, 1–2 hét, fix áron.

Ha van egy feladat, amit meg kell építeni vagy rendbe tenni, írja le röviden, és két munkanapon belül válaszolok.

Kapcsolat

← Összes írás