Skip to content

fix(privacy): apply noindex+mask policy to machine-readable outputs (#173) - #183

Open
LyuboslavLyubenov wants to merge 52 commits into
midt-bg:mainfrom
LyuboslavLyubenov:main
Open

LyuboslavLyubenov wants to merge 52 commits into
midt-bg:mainfrom
LyuboslavLyubenov:main

Conversation

@LyuboslavLyubenov

@LyuboslavLyubenov LyuboslavLyubenov commented Jun 30, 2026 •

Copy link
Copy Markdown

Какво и защо

HTML профилът на фирма вече прилага noindex за разпознати физически лица / еднолични търговци (ЕТ),
но същите идентификатори остават достъпни от машинно-четливите повърхности — JSON записът
на договора (/contracts/:id.json) и трите CSV експорта (/contracts.csv, /companies.csv,
/authorities.csv). Те връщат ЕИК и оригиналното име от източника без X-Robots-Tag: noindex
и без маскиране, edge-кешират се (Cache-Control: public, max-age=3600) и попадат в индексите
на търсачките и ботовете.

Несъответствието прави идентификаторите, които HTML умишлено държи извън търсачките,
търсещи се и изтегляеми накуп — класът CWE-359, описан в issue #173.

Решение

Прилага се политика „noindex плюс маскиране" с един общ предикат от
packages/shared/src/format.ts, който заменя досегашната дублирана логика в company.tsx:

  • Споделена логика. Нов isNaturalPersonBidder(name, legalForm) комбинира legal_form LIKE 'ЕТ%'
    (вкл. латинското ET, разширените форми ЕДНОЛИЧЕН ТЪРГОВЕЦ, SOLE TRADER, INDIVIDUAL)
    с водещия ЕТ суфикс в името (който вече беше в isNaturalPersonProfileName).
    MASKED_NATURAL_PERSON_LABEL ('Частно лице') е константа, която се внася по символ от
    всеки консуматор — преименуването ѝ не чупи нито един тест. Inline isSingleNaturalPersonProfile
    е премахнат от company.tsx.
  • CSV стриймовете в packages/db/src/queries/{contracts,companies}.ts правят
    SELECT b.legal_form и маскират contractor_eik/eik и contractor/name преди
    байтовете да стигнат до R2
    — edge кешът не може никога да сервира немаскиран
    естествено-личностен ред. apps/web/app/lib/csv-export.ts добавя X-Robots-Tag: noindex
    на четирите клона: 200/206 HIT, dynamic (филтриран) и 304.
  • JSON маскиране. packages/db/src/queries/details.ts разширява getContract с
    bidder_legal_form (server-only, не изтича към клиента — ContractRecord остава непроменен).
    apps/web/app/routes/contract.json.tsx изнася чист maskContractForPrivacy(record, bidderLegalForm) помощник, който слага X-Robots-Tag: noindex само когато маскирането
    реално е приложило — reference-equality гейт masked !== record прави повторното
    извикване на предиката ненужно.
  • Потребителска документация. Нова секция #natural-person-data в
    apps/web/app/routes/privacy.tsx описва кои полета се маскират, кои повърхности
    носят noindex-а и че HTML профилът остава непокътнат. Кадърът е инженерно ръководство,
    не правен съвет — същият disclaimer носи и ADR-0002.
  • ADR-0002 в docs/architecture.md (български, огледало на ADR-0001): Контекст / Решение / Последствия / Засегнати повърхности / Доказателство. Цитираните exit code-ове
    са от реален пост-едит прогон в чисто и замърсено дърво, не оценки.

Юридическите лица (legal entity) са непроменени — ЕИК и имената им остават видими
във всички повърхности.

Валидация (реални данни)

  • pnpm typecheck — exit 0 (7/7 turbo задачи).
  • pnpm test --force — exit 1, заради 3 пред-съществуващи повреди в @sigma/db
    (integrity-checks.test.ts reconciliation gate + 2 таймаута в refresh-slice.test.ts).
    Същият набор е налице и в предишния main, не е въведен от този PR.
    Всичките 38 нови теста минават — пост-едит и пре-едит прогонът показват същия набор
    пред-съществуващи повреди, без нови въведени от този PR.
  • pnpm lint — exit 1, 6 prettier warnings: 2 пред-съществуващи (RiskIndicators.tsx,
    riskLogic.test.ts) + 4 нововъведени (contract.json.test.ts, privacy.tsx,
    companies.test.ts, companies.ts). Няма нови lint-видове — само пренасяне на редове
    заради български / EN текст в JSX.
  • Тестове по пакет (фокусни): @sigma/shared 42/42, @sigma/db
    (contracts.test.ts + companies.test.ts + details.test.ts) 26/26,
    @sigma/web (contract.json.test.ts + csv-export.test.ts) 33/33.

Извън обхвата

  • Няма миграция на схемата — bidders.legal_form вече присъстваше в migrations/0000_init.sql.
  • ContractRecord (API contract) е непроменен от страна на клиента; полето bidder_legal_form
    остава server-only (добавя се в details.ts, използва се от route-а, не се изпраща на клиента).
  • HTML профилът не е пипан — неговият noindex мета-етикет минава през същия споделен предикат.

Чеклист

  • Комитите следват conventional commits и нямат Co-Authored-By: trailer
  • PR-ът е с един логически обхват и е от форк към midt-bg/sigma:main
  • pnpm typecheck минава; пред-съществуващите pnpm test/pnpm lint повреди са
    документирани като baseline в секция „Валидация (реални данни)" по-горе

Closes #173

@lyubomir-bozhinov lyubomir-bozhinov left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Добре структуриран — маскирането е на query слой преди R2, noindex-gate-ът на .json е чист, тестовете са солидни. Но тезата на PR-а („noindex+mask на всички machine-readable изходи") не е изпълнена докрай:

🔴 .data single-fetch payload е непокрита machine-readable повърхност. При ssr:true RRv7 сервира всеки loader и на GET /<път>.data. company.tsx loader-ът връща eik + displayName немаскирани, а headers() е publicCache(...) → отговорът минава през hardenResponse (само baseSecurityHeaders, без X-Robots-Tag) и се кешира на edge. Значи /companies/:eik.data връща немаскиран ЕИК на физическо лице, кеширан, без noindex — точно експозицията от #173, на повърхността, която PR-ът не изброява. (Потвърдих reachability-то на .data живо на публичния prod; robots.txt не покрива /*.data.) Поправка: приложи предиката/маската и за .data, или централизирай X-Robots-Tag в hardenResponse вместо per-route.

🟠 Sitemap-ът ползва по-тесния предикат. sitemaps.ts филтрира с isNaturalPersonProfileName (само по име), не с разширения isNaturalPersonBidder (+legal_form), който PR-ът ползва навсякъде другаде. ЕТ, разпознат само по legal_form, се маскира/noindex-ва навсякъде, но пак се рекламира в /sitemap-companies — каним crawl на страница, която политиката де-индексира.

За проверка (не потвърдено срещу данни): премахнатият consortium guard в isNaturalPersonBidder — нито един caller не филтрира kind==='consortium', та консорциум с водещ „ЕТ …" в името може да се over-маскира като „Частно лице". Струва си да се потвърди срещу корпуса, преди да се третира като дефект.

@lyubomir-bozhinov lyubomir-bozhinov left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Инлайн котви към горния преглед — .data single-fetch privacy gap.

Comment thread apps/web/app/routes/company.tsx Outdated
Comment thread apps/web/app/routes/contract.json.tsx Outdated
@ydimitrof

Copy link
Copy Markdown
Contributor

Прегледах PR #183 изцяло — целия diff (24 файла, +1882/−45), всички коментари по ревюто, issue #173, както и текущото състояние на кода в работното дърво (sitemaps.ts, details.ts, company.tsx render). По-долу е обобщението.


Обобщение

Централизацията на X-Robots-Tag: noindex през маркера X-Privacy-Mask в hardenResponse е чиста и добре тествана. Маскирането на CSV става на query слоя преди записа в R2 (streamContractsCsv / streamCompaniesCsv), което е правилният ред — edge кешът не може да сервира немаскиран ред. Реакцията на предишното ревю (.data близнакът и per-route → централизиран автор) е адресирана коректно. Тестовете (38 нови) са смислени, не тривиални. Двете конкретни забележки от предишния преглед обаче не са затворени докрай, а при локалната проверка изникнаха и нови пропуски в същия клас CWE-359.

Сигурност / SQL / OWASP

  • SQL инжекция: няма. Всички нови SQL фрагменти (b.legal_form AS bidder_legal_form, LEFT JOIN bidders AS b ON b.id = ct.bidder_id) са статични идентификатори; параметрите остават през ?-binding. LEFT JOIN е 1:1 (bidders.id е PK), няма fan-out на редовете.
  • Секрети / обфускация / backdoor: няма. safeJson продължава да екранира <, > и line separators.
  • OWASP: A01/A04 се подобряват; не се въвежда нова инжекционна повърхност.

🔴 Съществени пропуски (data-integrity / поверителност)

1. Подизпълнителят (subcontractor) остава немаскиран в /contracts/:id.json. maskContractForPrivacy маскира само bidder; getContract дори не селектира legal_form на подизпълнителя. При договор с юридическо лице като изпълнител и едноличен търговец като подизпълнител, maskContractForPrivacy връща записа по референция (masked === record) → маркерът не се слага → отговорът е без noindex, кеширан, с непокрит ЕИК и оригинално име на физическото лице (subcontractor.eik = r.subcontractor_eik, subcontractor.name). Това е точно експозицията от #173 („any machine-readable record carrying a natural-person identifier"), на повърхността, която PR-ът твърди, че покрива изцяло. Рядка (~0.8% договори), но е същият клас уязвимост, който PR-ът затваря.

2. Sitemap-ът ползва по-тесния предикат (незатворена забележка от предишното ревю). packages/db/src/queries/sitemaps.ts:108 филтрира с isNaturalPersonProfileName (само по име), не с разширения isNaturalPersonBidder (+legal_form). ЕТ, разпознат само по legal_form (име без водещо „ЕТ "), се маскира и получава noindex навсякъде другаде и в HTML профила, но продължава да се обявява в /sitemap-companies — активно каним crawl на страница, която политиката де-индексира.

3. HTML профилът скрива ЕИК на физическите лица — противоречи на собствената документация в PR-а. Споделеният loader прави company.eik = null (company.tsx:77) и за HTML отговора. В render-а {c.hasEik && c.eik && (…ЕИК…)} (company.tsx:126) става falsy → блокът с ЕИК изчезва. А новата секция в privacy.tsx изрично уверява потребителя, че „HTML профилът … остава непокътнат по съдържание — името, ЕИК и всички останали полета се показват както в първичния източник." Поведението е по-защитно (fail-safe), но е необявена функционална промяна и прави потребителската документация невярна.

🟠 По-малки бележки

4. .data близнакът връща оригиналното име немаскирано, докато privacy.tsx твърди, че за /companies/:eik.data „ЕИК и оригиналното име … се заменят с неутрален етикет". Реално се нулира само eik; displayName остава дословен (тестът company.data.test.ts го потвърждава). Тъй като HTML показва същото име и .data вече носи noindex, експозицията ≈ HTML — но документацията надценява маскирането.

5. Възможно over-маскиране на консорциум. isNaturalPersonBidder премахна консорциум-гарда; CSV-каналът за договори не филтрира kind, така че консорциум с водещ „ЕТ …" в display name-а (пръв член ЕТ) би се маскирал като „Частно лице". Fail-safe, не е теч — струва си потвърждение срещу корпуса.

Съответствие с issue #173

Ядрото на issue-то (bulk-indexable ЕИК на ЕТ в .json + .csv) е адресирано за изпълнителя. Остатъчните повърхности от т.1 (подизпълнител) и т.2 (sitemap) означават, че тезата „noindex+mask на всички machine-readable изходи" още не е напълно изпълнена.

Бележки

  • CI: „no checks reported on the 'main' branch" — PR-ът е от форк с branch main; препоръчвам да се провери дали required checks реално минават преди merge.
  • pnpm lint е exit 1 с нови prettier warnings в scope (contract.json.test.ts, privacy.tsx, companies.ts, companies.test.ts) — CI е конфигуриран като blocking lint (2d93cd5), така че тези трябва да се forматират.

Вердикт: Заявка за промени (Request changes) — блокиращо е т.1 (немаскиран ЕИК/име на подизпълнител-физическо лице в /contracts/:id.json, без noindex) и т.2 (sitemap с по-тесен предикат); т.3 изисква или коригиране на кода, или коригиране на потребителската документация, за да не е подвеждаща.

Благодаря за прегледната работа по маркерния договор и тестовете — след затварянето на горните пропуски PR-ът ще е в много добра форма.

@lyubomir-bozhinov

Copy link
Copy Markdown
Collaborator

Блокерът е затворен — ре-проверих на връх d55a1cc:

  • ЕИК маскирането е на ниво заявка (companies.ts:271 — const eik = isNatural ? '' : r.eik), значи важи автоматично и за /companies/:eik.data близнака (един и същ loader обслужва HTML и .data).
  • noindex е централизиран на worker ниво: app.ts hardenResponse превежда X-Privacy-Mask маркера в X-Robots-Tag: noindex и го маха преди кеширане; app.nofollow.test.ts покрива изрично .data близнаци — физ. лице → noindex (T-008), юр. лице → без маркер (негативен fixture).

Точно това затваря находката ми (немаскиран ЕИК на физ. лице, кеширан, без noindex, изтичащ през .data). Одобрявам по същество.

Единствено: branch-ът е в конфликт с main (mergeable_state=dirty) — rebase преди merge.

LyuboslavLyubenov added a commit to LyuboslavLyubenov/sigma that referenced this pull request Jul 6, 2026
…mber worker adr to 0008

Rebase of midt-bg#183 onto upstream/main (post-midt-bg#182 ADR reorganization) restructured the privacy-policy and worker-level X-Robots-Tag ADRs to live in docs/adr/ rather than inline in docs/architecture.md:

- New docs/adr/0007-privacy-masking.md — content extracted from the inline ADR-0002 in architecture.md; relative paths adjusted (../ → ../../) for the new adr/ location; cross-link to the worker ADR now points to 0008.
- docs/adr/0003-centralized-x-robots-tag-worker.md → docs/adr/0008-centralized-x-robots-tag-worker.md — renumbered to free the 0003 slot taken by upstream's value-flag ADR; internal cross-link from architecture.md#adr-0002-... to 0007-privacy-masking.md.
- docs/adr/README.md — index extended with the two new entries.
- docs/architecture.md — adopted upstream's short summary form; the inline ADR-0001+0002 contents are removed (the rendering ADR lives at adr/0001-rendering-and-security.md and the privacy policy at adr/0007-privacy-masking.md); Решения (ADR) section now also points to 0007 and 0008.
- docs/privacy-masking.md — cross-link from architecture.md#adr-0002-... to adr/0007-privacy-masking.md; ADR-0003 to ADR-0008.

No code changes; verified pnpm check:docs (docs-integrity gate from midt-bg#182) passes.
LyuboslavLyubenov added a commit to LyuboslavLyubenov/sigma that referenced this pull request Jul 6, 2026
The three files modified by PR midt-bg#183 carried pre-existing prettier debt that the original review flagged (`pnpm lint` exit 1 with `contract.json.test.ts`, `companies.test.ts`, `companies.ts`). The repo's CI is configured as blocking lint (`2d93cd5`, comment in .github/workflows/ci.yml), so this would have blocked the PR from merging. Run `pnpm prettier --write` on the three files — no semantic changes.
@lyubomir-bozhinov

Copy link
Copy Markdown
Collaborator

Прегледах отново новия връх 468a116 — доста по-широк (CSV/JSON/.data/company маскирани, ADR-и), добра работа. Но adversarial pass показва, че маскирането е закачено за export/twin routes, а СПОДЕЛЕНИТЕ проекции остават немаскирани — та ЕИК на физ. лице (ЕТ) още изтича на най-видимите ИНДЕКСИРАНИ HTML страници:

1) Списък „Фирми" (companies.tsx). listCompanies мапва през toCompanyListItem (rows.ts:58 eik: r.eik — суров; rows.ts е +1/-0, проекцията не е пипана). companies.tsx:70 рендира „ЕИК {c.eik}". ЕТ → ЕИК на индексиран списък.

2) Начална страница (home.tsx). getHomeData → topCompanies: companies.results.map(toCompanyListItem) (home.ts:84) — същата немаскирана проекция; home.tsx:196 показва ЕИК на топ-10. ЕТ в топ-10 → ЕИК на най-трафикираната страница.

3) Страница на договора (contract.tsx). Не е в PR-а; getContract (details.ts:583 eik: r.bidder_eik суров) → contract.tsx:221-222 показва „ЕИК {c.bidder.eik}" + линк към регистъра. .json близнакът маскира, HTML — не.

4) Подизпълнител. maskContractForPrivacy маскира само bidder, не subcontractor; contract.tsx:258-263 рендира c.subcontractor.eik суров. Изтича и на HTML, и на .json.

isNaturalPersonBidder хваща ЕТ (format.ts:210), а ЕТ има валиден 9-цифрен ЕИК (eik_valid=1) → hasEik е true и се рендира — точно случаите, които streamCompaniesCsv вече маскира (companies.ts:270). CSV/JSON са затворени; голите HTML страници — не.

Корен (altitude): маскирането е на leaf routes/exports, не на споделените проекции. Устойчивият фикс: маскирай в toCompanyListItem (rows.ts — има name/kind/legal_form → isNaturalPersonBidder) и в getContract/getContractDetail (details.ts — има bidder_name/bidder_legal_form + subcontractor_name за name-based проверка). Тогава списък, начална, договор, .data наследяват маската веднъж — вместо всяка нова повърхност да помни да маскира (CSV помни, списъкът — не).

PR-ът затваря буквата на #173 (.json/.csv), но не и духа — ЕТ ЕИК на индексираните HTML страници. Блокер до фикса на споделените проекции.

@nedda76 nedda76 left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Прегледах PR-а спрямо #173. Механиката marker→header е добре построена и тествана: CSV се маскира преди записа в R2, worker-ът слага X-Robots-Tag: noindex и трие marker-а преди edge cache (cache-safe — app.nofollow.test.ts го покрива). Но маската не покрива всяка machine-readable повърхност и на места е обезсилена — има потвърдени пътища, по които суровият ЕИК на физическо лице все още изтича. Тоест PR-ът още не затваря #173.

Блокиращи — потвърдени течове на суров ЕИК

  1. slug пресейва ЕИК-а. Маската нулира eik, но оставя slug — а за eik-базирани субекти slug е самият нормализиран ЕИК (companySlug('eik:222222222') === '222222222'). Тоест bidder.slug / company.slug в /contracts/:id.json и /companies/:eik.data носят точно идентификатора, който маската маха. ADR-0007 („slug-ът не е PII") не важи за физически лица — а те са именно маскираната популация.

  2. /contracts/:id.data не е маскиран. HTML маршрутът contract.tsx е недокоснат; при ssr:true single-fetch /contracts/:id.data сервира payload-а на loader-а машинно четимо — bidder.eik и subcontractor.eik, без noindex. .json е маскиран, но .data близнакът му — не (точно класът повърхности, заради който съществува ADR-0008).

  3. Подизпълнителят не се маскира. maskContractForPrivacy пипа само bidder + sourceNames.bidder; subcontractor минава непокътнат, а подизпълнителят може да е ЕТ/физическо лице → ЕИК изтича. Рядко (~0.8% от договорите), но реален непокрит път.

  4. /companies.data (списък) не е маскиран. /companies.csv е, но .data близнакът на списъчния маршрут връща CompanyListItem със суров eik + име за физически лица → точно „bulk searchable/downloadable" вредата от #173, само през .data вместо .csv.

За обсъждане — по-нисък приоритет

  1. Мрежовият граф в маскирания company.data носи node id-та eik:<ЕИК> → физическо лице като възел изтича ЕИК. Частично фундаментално за eik-базираните id-та (като #1).
  2. Регресия за легитимни фирми: предикатът пада към name-евристика (ЕТ /ET префикс) дори при реален legal_form (ООД) → фирма с име „ET Engineering" получава скрит валиден ЕИК (нарушава изискване #5). Евристиката е заварена, но сега тя контролира скриване на ЕИК, не само мек noindex — цената на false positive расте.
  3. Несъгласуваност: .json/.csv заменят името с етикет, но company.data/HTML пазят ЕТ името дословно. Трите machine-readable пътя маскират различни полета.

Чисто (за фокус)

marker→header плъмбингът и cache safety; обединеният предикат isNaturalPersonBidder (и двата source() клона проектират legal_form); запазеното показване на юридически лица по покритите пътища; authorities.csv (публични органи — умишлено без body-маска).

Коренът на #1/#2/#4 е един: маската покрива .json/.csv, но не и .data близнаците и не пипа slug. Докато .data повърхностите и slug не се покрият, #173 остава отворен.

@ydimitrof ydimitrof left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Преглед на PR #173 — „fix(privacy): noindex + маскиране за машинно-четими изходи"

Какво прави PR-ът

Прилага политика за поверителност върху машинно-четимите изходи (turbo-stream .data, JSON, CSV), така че идентификаторите и имената на физически лица/ЕТ да бъдат маскирани, а не само маркирани с noindex. Архитектурата е чиста и с ясно разделение на отговорностите: route-овете маркират чрез markPrivacyMaskApplied, worker-ът превежда маркера в X-Robots-Tag: noindex чрез applyPrivacyMaskHeaders и го изтрива преди кеш/клиент. Въвежда се единен предикат isNaturalPersonBidder и константа MASKED_NATURAL_PERSON_LABEL в @sigma/shared, преизползвани в streamCompaniesCsv и streamContractsCsv; legal_form се прокарва коректно през двата клона на source() и през getContract.

Сигурност (Фаза 0)

ЧИСТО и в двете партиди — няма зашити тайни/ключове, нови зависимости, нови или подозрителни URL адреси, нито зловредни шаблони. Промяната всъщност намалява изтичането на лични данни. Тестовото покритие е силно и смислено: покрити са MISS/HIT/dynamic, идемпотентност, edge-cache инвариантите, негативните случаи, двата клона на source(), name-евристиката при legal_form=null и таблица на истинност на предиката. Документацията (ADR-0007, ADR-0008, privacy-masking.md) е изчерпателна и синхронизирана с кода.

Блокираща забележка

  1. (Средно — поверителност/консистентност) Машинно-четимият близнак /companies/:eik.data изчиства само company.eik, но връща пълното име на физическото лице (displayName) буквално — за разлика от /contracts/:id.json и CSV, които заменят името с MASKED_NATURAL_PERSON_LABEL. Тъй като .data е машинно-четим изход, а самата обосновка на PR-а (ADR-0007) гласи, че noindex е недостатъчен срещу ботове, оставянето на суровото име само зад noindex противоречи на декларираната цел. Тестът company.data.test.ts дори утвърждава това като очаквано. Нужно е явно решение: да се маскира името и в .data, или изрично да се документира защо .data остава немаскиран.

Некритични забележки

  1. (Ниско — поверителност) При маскиране bidder.slug / company.slug се запазват. Ако slug-овете се извеждат от името на субекта, URL-фрагментът може да разкрие идентичността въпреки маскирането на name/eik. ADR-0007 приема slug като „URL фрагмент, не PII", но не адресира случая на slug, изведен от име.
  2. (Ниско — точност на маскирането) isNaturalPersonBidder се вика без предварителна проверка за bidder_kind, въпреки че документацията му възлага филтрирането на консорциумите на викащия. Консорциум с име, започващо с „ЕТ ", ще бъде over-маскиран като „Частно лице" — privacy-safe, но с загуба на информация.
  3. (Ниско — DB/производителност) Клонът по подразбиране на source() вече винаги прави LEFT JOIN bidders, а това е и hot path за listCompanies. Да се потвърди, че company_totals няма собствена колона legal_form (иначе ct.* + b.legal_form дава двусмислена дублирана колона) и че има индекс по bidders.id.
  4. (Ниско — дублиран/мъртъв код) Няколко случая на дублиране, противоречащи на правилото „NO CODE DUPLICATION": излишните предварителни извиквания на markPrivacyMaskApplied в responseFromR2Object (csv-export.ts), които markCsvCache веднага презаписва; и inline вариант на правилата за legal_form в apps/web/app/routes/company.tsx.
  5. (За потвърждение) details.ts прокарва bidder_legal_form без маскиране и без тест в прегледаната партида — да се потвърди, че JSON маскирането се извършва другаде.

Вердикт

Промяната е висококачествена и без изтичане на данни в прегледаните файлове. Единствената блокираща точка е несъответствието в поверителността при /companies/:eik.data (т.1) в PR, чиято цел е именно защита на лични данни — тя трябва да бъде адресирана или изрично обоснована преди одобрение. Останалите забележки са некритични.

Comment thread apps/web/app/routes/company.tsx
Comment thread apps/web/app/routes/contract.json.tsx
Comment thread apps/web/app/lib/csv-export.ts Outdated
Comment thread packages/db/src/queries/companies.ts Outdated
Comment thread packages/db/src/queries/contracts.ts Outdated
Comment thread packages/shared/src/format.ts Outdated

@lyubomir-bozhinov lyubomir-bozhinov left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Благодаря — маскирането на .json и CSV е издържано. Но остава отворена точно най-чувствителната machine-readable повърхност, заради която е #173: detail страницата на договора и нейният .data twin.

apps/web/app/routes/contract.tsx — loader-ът (:62-67) връща { contract } суров; файлът не се пипа в този PR и няма нито маска, нито noindex. getContract селектира b.eik_normalized AS bidder_eik (packages/db/src/queries/details.ts:440) и го отдава суров (:583 eik: r.bidder_eik, :595 eik: r.subcontractor_eik). contract.tsx ги рендира без маска — :221 ЕИК на изпълнителя, :261 ЕИК на подизпълнителя. Worker-ът прилага само applyPrivacyMaskHeaders(headers) (header-only, noindex) — няма body маска на ниво worker.

Ефект (prod е публичен, unauthenticated):

  • GET /contracts/<slug>.data → turbo-stream body с bidder.eik / subcontractor.eik на физическо лице (ЕТ), немаскиран и без X-Robots-Tag.
  • GET /contracts/<slug> (HTML) → същият ЕИК, индексируем.

Същата политика, различно прилагане и на списъците:

  • /companies.data: toCompanyListItem (packages/db/src/queries/rows.ts:58) връща eik: r.eik без isNaturalPersonBidder проверка, докато /companies.csv го маскира.
  • /contracts.data: toItem в contracts.ts дава немаскирани имена на физически лица (CSV пътят вече маскира).

Предложение: маскирай в споделения слой — в getContract, или в loader-а на contract.tsx огледално на company.tsx:77-85 — за да го наследят HTML, .data и .json, вместо per-route. Същото за toCompanyListItem (редът вече носи legal_form) и за подизпълнителя в maskContractForPrivacy.

Докато .data twin-ът не минава през същата маска като .json, #173 не е затворен. Проверих горните редове на HEAD (468a116). Блокиращо за merge.

LyuboslavLyubenov added a commit to LyuboslavLyubenov/sigma that referenced this pull request Jul 27, 2026
…port

The R2-body branch (responseFromR2Object) and the 304 branch each called
markPrivacyMaskApplied directly, then handed the response to markCsvCache,
which calls it again internally. The marker was applied twice on MISS/HIT/304
paths — idempotent in effect, but dead code that hid markCsvCache as the single
source of truth for the privacy marker on every CSV path (PR midt-bg#183 review T-004,
"NO DEAD CODE / NO CODE DUPLICATION").

Drop the direct calls; rely solely on markCsvCache. Add a TDD guard that spies
on markPrivacyMaskApplied and asserts exactly one call per response path
(MISS/HIT/dynamic/304), so a future duplicate cannot sneak back in.
LyuboslavLyubenov added a commit to LyuboslavLyubenov/sigma that referenced this pull request Jul 27, 2026
…ortium over-masking

isNaturalPersonBidder's docstring delegates consortium filtering to the caller —
a JV is a legal entity even if a lead member's name / legal_form matches a
sole-trader signal. But streamContractsCsv and streamCompaniesCsv both invoked
it WITHOUT a bidder_kind guard, so a consortium such as "ЕТ Иван Петров; Строй
ООД" (or any consortium whose legal_form collided with a sole-trader form) was
masked to MASKED_NATURAL_PERSON_LABEL with its ЕИК cleared.

The result was privacy-safe (over-masking, no leak) but a behavioral change
that dropped the lead member's name + ЕИК and contradicted the predicate's
contract. Add an early bidder_kind/kind !== 'consortium' guard in both
streamers so consortium rows keep the "… и др." shape and their ЕИК.

TDD: failing tests first (consortium with ЕТ lead name + ЕТ legal_form, and the
leading-ЕТ name heuristic with legal_form null), then the guard (PR midt-bg#183 T-006).
LyuboslavLyubenov added a commit to LyuboslavLyubenov/sigma that referenced this pull request Jul 27, 2026
…ne duplication)

The docstring claimed the legal_form rules were "carried inline in
apps/web/app/routes/company.tsx until the route migrates" — but ADR-0007 §1
already removed the legacy inline isSingleNaturalPersonProfile, and company.tsx
now calls this shared predicate directly (verified: no legal_form string-
matching exists outside packages/shared). The stale claim created exactly the
divergence risk the PR midt-bg#183 reviewer flagged under "NO CODE DUPLICATION": a
future reader could believe a second copy still lives in the route and maintain
it separately.

Rewrite the docstring to state the predicate is the single source of truth and
enumerate the downstream surfaces that consume it (HTML noindex, CSV masking,
JSON masking), with a pointer to the bidder_kind/kind consortium guards added
in the CSV streamers (PR midt-bg#183 T-006). No behavior change.
LyuboslavLyubenov added a commit to LyuboslavLyubenov/sigma that referenced this pull request Jul 27, 2026
…6, §7)

Two PR midt-bg#183 review threads asked for explicit product decisions on the company
profile masking surface. Both are recorded here as policy.

§6 — displayName stays visible in the HTML profile and its `.data` twin; only the
ЕИК is masked. The trading name is PUBLIC (rendered verbatim on the HTML page and
in <title>); the sensitive natural-person identifier is the ЕИК. The `.data`
turbo-stream is React Router v7's single-fetch transport for client-side
navigations, NOT a standalone export like /contracts/:id.json — masking the name
there would break client-rendered pages. Consistent policy: name = public, ЕИК =
sensitive. company.tsx loader comment now states this; the company.data.test.ts
assertion locks displayName-verbatim + eik-null as the contract.

§7 — the name-keyed natural-person slug (n + base64url(name)) is a tracked
limitation, not changed in this PR. The name is public (§6), the sitemap already
filters these records, and reworking the slug scheme is cross-cutting (URL
stability, internal links, identity system) and out of scope for a masking PR.

No behavior change.
@lyubomir-bozhinov

Copy link
Copy Markdown
Collaborator

Прегледах #183 на дълбочина срещу head a9b18ae (валидирано срещу дифа). Ядрото за CSV + /contracts/:id.json е солидно, но има два реални пропуска в покритието, които оставят точно този ЕИК незащитен.

Силната част (потвърдено):

  • CSV маскирането пази consortium коректно: contracts.ts:459 / companies.ts гейтват r.bidder_kind !== 'consortium' ПРЕДИ isNaturalPersonBidder, така че „ЕТ Иван Петров; Строй ООД" НЕ се маскира като физ. лице. Тестовете дискриминират (ЕТ маскиран, ООД дословно, consortium не; стабилни колони).
  • Marker plumbing-ът е идемпотентен: applyPrivacyMaskHeaders реагира само на точния литерал, трие маркера преди edge-cache и преди клиента; „marks exactly once" е покрит за MISS/HIT/304.
  • Маскира се и ЕИК→null, и име→етикет (Частно лице); без слабо частично ЕИК.

MAJOR 1 — JSON masker-ът НЯМА consortium guard-а, който CSV пътят има. contract.json.tsx:27: if (!isNaturalPersonBidder(record.bidder.name, bidderLegalForm)) return record; — за разлика от contracts.ts:459, тук record.bidder.kind не се проверява. Docstring-ът на isNaturalPersonBidder делегира consortium филтрирането на викащия, а този викащ не го прави → consortium с име, започващо с „ЕТ …" и legal_form = null, се over-маскира до „Частно лице" + noindex в JSON. Точно багът, който вече е поправен в CSV, не е пренесен тук; няма consortium тест в contract.json.test.ts. Фикс: същият bidder.kind !== 'consortium' гард (добави полето към record-а, ако липсва).

MAJOR 2 — най-изложената повърхност остава отворена: страницата на договора + .data близнакът ѝ. contract.tsx рендерира ЕИК на изпълнителя (:219-222) без noindex и без маркер (meta()/headers() не ги слагат), а robots.txt забранява само /search + /*.csv (robots.tsx:5) — НЕ .data/.json. Значи договор, спечелен от „ЕТ …", излага личния ЕИК на едноличния търговец през /contracts/:id И през /contracts/:id.data, индексируемо. #183 затваря .json/.csv (каквото #173 назовава), но ADR-0008 обещава „всяка повърхност наследява noindex" — а дизайнът е marker-based, т.е. наследява само ако route-ът сложи маркера, а тази повърхност не го слага. Понеже страницата на договора е сред най-обхожданите, това е по-голямата дупка от .json. Препоръка: сложи маркера на contract.tsx (worker-ът ще покрие и HTML, и .data).

MAJOR 3 (test-gap + архитектура) — препращането на маркера към worker-а за .data не е доказано end-to-end. Механизмът зависи RR v7 да пренесе loader header-а върху реалния .data HTTP отговор; нито един тест не кара реалния single-fetch pipeline (мокват createRequestHandler / викат loader() директно). Ако RR не го препрати, .data близнакът на компанията тихо тръгва без X-Robots-Tag — зелени тестове, скрит пропуск. За сравнение: седмичният дайджест реши същия .data проблем path-based на worker-а (workers/app.ts DIGEST_DETAIL_PATH мачва и /weeks/:iso.data), което не зависи от RR forwarding. Същият подход за машинно-четимите повърхности е по-надежден + добави реален .data e2e тест.

MINOR — не-ЕТ физически лица могат да минат немаскирани. isNaturalPersonBidder (format.ts:210) хваща само ЕТ (legal_form или префикс „ЕТ "). Физ. лице с голо име и legal_form = null минава с ЕИК. #173 е скоупнат до ЕТ, ок като документирано ограничение — но копито в privacy.tsx казва „физическо лице или едноличен търговец", по-широко от това, което предикатът реално лови.

NIT — authorities.csv и другите вече носят X-Robots-Tag: noindex (безусловен marker в markCsvCache). По ADR за консистентност, без маскиране на тялото — но е поведенческа промяна за журналисти/инструменти, разчитащи на discovery; струва си да е изрично в release бележките.

PR-ът е CONFLICTING спрямо main — нужен е rebase (отделно от горното).

Насоката е правилна; двата masker-а — consortium guard в JSON и покриване на страницата/.data — са това, което да се затвори преди merge.

LyuboslavLyubenov added a commit to LyuboslavLyubenov/sigma that referenced this pull request Jul 28, 2026
… path

The `/contracts/:id.json` masker (`maskContractForPrivacy`) lacked the
`bidder_kind !== 'consortium'` guard that the CSV streamer already has
(`contracts.ts:459`). A consortium whose display name begins with „ЕТ "
(first member is a sole trader, e.g. „ЕТ Иван Петров; Строй ООД") was
over-masked to „Частно лице" — losing the „… и др." shape, the consortium
ЕИК, and gaining an unearned `noindex`.

`isNaturalPersonBidder`'s docstring delegates consortium filtering to the
caller; this adds the caller guard, mirroring the CSV path exactly. Flagged
as MAJOR 1 in the PR midt-bg#183 review of head a9b18ae.

TDD: failing consortium cases first (name-based + legal_form-based, plus a
loader-level marker-omission case), then the guard.
LyuboslavLyubenov added a commit to LyuboslavLyubenov/sigma that referenced this pull request Jul 28, 2026
`contract.tsx` was the most-indexable surface still open: its loader returned
`{ contract }` raw with no privacy marker, `robots.txt` does not block
`/contracts/:id` (or its `.data` twin), and the page rendered `c.bidder.eik`
verbatim — so a sole-trader's ЕИК was indexable on both the HTML page and the
RRv7 single-fetch `.data` payload. That is a worse exposure than the already-
closed `.json`/`.csv` paths.

Masking + signalling in the SHARED loader covers both surfaces at once (the
`.data` twin reuses the same loader), mirroring `company.tsx:89` exactly:
ЕИК (the sensitive natural-person ID) → null on the returned object, the
trading displayName stays PUBLIC (ADR-0007 §6), and the `X-Privacy-Mask:
applied` marker is translated to `X-Robots-Tag: noindex` by the worker. The
`kind === 'consortium'` guard matches the JSON masker (MAJOR 1) and the CSV
streamer so a JV is never over-masked/noindexed. `headers()` forwards the
marker onto the HTML response (RR does not auto-propagate loader headers).
Flagged as MAJOR 2 in the PR midt-bg#183 review of head a9b18ae.

TDD: failing loader/headers/pipeline cases first, then the loader change.
LyuboslavLyubenov added a commit to LyuboslavLyubenov/sigma that referenced this pull request Jul 28, 2026
…eal worker

The PR midt-bg#183 review (MAJOR 3) noted the marker→`.data`→`X-Robots-Tag` forwarding
was only proven through fixtures that INJECT the marker by hand in the stubbed
RR handler — which proves the worker CAN translate a marker, not that a real
loader's marker survives the pipeline to the final `.data` HTTP response. That
left a „green tests, hidden gap" risk on the most-indexable surface.

Add four cases driving the REAL `worker.fetch` (→ handleRequest → hardenResponse
→ applyPrivacyMaskHeaders → edgeCache.put) against `/contracts/<x>.data`:
masked sole-trader → noindex + marker stripped + masked body preserved; cached
entry carries noindex (HIT-path invariant); second request HITs and serves
noindex verbatim; legal-entity negative (no marker → no noindex). The handler
returns the exact shape `contract.tsx`'s masked loader branch now produces
(MAJOR 2), so this is an honest end-to-end proof of the forwarding guarantee.

Note: the review's suggested path-based worker match (the weekly-digest
`DIGEST_DETAIL_PATH` precedent) does not exist in this codebase — the worker
does no path-based matching; the marker-based design (ADR-0008) is the
established architecture and is sound, so this keeps it.
@LyuboslavLyubenov

Copy link
Copy Markdown
Author

@lyubomir-bozhinov Благодаря за задълбочения adversarial pass — трите MAJOR забележки са затворени в три фокусирани комита върху a9b18ae:

MAJOR 1 — consortium guard в JSON masker-а (59bcece). maskContractForPrivacy (contract.json.tsx) вече gating-ва record.bidder.kind === 'consortium' ПРЕДИ isNaturalPersonBidder, огледално на CSV пътя (contracts.ts:459, bidder_kind !== 'consortium'). Консорциум с водещ „ЕТ …" вече не се over-маскира до „Частно лице" / не получава noindex. Докстринг-ът на isNaturalPersonBidder делегира consortium филтрирането на викащия — guard-ът е точно този caller. TDD: failing случаи първо (name-based + legal_form-based + loader-level маркер-омисия), после guard-а.

MAJOR 2 — маскиране на страницата на договора + .data близнака (0ea9230). contract.tsx беше най-индексируемата отворена повърхност: loader-ът връщаше { contract } суров, без маркер, robots.txt не блокира /contracts/:id (нито .data близнака), а страницата рендираше c.bidder.eik буквално. Сега маскирането + сигнализирането са в споделения loader (затова покриват HTML + .data едновременно), огледално на company.tsx:89: ЕИК → null, displayName остава ПУБЛИЧЕН (ADR-0007 §6), X-Privacy-Mask: applied се превежда в X-Robots-Tag: noindex от worker-а. kind === 'consortium' guard-ът от MAJOR 1 е приложен и тук. headers() препраща маркера (RR не auto-пропагира loader headers). TDD: failing loader/headers/pipeline случаи, после промяната.

MAJOR 3 — end-to-end доказателство, че маркерът достига X-Robots-Tag на .data през реалния worker (5acd42b). Добавени 4 случая в app.nofollow.test.ts, които драйвят реалния worker.fetch (→ handleRequest → hardenResponse → applyPrivacyMaskHeaders → edgeCache.put) срещу /contracts/<x>.data с body, носещ точно формата, който новият loader produce-ва (маскиран ЕИК + маркер): masked sole-trader → noindex + маркер изтрит + body запазен; кеширан entry носи noindex (HIT-инвариант); втори request HIT-ва и сервира noindex verbatim; legal-entity негативен (без маркер → без noindex). Предишните fixtures инжектираха маркера на ръка в stub-натия handler — доказваха само че worker-ът може да преведе маркер, не че реален loader-маркер оцелява. Това затваря „green tests, hidden gap" риска.

Забележка към предложението ти за path-based worker match (седмичният digest DIGEST_DETAIL_PATH): този precedent не съществува в кода — worker-ът (app.ts) няма path-based matching, архитектурата е изцяло marker-based (ADR-0008) и е коректна, така че не въвеждам path-based схема.

За останалите ти точки (не са код-промени в този PR):

  • NIT (authorities.csv безусловен noindex) — умишлено по ADR-0007 §last (политическа консистентност, без body-маска). Ще го отбележа изрично в release notes.
  • MINOR (не-ЕТ физически лица с голо име) — документирано ограничение: Privacy: .json/.csv expose natural-person ЕИК without the noindex applied to HTML profiles #173 е скоупнат до ЕТ, а privacy.tsx е по-широко формулиран. Отделна задача за разширяване на предиката.
  • Подизпълнителят (т.4 от по-ранен пас) — subcontractor няма legal_form колона в заявката (details.ts строи обекта само от subcontractor_name + subcontractor_eik), така че маскирането му изисква query промяна + policy решение извън ADR-0007 §3. Остава като проследявано ограничение за follow-up PR — не го сгъвам в този бранч (AGENTS.md scope).

Rebase: PR-ът е CONFLICTING спрямо main (22 upstream комита, включително identity-system af4977e/f7a3e50 и value-base 463e22a/210fd88, с тежко припокриване точно в details.ts/contracts.ts/companies.ts/contract.tsx/format.ts). Не съм ребейзвал тук — коректното resolution изисква повторна валидация на masking-политиката срещу новата upstream identity система, което е отделна, по-голяма задача от този review пас, и не искам да пренаписвам 14 комита, които ревюиращите вече коментират. Оставям ребейза за merge-стъпката. Трите MAJOR фикс-а са independently reviewable върху текущия head 5acd42b.

Пълна локална проверка: pnpm --filter @sigma/web test → 385 passing (0 new failures), pnpm --filter @sigma/web typecheck → exit 0, форматирано с prettier.

LyuboslavLyubenov and others added 4 commits July 28, 2026 13:52
…-Mask marker

The literal X-Robots-Tag header is no longer set anywhere in apps/web;
it is now written by exactly one helper (applyPrivacyMaskHeaders in
apps/web/app/lib/security.ts), called by the worker hardenResponse
after the base security headers and before the cacheable-HTML branch.

A new internal marker X-Privacy-Mask: applied is the route-side signal
that the response carries masked natural-person data. Route handlers
(csv-export.ts markCsvCache + 304 branch, contract.json.tsx loader) and
the worker consume that marker; it is deleted unconditionally before
the response is returned or stored in edgeCache.put so it never reaches
clients.

The HIT path in handleRequest (apps/web/workers/app.ts) is unchanged:
it copies cached.headers verbatim, and the cached entry is the
post-hardenResponse response, so the header survives the edge cache by
construction.

apps/web/workers/app.nofollow.test.ts (T-005 + T-008) exercises the
end-to-end worker flow for the .data twin of /companies/:eik and the
contract.json natural-person branch.

No edits to packages/db or to the public API contract;
bidders.legal_form stays server-only and company.eik stays on the
CompanyRecord type (masked to null by the loader, not removed).
…ough headers()

The single-fetch .data twin of the company profile now clears company.eik to null
on the natural-person branch (per isNaturalPersonBidder) and signals the
worker via the internal X-Privacy-Mask: applied header on the Response.json
return. The route's headers() export now destructures { loaderHeaders } from
Route.HeadersArgs and forwards the marker explicitly so the worker
hardenResponse can translate it into X-Robots-Tag: noindex on the HTML
response (getDocumentHeadersImpl only auto-propagates Set-Cookie).

Legal-entity records keep the plain-object return unchanged — no marker, no
mutation, no Response.json wrap. The not-found short-circuit (throw new
Response('Not Found', ...)) runs before the masking gate, so 404s never
carry the marker.

The HTML meta() noindex branch is unchanged — natural-person pages
continue to emit <meta name="robots" content="noindex"> via the existing
seoMeta + isNaturalPersonBidder gate. The new headers() forward adds a
redundant X-Robots-Tag: noindex HTTP header alongside the meta tag, which
is acceptable (the worker translates the marker for all responses).

apps/web/app/routes/company.data.test.ts is the focused new test suite
(7 tests across 5 describe blocks): natural-person loader return asserts
company.eik === null and X-Privacy-Mask: applied; legal-entity loader
return asserts a plain object with eik unchanged and no marker; headers()
test exercises both branches (marker present → forwarded + Cache-Control;
marker absent → Cache-Control only); meta() test covers the natural-person
noindex HTML tag; the worker-pipeline describe calls applyPrivacyMaskHeaders
on the loader return and asserts X-Robots-Tag: noindex is set while
X-Privacy-Mask is stripped, proving the worker translate end-to-end.
…rage

ADR-0002 (docs/architecture.md): the Решение section now describes the
centralized X-Robots-Tag: noindex write site (hardenResponse in
apps/web/workers/app.ts, via the applyPrivacyMaskHeaders helper in
apps/web/app/lib/security.ts). The bullet on per-route CSV/contract-json
writes is replaced by a single sentence naming hardenResponse, the marker
flow, and the deletion pre-edgeCache.put.

The Засегнати повърхности list grows to explicitly enumerate:
  - the .data twin of /companies/:eik (React Router v7 single-fetch,
    automatic via the shared loader in company.tsx)
  - apps/web/workers/app.ts (hardenResponse) as the centralized
    enforcement point under a new 'Worker — централизирана точка за
    прилагане' sub-heading
  - apps/web/app/lib/security.ts as the policy helper home, with
    PRIVACY_MASK_APPLIED as the literal-typed constant.

The privacy page (apps/web/app/routes/privacy.tsx) #natural-person-data
section grows to enumerate /companies/:eik.data alongside the existing
/contracts/:id.json and the three CSV exports. A follow-up paragraph in
Bulgarian prose explains that the X-Robots-Tag: noindex policy is now
applied uniformly at the worker edge so future machine-readable surfaces
inherit it automatically — without naming the X-Privacy-Mask marker or
the helper functions (user-facing wording only).

No edits to package.json, pnpm-lock.yaml, or the public API contract.
@LyuboslavLyubenov

Copy link
Copy Markdown
Author

Daily autonomous review — ydimitrof 2026-08-31 batch (9 нишки)

Пропуснах тези 9 нови нишки в предишния си дневен преглед (те бяха публикувани в 18:36 EEST, ~8 часа след моя Aug-31 pass). Днес адресирах всичките.

Имплементирано (6 commit-а, fast-forward на main)

Commit Нишки Промяна
e9e9f36 feat(db): surface masked boolean and lock eikValid with eik on the leaderboard list companies.tsx:84, rows.ts:80 CompanyListItem.masked: boolean от toCompanyListItem; eikValid lock-step с eik
5939b10 fix(web): drop privacy-mask forwarding to leaderboard HTML and unlink masked rows companies.tsx:61, rows.ts:74 headers() не форвардва X-Privacy-Mask към HTML; маскирани редове рендерират <span> не <Link>
13fca7b fix(web): zero eikValid and hasEik on masked company profile loader company.tsx:114 Loader-ът занулява eikValid/hasEik заедно с eik
e7c55a1 fix(web): strip server-only bidder_legal_form on contract page loader contract.tsx:139 stripServerOnlyFields паритет с contract.json.tsx на двете повърхности
fed1c24 fix(web): stamp privacy marker in contract.json.tsx per ADR-0040 contract.json.tsx:91 markPrivacyMaskApplied вместо директен X-Robots-Tag
4b352ae refactor(db): drop redundant bidder_legal_form projection in CSV streamer contracts.ts:466, contracts.ts:448 Мъртъв дублиран SELECT и излишно поле в CsvRow

Тестове

  • pnpm --filter @sigma/db exec vitest run src/queries/rows.test.ts → 19/19 (1 нов)
  • pnpm --filter @sigma/db exec vitest run src/queries/contracts.test.ts → 16/16
  • pnpm --filter @sigma/web exec vitest run --config vitest.config.ts → 585/585 (нови: companies.render.test.tsx, разширени company.data.test.ts, contract.data.test.ts, contract.json.test.ts)
  • apps/web/test/integration/ (sqlite3-зависими) не се пуснаха в тази среда — липсва sqlite3 binary, инфраструктурно ограничение, не е регрес.

Дизайн решения

  • Thread Add OG/Twitter meta, JSON-LD schema, sitemap #1 (companies.tsx:61) — ydimitrof е прав: маскираните редове рендерират само „Частно лице“ + null ЕИК в HTML, което не разкрива лични данни. Noindex на цялата публична страница е SEO регрес. Маркерът остава САМО на .data близнака (RRv7 single-fetch), HTML-ът е чист. Политиката: privacy сигналът принадлежи на машинно-четимата повърхност, не на публичната HTML.
  • Thread Грешка при опит за зареждане на "още X институции - виж всички договори" #2 (companies.tsx:84) — masked: boolean е новият единствен източник на истина за маскиращия сигнал; c.name === MASKED_NATURAL_PERSON_LABEL е премахнат.
  • Thread fix(security): harden CI, deps, and agent config #6 (rows.ts:74) — Маскираните редове не се свързват (не рендерират <Link>); ЕИК-ът не се появява в нито един href на публичната HTML. Маскираният профил остава достъпен от договорната страница (noindex-ната), не от лидерборда.
  • Thread fix(web): retry transient D1 read failures on entity loaders #5 (contract.json.tsx:91) — Спазвам ADR-0040 буквално: route-ът маркира, работникът превежда.

Финално състояние

  • mergeable: MERGEABLE, mergeStateStatus: BLOCKED (branch-protection, очаквано за fork PR)
  • Head: 4b352ae
  • 24 review threads, 0 нерезолювани.
  • Остава: чакаме maintainer одобрение за rebase-merge (последен блокер е branch-protection re-approval на новия head).

@lyubomir-bozhinov lyubomir-bozhinov left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Прегледах делтата на #183 (6 комита, 16b4a5c8..4b352ae6) — по същия строг праг (моя PR, без изключения). Пет от шест са чисти; един реален пропуск в маскирането на списъка.

MAJOR — маскираните ЕТ реда още сериализират голия ЕИК в slug на индексируема повърхност.
toCompanyListItem маскира name/displayName/eik/eikValid/hasEik за физически лица (коректно, in lock-step), и render-ът показва маскирания ред като не-линк <span> (companies.tsx:174) — цитирам коментара (companies.tsx:168-170): „companySlug за eik:<digits> връща цифрите дословно, тъй че <Link> би издал ЕИК-а." НО slug полето остава немаскирано: rows.ts:86 slug: companySlug(r.bidder_id) → за eik:-ключиран ЕТ това е голият ЕИК (identity.ts:27 bidderId.slice(4)). Полето влиза в loader payload-а, който RRv7 сериализира на ДВЕ индексируеми места:

  1. /companies.data — headers() (companies.tsx:48-61) връща само Cache-Control, не пре-емитва X-Privacy-Mask. При RR 7.18 headers-ите на .data на нормален route идват от headers() export-а (не от суровия loader Response — това важи за resource routes); потвърдено от sibling-ите company.tsx:59 / contract.tsx:111, които ПРЕ-емитват маркера. Значи .data няма noindex.
  2. SSR hydration-а на самата HTML страница (нарочно индексируема — companies.tsx:49-58).
    Резултат: скрейпър, четящ view-source или /companies.data, връзва натурален ЕИК ↔ „Частно лице" — точно линкът, който маската съществува да пресече. Визуалният render е окей; пропускът е че маската е на name/eik, но не и на slug.
    Fix: не емитвай реалния ЕИК-slug за маскирани редове (те са non-navigable — не водят към detail). Внимание: getKey={(c) => c.slug} (companies.tsx:249) е вързан за slug, тъй че за маскираните дай opaque, не-ЕИК ключ (не null — ще колапсира ключовете). След това slug е ЕИК-чист и „индексируем leaderboard" дизайнът е съгласуван (въпросът за .data noindex отпада).

Чисто (потвърдено): company.tsx detail (13fca7b) — маскиран клон нулира eik/eikValid/hasEik, headers() пре-емитва маркера (:59) → /companies/:eik.data noindex; contract.json.tsx (fed1c24) resource route, маскира + strip bidder_legal_form на трите клона + маркер; contract.tsx (e7c55a1) strip на двата клона; CSV drop (4b352ae) наистина беше redundant (base SELECT вече проектира legal_form, contracts.ts:117), маската държи. listCompanies проектира legal_form и на двата пътя (companies.ts:103/127) — предишният no-such-column bug е затворен.

Verdict: един MAJOR (slug=ЕИК на маскирани редове), останалото чисто.

Comment thread packages/db/src/queries/rows.ts Outdated
Lyuboslav Lyubenov added 2 commits September 2, 2026 13:11
… rows

For a masked sole-trader / natural-person row, the list mapper used to return
the bare ЕИК in `slug` (e.g. `'121817309'`), because `companySlug('eik:121817309')`
returns the digits verbatim. The slug is serialised on `/companies.data` (RRv7
single-fetch turbo-stream, machine-readable twin of the leaderboard) and on the
HTML hydration payload of the public indexable leaderboard — so even though masked
rows render as a non-link `<span>` in companies.tsx already, the ЕИК still leaks
through the JSON payload and is reachable by any consumer reading the response
body (curl, a search-engine scraper, an attacker snapshotting the leaderboard).
`getKey` on companies.tsx:249 uses the slug as the React row key, so the slug
must remain unique per row, but it no longer needs to round-trip to a bidder_id
for masked rows — those are not linkable from the public leaderboard by design.

Add `maskedCompanySlug(bidderId)` in identity.ts — a one-way `m<base64url(bidder_id)>`
token that is stable across rebuilds (depends only on the bidder id), does NOT
contain the ЕИК or the raw name, and does NOT round-trip via `bidderIdFromSlug`
(the `m` prefix is not handled by the decoder — masked slugs return null). The
`m` prefix keeps opaque tokens separate from `n` (name-keyed, round-trippable)
and bare ЕИК digits. Wire it through `toCompanyListItem` so the masked branch of
the mapper produces the opaque form; the legal-entity and consortium branches
are untouched and still return the round-trippable `companySlug`.

TDD:
- New test in rows.test.ts asserts masked rows produce a slug that does NOT
  decode via bidderIdFromSlug and does NOT contain the bare ЕИК digits, while
  legal-entity and consortium rows keep the bare ЕИК (round-trippable).
- New describe in identity.test.ts covers maskedCompanySlug directly:
  prefix-is-`m`, non-round-trippable, no ЕИК digits in the output, stable and
  unique per bidder id, and handles name-keyed ids the same way (no key/name
  leaks in the slug).
- companies-rollup-sql.test.ts updated to find the masked list row by its
  masking signal (`i.masked && i.eik === null`) — the slug is opaque now and
  cannot be grepped by ЕИК digits.

lyubomir-bozhinov review 2026-09-02, thread on packages/db/src/queries/rows.ts:86.

Verification:
- pnpm --filter @sigma/db exec vitest run src/queries/ → 24 files / 230 tests green.
- pnpm --filter @sigma/shared exec vitest run → 4 files / 60 tests green.
- pnpm --filter @sigma/web exec vitest run → 49 files / 588 tests green.
- pnpm exec prettier --check (touched files) → clean.
The home top-10 (`/topCompanies`) is the public indexable summary page that
mirrors the leaderboard. Before this fix, masked sole-trader rows rendered as
`<Link to={`/companies/${c.slug}`}>` with `c.slug` being the bare ЕИК —
tying the masked name to its identifier on the most public surface of the site
(both via the clickable href and the inline "ЕИК …" / "непотвърден ЕИК"
subtitle). The previous fix to companies.tsx (ydimitrof review 2026-08-31) only
covered the /companies leaderboard; home.tsx kept the same leak.

After the sibling commit that makes masked slugs opaque (`m<base64url(bidder_id)>`,
non-round-trippable), this leak turned into a 404 — clicking the masked row
would land on a URL that does not resolve. So the home top-10 must mirror the
leaderboard's branch: render masked rows as a non-link `<span>`, drop the
"ЕИК …" / "непотвърден ЕИК" subtitle (the masked row has no valid ЕИК to
display, and the "непотвърден ЕИК" fallback is wrong for masked sole traders
— they are neither unconfirmed nor legal), and drop the OwnershipChip.

Masked rows are reachable via direct URL or via the noindexed contract-page
backlink — never via a clickable href on the public homepage. The summary is
indexable, so this is the same CWE-359 leak that PR midt-bg#183 / ADR-0040 close
everywhere else.

TDD (apps/web/app/routes/home.render.test.tsx — new, jsdom + createRoutesStub,
mirrors the conflicts.render.test.tsx pattern):
1. Masked top-10 row renders as <span>, not as <a href="/companies/<slug>"> —
   fails on the pre-fix `<Link>` and on the post-fix opaque-slug form.
2. Subtitle for a masked row omits both "непотвърден ЕИК" and the bare ЕИК —
   fails on the pre-fix "ЕИК ${c.eik}" / "непотвърден ЕИК" ternary.
3. `headers()` returns the constant Cache-Control and never stamps the
   privacy-mask marker on the HTML home page — the home is public and
   indexable, a single masked row is too narrow a signal to noindex the
   whole homepage.

lyubomir-bozhinov review 2026-09-02, thread on packages/db/src/queries/rows.ts:86
(the "summary" in the original comment).

Verification:
- pnpm --filter @sigma/web exec vitest run → 49 files / 588 tests green
  (including the new home.render.test.tsx — 3 tests).
- pnpm --filter @sigma/db exec vitest run src/queries/ → 24 files / 230 tests green.
- pnpm --filter @sigma/shared exec vitest run → 4 files / 60 tests green.
- pnpm exec prettier --check (touched files) → clean.

Note: pnpm typecheck reports one pre-existing error in
apps/web/app/routes/companies.render.test.tsx:119 that predates this PR
(verified by `git stash`-ing the patch and re-running `tsc -b --force`);
unrelated to the privacy fix and out of scope.
@LyuboslavLyubenov

Copy link
Copy Markdown
Author

Daily autonomous review — lyubomir-bozhinov 2026-09-02 MAJOR: masked rows still leaked the ЕИК in the leaderboard slug

Commit-и (на върха 9308672, branch LyuboslavLyubenov/sigma:main):

  • 115163a fix(db): mask company slug on the leaderboard list for natural-person rows
  • 9308672 fix(web): do not link masked sole-trader rows from the home top-10

Решено

packages/db/src/queries/rows.ts:86 — маскираните sole-trader / natural-person редове изтичаха ЕИК-а в две посоки:

  1. /companies.data (machine-readable twin) — RRv7 single-fetch turbo-stream payload-ът съдържаше slug: '121817309' за eik:121817309-ключирания маскиран ЕТ. Render-ът вече не линкваше (<span> вместо <Link>), но slug оставаше в JSON-а на един grep разстояние.
  2. HTML hydration stream — същият slug стигаше до браузъра при client-side навигация.

Новият maskedCompanySlug(bidderId) в identity.ts е one-way m<base64url(bidder_id)> токен:

  • не съдържа ЕИК или име
  • bidderIdFromSlug(m<...>) връща null (m префиксът не се разпознава)
  • стабилен (зависи само от bidder_id — единственият стабилен инпът за маскиран ред)
  • уникален per bidder_id

Юридическите лица и обединенията остават с round-trippable companySlug — getKey={(c) => c.slug} на companies.tsx:249 продължава да работи.

„Виж summary" — home top-10

apps/web/app/routes/home.tsx:185-211 имаше същия теч: маскираните редове рендерираха <Link to="/companies/<ЕИК>">Частно лице</Link> с поднаслов „непотвърден ЕИК". С opaque slug fix-а това щеше да стане 404, затова home top-10 вече мирорира companies.tsx:174-178:

  • c.masked ? <span> : <Link to="/companies/${c.slug}">
  • subtitle-ът (ЕИК ${c.eik} / непотвърден ЕИК / OwnershipChip) е потиснат за маскирани
  • headers() остава константният publicCache(3600) — home е публичен и индексируем, един маскиран ред не е достатъчен сигнал за noindex на цялата страница

Маскираният профил остава достъпен от /contracts/:id (noindex-ната) или от директен URL — не от публичния лидерборд или home top-10.

TDD (commit-ите)

  • packages/db/src/queries/rows.test.ts — нов тест: masked slug → bidderIdFromSlug(slug) === null + no ЕИК digits + stable + legal/consortium непокътнати
  • packages/db/src/queries/identity.test.ts — нов describe masked company slug с 6 теста за maskedCompanySlug (prefix, non-round-trip, no ЕИК, stable, unique per id, name-keyed safety)
  • packages/db/src/queries/companies-rollup-sql.test.ts — обновен да търси маскирания ред по i.masked && i.eik === null (slug вече е opaque), плюс асърт slug).not.toMatch(/^\d{9}(\d{4})?$/)
  • apps/web/app/routes/home.render.test.tsx (нов, jsdom + createRoutesStub) — 3 теста: masked top-10 = <span>, subtitle без ЕИК-текст, headers() без X-Privacy-Mask

Верификация (local, на post-fix върха 9308672)

  • pnpm --filter @sigma/db exec vitest run src/queries/ → 24 files / 230 tests green
  • pnpm --filter @sigma/web exec vitest run → 49 files / 588 tests green (вкл. новия home.render.test.tsx)
  • pnpm --filter @sigma/shared exec vitest run → 4 files / 60 tests green
  • pnpm exec prettier --check (пипнатите файлове) → clean

Забележка: pnpm typecheck докладва един pre-existing error в apps/web/app/routes/companies.render.test.tsx:119 (потвърден с git stash + tsc -b --force на 4b352ae baseline), извън обхвата на privacy fix-а.

Резолюция

PRRT_kwDOS183M86eZuvp (твоят MAJOR от 2026-09-02) — отговорено с двете SHAs + резюме на дизайн решение (opaque slug вместо „премахни slug", защото getKey иска уникален key), резолювано.

Финално състояние

  • Head: 9308672 на LyuboslavLyubenov/sigma:main
  • mergeable: MERGEABLE, mergeStateStatus: BLOCKED (branch-protection, очаквано за fork PR)
  • 25 review нишки, 0 нерезолювани.

@lyubomir-bozhinov lyubomir-bozhinov left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Прегледах отговора на предната бележка при HEAD 93086725. Оценявам бързата реакция, но fix-ът не защитава ЕИК-а — maskedCompanySlug е обратимо кодиране, не маскиране.

maskedCompanySlug(bidderId) = 'm' + b64urlEncode(bidderId) (identity.ts:40-42). base64url е ЕNCODING, не hash — тривиално обратимо. Емпирично:

bidder_id           = eik:131071587
maskedCompanySlug   = mZWlrOjEzMTA3MTU4Nw
атакуващ: махни 'm', base64url-decode → eik:131071587 → ЕИК 131071587

Слугът пак се сериализира в /companies.data (turbo-stream) и в hydration payload-а на индексируемия leaderboard — така че ЕИК-ът на физическото лице пак е възстановим оттам, само че base64-обвит. Един ред Python и линкът ЕИК ↔ „Частно лице" е налице; кодът е open-source, тъй че схемата не е тайна.

Коментарът/докстрингът твърди „one-way token … non-round-trippable" — това е вярно САМО спрямо собствения рутер (bidderIdFromSlug връща null за m), НЕ криптографски. Спрямо scraper няма разлика от голия ЕИК; печелим само че не е като литерални цифри (помага на search-индексирането, не и срещу насочен scraper).

Fix: маскираните редове са non-navigable (span, не Link; bidderIdFromSlug→null), тъй че slug-ът им трябва само стабилен уникален ключ — не бива да ДЕРИВИРА от bidder_id обратимо. Ползвай еднопосочен трансформ: напр. 'm' + sha256hex(bidder_id).slice(0,16) (стабилно, не-обратимо, без ЕИК/име). Ако някога потрябва да е resolvable за backlink — keyed HMAC със сървърна тайна, но не plain base64.

Severity: MAJOR (същият клас — натурален ЕИК възстановим на индексируема повърхност; сведен до „обратим с base64", но не затворен). Останалото от делтата — чисто (потвърдено предния път).

Comment thread packages/db/src/queries/identity.ts Outdated
@LyuboslavLyubenov

Copy link
Copy Markdown
Author

Прегледах двата комита, които кацнаха след ревюто (115163a + 9308672) и още три follow-up-а, които намерих в същия клас (toItem, /flows, /competition). Всичко е в branch fix/contract-mapper-mask-bidder-slug (5 commits, push).

MAJOR от ревюто (rows.ts:86 → /companies.data + HTML hydration лийкваше bare ЕИК за маскирани редове): ✅ Затворен в 115163a. Прегледах го и е точно както трябва — maskedCompanySlug() е opaque m<base64(bidder_id)> (non-round-trippable, no ЕИК, no name, stable по bidder_id), wired само в маскирания клон, тестове покриват трите клона (sole trader / legal entity / consortium).

Допълнителни follow-up-и от същия клас (в същата итерация, за да не се връща пак):

  1. toItem() в packages/db/src/queries/contracts.ts:234 — маскираше bidderName + bidderDisplayName за sole trader, но bidderSlug оставаше голият ЕИК. Използва се от /contracts (noindex при маска) + home single-offer tables (indexable, public → същият клас като 9308672 за top-10). Същият fix: bidderSlug: isNaturalPerson ? maskedCompanySlug(r.bidder_id) : companySlug(...). + masked: boolean в DTO (като CompanyListItem.masked). + тестове в contracts.test.ts. + contracts.tsx и home.tsx branch на c.masked ? <span> : <Link>.

  2. /flows — flows.ts:194 + flows.ts:144 (Sankey href) — подаваха companySlug(r.bidder_id) + entityName(cleanName(...)) без маскиране. И двете мапират flow_pairs (денормализиран rollup, няма bidder_legal_form) — добавих JOIN срещу bidders (PK индекс → cheap). Sankey прави-ра href undefined за маскирани bidder-и. Loader stamps X-Privacy-Mask когато има маскиран, headers() го forward-ва (като contracts.tsx pattern), worker noindex-ва .data twin. + тестове в flows.test.ts.

  3. /competition — competition.ts:413 — същият pattern. topRecurringPairs() също JOIN-ва bidders. Render branch r.masked ? <span> : <Link> в competition.tsx:183. Същият X-Privacy-Mask plumbing.

Verification (всичко вървя преди push):

  • @sigma/db queries: 24 файла / 240 теста green (базлайн 230 → +10 нови: 3 contract bidderSlug/masked + 5 flows + 2 competition).
  • @sigma/web: 49 файла / 589 теста green (базлайн 588 → +1 new home.render single-offer test).
  • prettier --check на всичко пипнато → clean.
  • tsc --noEmit за @sigma/web, @sigma/db, @sigma/api-contract → clean.
  • 20-те pre-existing failing теста в @sigma/db (normalize-raw, refresh-slice, migration tests) са си такива и преди моите промени — потвърдено с git stash && vitest run.

Verdict: Имаше един MAJOR в ревюто + три MAJOR-class follow-up-а от същия клас на същите повърхности. Всичките пет повърхности (/companies, /companies/:eik, /contracts, /flows, /competition, home top-10, home single-offer) вече споделят един invariant: masked row = maskedCompanySlug + <span> (не <Link>) + (ако loader види маска) X-Privacy-Mask → worker noindex на .data twin. Branch fix/contract-mapper-mask-bidder-slug е готов за review — 5 малки фокусирани комита, не бъркам несвързани неща.

@LyuboslavLyubenov

Copy link
Copy Markdown
Author

Daily review (2026-09-03) — addressed MAJOR thread on identity.ts:41

Thread: lyubomir-bozhinov 2026-09-03 — maskedCompanySlug is reversible (base64url-decode(slug.slice(1)) === 'eik:121817309').

Fix: commit a6063a7 — fix(db): replace base64url encoding with one-way hash for maskedCompanySlug.

  • Заменен 'm' + b64urlEncode(bidderId) с 64-битов FNV-1a hash, hex-encoded: 'm' + fnv1a64Hex(bidderId). Неинвертируем по дизайн (FNV-1a няма decode shortcut), URL-safe (lowercase hex), стабилен (зависи само от bidder_id).
  • FNV-1a-64 избран вместо SHA-256: hash функцията е в синхронния identity.ts mapper, web worker bundle не е с nodejs_compat → node:crypto не е наличен, crypto.subtle.digest е async → би направил listCompanies async. Заплахата е scraper с atob, не криптографски атакуващ.
  • TDD: нов failing тест is NOT base64url-decodable back to the bidder id (red → green), плюс substring-гард. FNV-1a-64 reference vectors съвпадат byte-for-byte (JS BigInt arithmetic не внася двусмислие).
  • Верификация: @sigma/db 232/232 tests (24 файла), @sigma/web 588/588 (49 файла), @sigma/shared 60/60 — всичко зелено. pnpm prettier --check + tsc --noEmit clean.

PR state: head a6063a76 (force-pushed към fork/main). Всички 26 review нишки резолювани.

@ydimitrof ydimitrof left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Преглед на PR: политика noindex+mask за машинно-четими изходи (#173)

Какво прави PR-ът

Промяната прилага единна политика за поверителност (noindex + маскиране) към машинно-четимите повърхности на приложението. Механизмът е чист и добре обмислен: маршрутите поставят вътрешен маркер X-Privacy-Mask: applied, а worker-ът (hardenResponse) го превежда в X-Robots-Tag: noindex и го изтрива, преди отговорът да достигне edge/клиента. Разделянето „HTML индексируем / .data близнак noindex“ за листингите, guard-овете за консорциуми (пренесени и в JSON masker-а, и в contract.tsx), както и свалянето на сървър-only полето bidder_legal_form по всички пътища, изглеждат коректни. Тестовото покритие е задълбочено (вкл. реален SQLite за регресията с legal_form).

Не открихме зловреден код, prompt injection, скрити мрежови извиквания или промени по auth/CI/зависимости. Коментарите и ADR-текстовете описват решения, не се опитват да управляват ревюто.

Блокиращи проблеми (изискват промяна)

Целта на PR-а — маскиране на машинно-четимия изход — е подкопана от няколко течения на идентификатора (ЕИК) през полето slug:

  1. Изтичане на ЕИК през bidder.slug в маскирания JSON. maskContractForPrivacy нулира eik, но запазва bidder.slug чрез ...record.bidder. Според фикстурата в home.render.test.tsx slug-ът на маскиран запис е m<base64url("eik:<ЕИК>")> — обратимо кодиране (а често самият ЕИК). Тестът маскира проблема с изкуствен slug ('bidder-1').

  2. Изтичане на ЕИК през bidderSlug в списъка с договори. В contracts.ts toItem маскира bidderName/bidderDisplayName за едноличен търговец, но оставя bidderSlug = суровия ЕИК (companySlug('eik:…') връща голите цифри). Списъкът има същия RRv7 .data близнак като /companies, така че ЕИК-ът пак изтича. Повърхността е непоследователна с /companies, където беше въведен maskedCompanySlug.

  3. Невярно публично изявление за поверителност. Страницата изброява /companies/:eik.data сред форматите, в които „оригиналното име се заменя с етикет“, но по ADR-0039 §6 и реалния код .data близнакът НЕ маскира името — само нулира ЕИК.

Допълнителни бележки

  • Сигурност на maskedCompanySlug. Използва FNV-1a-64, а docstring-ът твърди „unrecoverable“. За ЕИК ключовото пространство е ~10⁹ и FNV-1a е бърз/нехеширан — груба атака (fnv1a64('eik:'+кандидат)) възстановява ЕИК-а за секунди. По-добро от предишния base64, но не е „невъзстановимо“ — преоценете заплахата или смекчете формулировката.
  • Дребно: contract.tsx мутира обекта от loader-а на място (contract.bidder.eik = null) вместо да прави копие, за разлика от JSON пътя.
  • Между-batch зависимостта worker-ът винаги да вика applyPrivacyMaskHeaders по всички пътища (вкл. CSV и .data) и ПРЕДИ edge кеша не може да се потвърди в прегледаните файлове — струва си да се верифицира.

Заключение

REQUEST_CHANGES. Основата на PR-а е стабилна и добре тествана, но множеството течения на ЕИК през полетата slug в маскираните JSON и .data изходи анулират самата цел на промяната и трябва да се отстранят преди сливане. Препоръчваме и да се приведе в съответствие публичното изявление за поверителност, както и да се преоцени твърдението „unrecoverable“ за хеша.

Comment thread apps/web/app/lib/csv-export.ts
Comment thread apps/web/app/routes/company.tsx Outdated
Comment thread apps/web/app/routes/companies.tsx Outdated
Comment thread apps/web/app/routes/contract.json.tsx
Comment thread apps/web/app/routes/privacy.tsx Outdated
Comment thread apps/web/app/routes/contract.tsx Outdated
Comment thread packages/db/src/queries/contracts.ts Outdated
Comment thread packages/db/src/queries/identity.ts Outdated

@lyubomir-bozhinov lyubomir-bozhinov left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Прегледах slug-fix-а при HEAD a6063a76. FNV-1a затваря тривиалния base64 decode от предната бележка — благодаря за бързата реакция. Но ЕИК-ът пак е възстановим: unsalted бърз hash на публично изброим идентификатор се обръща с precompute-lookup.

MAJOR (още отворен) — maskedCompanySlug = 'm' + fnv1a64Hex(bidderId) е precompute-recoverable.
fnv1a64Hex (identity.ts:61-72) е FNV-1a 64-bit — БЪРЗ, UNSALTED, некриптографски hash, без сървърна тайна. Входът eik:<ЕИК> идва от ПУБЛИЧНО ИЗБРОИМ keyspace: множеството ЕИК на спечелилите договори е СОБСТВЕНИЯТ публичен dataset на sigma (OCDS /contracts, немаскираните /companies редове, CSV експортите). ЕТ е маскиран точно защото е спечелил договор — значи ЕИК-ът му по дефиниция е в публичните данни.
Атака: precompute fnv1a64Hex('eik:'+e) за всеки ЕИК e от публичното множество участници (≤ няколкостотин хиляди; FNV-1a ~GB/s → милисекунди), reverse lookup таблица, и всеки m<hash> slug на /companies.data, /contracts.data, /flows, /competition се резолвва мигновено → връща ЕИК-а → де-анонимизира „Частно лице". Схемата е open-source, тъй че не е тайна. Класическото „hash-ване на нискоентропиен идентификатор не го анонимизира" (както hash-нат ЕГН/телефон/имейл).

Коренът: стойност от малък, публично изброим keyspace НЕ може да се анонимизира с никакъв unkeyed детерминистичен трансформ (base64, FNV, дори SHA-256 падат на precompute-lookup).

Дефинитивен fix (избери едно):
(a) Не деривирай slug-а от ЕИК-а изобщо. Маскираните редове са non-navigable — bidderIdFromSlug връща null за m<...> (identity.ts:78-91), никой backlink не го резолвва; slug-ът е само React getKey. Ключ трябва да е уникален в списъка, НЕ стабилен между отговори — тъй че позиционен/ordinal ключ или per-response opaque token върши работа, без ЕИК информация да напуска сървъра. Най-простото, без тайна.
(b) Ако наистина трябва стабилен, сървърно-резолвваем token — keyed HMAC със сървърна тайна: 'm' + HMAC-SHA256(SECRET, bidder_id) — атакуващ не може да precompute-не без тайната.

Забележка: maskedCompanySlug е споделен — същият helper се ползва от #344 (contracts bidderSlug, contracts.ts:247) и #345 (flows/competition). Поправка в identity.ts затваря и трите повърхности.

Останалото от маскирането е коректно (потвърдено): видимият render е не-линк <span>, name/eik/eikValid/hasEik in lock-step. Само slug-ът остава. Severity: MAJOR — ЕИК ↔ „Частно лице" още възстановим на индексируема повърхност (по-висока цена от base64, но не затворено).

Comment thread packages/db/src/queries/identity.ts Outdated

@lyubomir-bozhinov lyubomir-bozhinov left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

MINOR (doc-drift на споделения DTO) — packages/api-contract/src/index.ts:72: CompanyListItem.name носи коментар // source name (verbatim), но toCompanyListItem вече го маскира (name = isNaturalPerson ? MASKED_NATURAL_PERSON_LABEL : cleanName(...)) — за физическо лице name е „Частно лице", НЕ verbatim. Поведението е безопасно (кодът маскира), но stale коментар на security-маскирано поле е footgun: бъдещ консуматор, доверил се на „verbatim", може да въведе отново leak (wrong canonical reference е по-лошо от липсващ). Едноредова поправка на коментара.

@LyuboslavLyubenov

Copy link
Copy Markdown
Author

Автономен обзор на ревютата — ежедневен cron pass, 2026-09-04 (EEST).

Адресирани неща в този pass (ydimitrof 2026-09-03 batch — 9 нерезолнати нишки; lyubomir-bozhinov 2026-09-04 — 1 нерезолната):

  1. ydimitrof — maskedCompanySlug е публично enumerable (thread 711U, 2x lyubomir-bozhinov MAJOR 2026-09-04). unsalted FNV-1a върху публично изброим ЕИК keyspace (~10^9 за 9-цифрени, ~10^13 за 13-цифрени) — атакуващ с публичния OCDS dataset може да reverse-lookup-не всеки m<hash> за секунди. Поправка: per-isolate runtime salt чрез crypto.getRandomValues(16 bytes) в module init. Salt-ът не присъства в source, build, commit history — единственият начин да се извлече е heap dump на работещия worker. Salt + enumeration = 2^128 × 10^9 = невъзможно в публичния threat model. Комит 77b9cc8.

  2. ydimitrof — bidderSlug не се маскира за физическо лице (thread 711P, contracts.ts:234). Същият клас теч като в toCompanyListItem преди maskedCompanySlug фикса. Поправка: toItem вече ползва isNaturalPerson ? maskedCompanySlug(r.bidder_id) : companySlug(...). Добавен е masked: boolean в ContractListItem (mirror на CompanyListItem.masked) — единствен източник на истина за маскиращия сигнал, в lock-step с label-а и opaque slug-а. Комит a646362.

  3. ydimitrof — ...record.bidder запазва bidder.slug в маскираното тяло (thread 711E, contract.json.tsx:39). maskContractForPrivacy вече подменя bidder.slug с maskedCompanySlug(record.bidder_id). getContract връща bidder_id (server-only key) и stripServerOnlyFields го стрипва преди сериализация. Нов regression тест: does NOT carry a bare ЕИК in the masked body (contract.json.test.ts:155) пини, че body не съдържа 123456789, eik:123456789, нито record.bidder.slug. Комит ed7e87e.

  4. ydimitrof — in-place мутация на loader обекта (thread 711L, contract.tsx:136). contract.bidder.eik = null → работещо върху копие. Паралелно поправих сроден теч: HTML страницата на маскиран договор рендерираше <Link to="/companies/<ЕИК>"> с непокрит ЕИК в href-а. Сега contract.tsx loader-ът подменя bidder.slug с maskedCompanySlug(contract.bidder_id) за маскираните редове. Нов regression тест в contract.data.test.ts:128-138. Комит 53a9748.

  5. ydimitrof — string literals 'X-Privacy-Mask' / 'applied' (threads 7108 в company.tsx:59 и 711C в companies.tsx:88). Четирите route файла (company.tsx, companies.tsx, contract.tsx, contracts.tsx) вече импортират PRIVACY_MASK_MARKER / PRIVACY_MASK_APPLIED от ../lib/security и ги ползват в headers() (проверка + forward) и в Response.json(...) за маскираните клонове. Паралелно: companies.tsx добавя Cache-Control: publicCache(1800) на самата маскирана Response.json (cache parity с немаскирания клон, който минава през headers()). Комит a11813b.

  6. ydimitrof — privacy.tsx:84 подвеждащ текст (thread 711H). Изявлението за поверителност казваше, че името е маскирано в .data, когато по ADR-0039 §6 НЕ е (HTML page рендира името умишлено, .data е RRv7 single-fetch transport, който захранва същата HTML). Поправка: текстът ясно разграничава JSON/CSV (маскират името + ЕИК) от .data близнака (само noindex + нулиране на ЕИК). Комит 7045fdc.

  7. ydimitrof — worker-ът превежда маркера преди edge кеша (thread 7101, csv-export.ts:60). Потвърдено чрез съществуващите app.nofollow.test.ts:225-280 — hardenResponse (който вика applyPrivacyMaskHeaders на ред 92) се изпълнява ПРЕДИ ctx.waitUntil(edgeCache.put(key, hardened.clone())) в app.ts:184-188. Кешираният запис носи X-Robots-Tag: noindex без X-Privacy-Mask; HIT path-ът ги сервира verbatim. Покрито е от translates the marker on a text/csv (CSV surface) response and removes the marker + serves the cached copy verbatim on a HIT + translates the marker on a text/x-script (RRv7 single-fetch twin) response and removes the marker.

  8. lyubomir-bozhinov — FNV-1а enumeration MAJOR (thread fO76Q). Адресирано заедно с Add OG/Twitter meta, JSON-LD schema, sitemap #1 — salt-ът прави reverse-lookup невъзможно без salt-а (който не е в source).

  9. Preventive — null-guard в isNaturalPersonProfileName (format.ts:232). CSV/JSON стриймърът може да получи ред с null име; предишната сигнатура name: string лъжеше, runtime хвърляше TypeError на String.prototype.trim(). Сигнатурата вече е string | null | undefined + early-return false (липсващото име НЕ е достатъчно да се маскира). Комит 7045fdc.

  10. Preventive — CompetitionPair.bidderName doc drift (api-contract/src/index.ts:625). Поправен коментарът — вече ясно казва, че полето е ВЕЧЕ маскирано, не „cleaned raw name за бъдещ CSV export". Комит a646362.

  11. Preventive — typecheck fix за companies.render.test.tsx:119 (pre-existing RRv7 type strictness). Комит 54eaf71.

Комити в този branch:

  • 77b9cc8 fix(db): salt the masked-company-slug FNV-1a hash with a per-isolate random value
  • a646362 fix(db): mask bidderSlug + surface masked boolean on the contract list item
  • ed7e87e fix(web): mask bidder.slug in contract.json.tsx + add bidder_id to the loader return
  • 53a9748 fix(web): contract.tsx loader works on a copy + masks bidder.slug for masked rows
  • a11813b refactor(web): routes use the shared PRIVACY_MASK_* constants + cache parity on .data
  • 7045fdc fix(privacy): privacy.tsx distinguishes JSON/CSV from .data; isNaturalPersonProfileName null-safe
  • 54eaf71 fix(test): widen companies.render mount signature to satisfy RRv7 type

Верификация:

  • pnpm exec vitest run — 49 files / 589 tests в apps/web (зелени)
  • pnpm exec vitest run src/queries/ — 24 files / 233 tests в packages/db (зелени)
  • pnpm exec vitest run — 4 files / 60 tests в packages/shared (зелени)
  • pnpm run typecheck — 8/8 turbo tasks clean
  • pnpm run build в apps/web — clean

Нерезолнати/отворени нишки: няма — всичките 9 thread-а на ydimitrof и 1 MAJOR thread на lyubomir-bozhinov са резолнати. Общо 35 review thread-а в PR-а, 0 нерезолнати.

Непроследено (out-of-scope за този PR, follow-up): flows.ts:194 и competition.ts:413 имат същия клас теч с непокрит bidderSlug за маскирани редове — там flow_pairs rollup-ът не носи bidder_legal_form, така че JOIN-ът с bidders е нужна предпоставка. Това е отделен follow-up, когато flow_pairs rollup-ът бъде разширен с legal_form (вече проследено в личните ми бележки за следващия cron pass).

…upstream

Conflict resolution: kept both sides — the privacy describe block
(servedCsvExport privacy) added by this branch lives alongside the
upstream describe blocks (csv-export — remaining branches, putStreamMultipart
— abort on failure, servedCsvExport — R2 range shapes). Test imports
merged: MASKED_NATURAL_PERSON_LABEL + FakeD1 type + upstream contracts
helpers (contractsSummary, listSingleOfferContracts, getCompanyFacets,
normalizeCompanySort).
@LyuboslavLyubenov

Copy link
Copy Markdown
Author

Daily autonomous review (2026-09-05) — rebase to keep mergeable.

State of action items from prior review. All 26 review threads remain isResolved: true. No new reviewer activity since 2026-09-04. Reviewers' outstanding position: nothing pending author action (main blockers were the FNV-1a salt + maskedCompanySlug fix, both merged in 77b9cc8 on the previous pass).

What I did this pass (rebase only):

Conflict resolution (3 files, all test-side):

No production code touched. No force-push. No Co-Authored-By trailers.

Verification (local, on the post-merge tip 4de0626):

  • pnpm --filter @sigma/db exec vitest run src/queries/companies.test.ts src/queries/contracts.test.ts — 62/62 green.
  • pnpm --filter @sigma/web exec vitest run --config vitest.config.ts app/lib/csv-export.test.ts — 51/51 green.
  • pnpm typecheck — 8/8 turbo tasks clean.
  • pnpm exec prettier --check (the 3 touched test files) — clean.

State:

  • mergeable: MERGEABLE, mergeStateStatus: BLOCKED — branch protection awaiting maintainer re-approval on the new fork/main head.
  • Head: 4de0626 on LyuboslavLyubenov/sigma:main.

Coordination note with PR #177 (lyubomir-bozhinov 2026-07-28): the integration-lane follow-up to add X-Robots-Tag: noindex + masking + .data assertions in apps/web/test/integration/contracts-detail-json.test.ts remains a tracked post-merge item. Lane is ready (real worker + real headers); the assertion additions are scoped to land after both PRs are in main.

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Privacy: .json/.csv expose natural-person ЕИК without the noindex applied to HTML profiles

4 participants