Files
progcode/editorial/agent-rewrites/103.json
T

8 lines
21 KiB
JSON

{
"index": 103,
"slug": "editorial-2025-02-field-ai-code-verification",
"title": "Как проверить AI-предложение кода до merge",
"excerpt": "Зелёный happy path не доказывает сохранность API и правил доступа. На примере трёх границ разбираем отрицательные тесты, команды проверки и критерий готовности изменения.",
"contentHtml": "<p>AI-ассистент предложил короткое исправление: основной тест прошёл, diff выглядел аккуратно, а запрос без роли всё равно получил доступ к операции. Такой дефект легко пропустить, потому что проверка ответа и проверка права отвечают на разные вопросы. Цена ошибки — не только повторная отладка: при неверной авторизации пользователь может прочитать или изменить чужой ресурс.</p>\n<p>Надёжный вопрос перед merge звучит так: какой контракт меняется, где находится граница доверия и каким наблюдением можно опровергнуть предложение? AI не является доказательством. Он ускоряет перебор вариантов, но каждое принятое утверждение нужно проверить в коде, тесте и контексте системы.</p>\n<h2>Сначала зафиксируйте границу изменения</h2>\n<p>Начните с diff, а не с объяснения ассистента. Запишите вход, выход, потребителя и правило, которое нельзя нарушить. Для функции доступа это субъект, действие и ресурс; для mapper — форма результата; для функции <code>preview</code> — неизменность входного состояния. Одно предложение может затронуть сразу несколько границ, даже если изменена одна строка.</p>\n<pre><code>git diff --check\ngit diff --unified=80 -- src/auth/can-edit.ts\nrg -n 'canEdit|authorize|role|tenant|owner' src test</code></pre>\n<p>Первые две команды показывают пробельные ошибки и полный локальный контекст. Третья помогает найти параллельные точки входа, но не доказывает, что найден полный граф вызовов. Имена каталогов здесь примерны: в конкретном репозитории подставьте фактические пути, а результат поиска сопоставьте с маршрутом запроса.</p>\n<h2>Граница API: правильное число не спасает неправильную форму</h2>\n<p>Представим mapper, который получает <code>subtotalCents</code> и <code>taxCents</code>. AI заменил поле ответа на <code>total</code>. Арифметика осталась верной, поэтому тест, сравнивающий только число, зелёный. Потребитель читает <code>amountCents</code> и получает <code>undefined</code>. Это не «мелкое переименование»: форма ответа — часть контракта.</p>\n<p>Проверяйте ключи и способ чтения результата тем же кодом, который использует приложение. Если изменение имени действительно нужно, сначала меняется контракт и все потребители, а затем тесты. Переписать assertion под новый ключ — не исправление, пока владелец API не принял несовместимое изменение.</p>\n<pre><code>function toAmount({ subtotalCents, taxCents }) {\n return { amountCents: subtotalCents + taxCents };\n}\n\nconst result = toAmount({ subtotalCents: 1000, taxCents: 180 });\nif (JSON.stringify(Object.keys(result)) !== JSON.stringify(['amountCents'])) {\n throw new Error('response shape changed');\n}\nif (result.amountCents !== 1180) {\n throw new Error('amount calculation changed');\n}</code></pre>\n<p>Этот фрагмент проверяет учебный объект и порядок ключей, заданный самим примером. В production контракт лучше закрепить схемой или типом и тестом реального потребителя; здесь намеренно показана минимальная проверка, которую можно запустить без фреймворка.</p>\n<h2>Граница состояния: preview не должен незаметно мутировать вход</h2>\n<p>Вторая ловушка — побочный эффект под безобидным именем. Функция <code>preview</code> должна построить представление, но предложенная реализация присваивает <code>draft.status = 'normalized'</code>. Caller передал собственный объект и после preview ожидает исходный статус. Проверка только строки ответа этого не увидит.</p>\n<p>Снимите состояние до вызова и сравните его после. Для простого плоского объекта пример выглядит так:</p>\n<pre><code>function preview(draft) {\n return { ...draft, status: 'normalized' };\n}\n\nconst draft = { id: 'd-17', status: 'new' };\nconst before = JSON.stringify(draft);\nconst view = preview(draft);\n\nif (JSON.stringify(draft) !== before) {\n throw new Error('preview mutated input');\n}\nif (view.status !== 'normalized' || draft.status !== 'new') {\n throw new Error('preview contract failed');\n}</code></pre>\n<p>Поверхностная копия не защищает вложенные массивы и объекты: если функция меняет <code>draft.items[0]</code>, понадобится глубокая копия, иммутабельная структура или отдельный тест на каждый изменяемый уровень. Поэтому нельзя автоматически заменить любой <code>Object.assign</code> на знак качества. Сначала определите владение данными и разрешённые мутации.</p>\n<h2>Граница доступа: allow-list сильнее списка исключений</h2>\n<p>Третий пример — проверка роли. Условие <code>actorRole !== 'viewer'</code> блокирует viewer, но пропускает <code>undefined</code>, опечатку и любую новую строку. Happy path для editor и даже отрицательный тест только для viewer не закрывают эту дыру.</p>\n<p>Если в учебном контракте разрешена ровно одна роль, разрешённое множество должно быть явным, а неизвестное значение — отклоняться:</p>\n<pre><code>function canEdit(actorRole) {\n const allowedRoles = new Set(['editor']);\n return allowedRoles.has(actorRole);\n}\n\nfor (const [role, expected] of [\n ['editor', true],\n ['viewer', false],\n [undefined, false],\n ['edtiro', false],\n]) {\n if (canEdit(role) !== expected) {\n throw new Error('unexpected decision for ' + String(role));\n }\n}</code></pre>\n<p>Это тест чистой функции, а не всей авторизации. В реальном запросе нужно проверить, откуда взялась identity, кто выбирает tenant и ресурс, где сервер принимает решение и что происходит при отказе. Проверка роли в браузере может улучшить интерфейс, но не должна выдавать доступ: решающий контроль находится на сервере, шлюзе или серверной функции.</p>\n<h2>Почему зелёные сигналы расходятся</h2>\n<table><thead><tr><th>Сигнал</th><th>Что он доказывает</th><th>Чего не доказывает</th><th>Следующее действие</th></tr></thead><tbody><tr><td>Unit-тест happy path</td><td>Разрешённый вход даёт ожидаемый результат</td><td>Отказ, форма ответа, побочные эффекты и соседний ресурс</td><td>Добавить отрицательный и contract-тест</td></tr><tr><td>Линтер или SAST</td><td>Найден или не найден известный шаблон</td><td>Бизнес-правило и смысл конкретного ресурса</td><td>Разобрать finding вручную по data flow</td></tr><tr><td>AI code review</td><td>Ассистент сформулировал комментарии в scope review</td><td>Полноту находок и обязательное решение владельца</td><td>Проверить комментарии и повторить review после нового diff</td></tr><tr><td>Ручной сценарий</td><td>Наблюдаемое поведение на выбранном окружении</td><td>Другие входы, окружения и параллельные entry point</td><td>Закрепить сценарий автоматическим тестом</td></tr><tr><td>Approval</td><td>Уполномоченный принял решение по известному scope</td><td>Отсутствие неизвестных рисков</td><td>Записать остаточный риск и условия отката</td></tr></tbody></table>\n<p>Разные статусы не складываются в магическое «всё безопасно». У каждого сигнала есть scope и слепая зона. Например, GitHub описывает Copilot code review как источник комментариев; по умолчанию это не approval, а автоматическая оценка готовности сама по себе не считается обязательным подтверждением. Это важное различие между подсказкой инструмента и полномочием на merge.</p>\n<h2>Воспроизводимая последовательность перед merge</h2>\n<ol><li><strong>Опишите инвариант.</strong> Запишите точную форму ответа, условие неизменности или пару «субъект — действие — ресурс». Слово «качественно» нельзя проверить.</li><li><strong>Определите scope.</strong> Перечислите изменённые файлы, входы, выходы, потребителей и серверные точки доступа. Для security-изменения отдельно отметьте trust boundary.</li><li><strong>Запустите базовые проверки.</strong> Выполните форматтер, typecheck, unit-тесты и <code>git diff --check</code>. Сохраните команды и версии инструментов.</li><li><strong>Проверьте разрешённый путь.</strong> Сравните значение, тип, форму ответа и побочные эффекты. Тест должен читать результат как реальный потребитель.</li><li><strong>Добавьте отрицательные входы.</strong> Используйте отсутствующее значение, неверную роль, чужой tenant, другой идентификатор ресурса и отказ downstream — только те варианты, которые входят в модель угроз.</li><li><strong>Проследите поток данных.</strong> Найдите источник identity, место авторизационного решения и обработку отказа. Не считайте клиентский флаг или скрытую кнопку защитой.</li><li><strong>Повторите review по новому diff.</strong> После исправления проверяется не старый комментарий, а обновлённый scope. Если сигнал относится к другой версии кода, он не подтверждает текущий merge.</li><li><strong>Выберите вердикт.</strong> <code>merge</code> означает, что заявленные проверки пройдены; <code>revise</code> — известен следующий фикс; <code>block</code> — есть расхождение, которое должен снять владелец риска.</li></ol>\n<p>Минимальный журнал проверки может быть обычным текстом в описании изменения:</p>\n<pre><code>scope: src/auth/can-edit.ts, test/auth/can-edit.test.ts\ncontract: only editor may edit a resource in the actor's tenant\npositive: editor + own tenant -> allow\nnegative: viewer, missing role, other tenant -> deny\nside_effects: request context unchanged\nchecks: npm test -- can-edit && git diff --check\nowner: team-auth\nverdict: revise</code></pre>\n<p>Строка <code>verdict: revise</code> здесь не формальность: если не проверен чужой tenant, честный результат — не merge. Журнал не заменяет тест и не делает проект безопасным, но оставляет проверяемую связь между риском, наблюдением и решением.</p>\n<h2>Что делать при расхождении evidence</h2>\n<p>Если форма API не совпала с потребителем, остановите подготовку merge и найдите владельца контракта. Если preview мутирует данные, зафиксируйте вход до и после, затем выберите иммутабельный результат или явно названную мутацию. Если запрос без роли или с чужим tenant получает доступ, блокируйте изменение до серверной проверки и негативного теста.</p>\n<p>Stop, revert и rollback — разные действия. Stop не меняет историю и лишь запрещает продолжать merge. Revert создаёт новое изменение, отменяющее конкретный коммит. Rollback возвращает уже доставленную систему к прежнему состоянию и требует отдельного плана для данных, миграций, флагов и совместимости. Не называйте блокировку rollback: это создаёт ложное ощущение, что восстановление уже подготовлено.</p>\n<h2>Ограничения применимости</h2>\n<p>Примеры выше — небольшие чистые функции, а не доказательство безопасности приложения. Они не проверяют токены, identity provider, срок сессии, CSRF, rate limit, кеши, гонки, multi-tenant запросы, базу данных, конфигурацию шлюза или эксплуатационные права. Даже полный набор unit-тестов не исключает дефект в маршруте, middleware или другом entry point.</p>\n<p>Allow-list подходит для показанного правила с одной ролью, но не заменяет policy engine для сложных правил, где важны атрибуты ресурса, отношения владельца, время и состояние. Для таких систем тестируйте таблицу разрешений и отказов на уровне API и интеграции. Секреты, персональные данные и production-токены нельзя помещать в prompt или учебный репозиторий; используйте обезличенные фикстуры.</p>\n<p>Официальные рекомендации также не являются сертификатом. OWASP говорит, что ручной security-review дополняет автоматические инструменты и особенно нужен для бизнес-логики, потоков данных и контекстных уязвимостей. NIST SSDF задаёт общий набор практик безопасной разработки и общий словарь, но не выбирает контракт конкретного сервиса. Эти документы помогают построить процесс, а границы и остаточный риск всё равно определяет команда.</p>\n<h2>Критерий готовности</h2>\n<p>AI-предложение готово к merge, когда можно независимо ответить на четыре вопроса: какой контракт оно меняет; какой позитивный и какой отрицательный путь проверены; где выполняется окончательная проверка доступа; какой владелец принял остаточный риск. К ответам приложены текущий diff, воспроизводимые команды и результат тестов.</p>\n<p>Если хотя бы один вопрос остаётся без наблюдаемого ответа, меняйте статус на <code>revise</code> или <code>block</code>. Такой вердикт не обвиняет инструмент и не требует отказаться от AI. Он просто оставляет merge только для изменения, чья граница, проверка и ответственность видны.</p>\n<figure><img src=\"/assets/editorial/2025/ai-code-verification-2025-evidence-chain.svg\" alt=\"Схема проверки AI-изменения: diff проходит через контракт, статический анализ, позитивный и негативный тесты, review и ручной сценарий\" /><figcaption>Каждый этап отвечает на свой вопрос. При расхождении scope подготовку merge останавливают и формулируют вопрос владельцу риска; сама подсказка AI не становится разрешением на доставку.</figcaption></figure>\\n<h2>Проверяемые источники</h2><ul><li><a href=\"https://docs.github.com/en/copilot/how-tos/use-copilot-agents/request-a-code-review/use-code-review\" target=\"_blank\" rel=\"noopener\">GitHub Docs: Using GitHub Copilot code review</a> — scope review, комментарии и ограничения approval assessment.</li><li><a href=\"https://cheatsheetseries.owasp.org/cheatsheets/Secure_Code_Review_Cheat_Sheet.html\" target=\"_blank\" rel=\"noopener\">OWASP: Secure Code Review Cheat Sheet</a> — diff-based review, trust boundaries, автоматические инструменты и ручной анализ.</li><li><a href=\"https://cheatsheetseries.owasp.org/cheatsheets/Authorization_Cheat_Sheet.html\" target=\"_blank\" rel=\"noopener\">OWASP: Authorization Cheat Sheet</a> — deny by default, проверка каждого запроса и серверное enforcement.</li><li><a href=\"https://csrc.nist.gov/pubs/sp/800/218/final\" target=\"_blank\" rel=\"noopener\">NIST SP 800-218: Secure Software Development Framework (SSDF) 1.1</a> — рамка практик для снижения риска уязвимостей и общий язык процесса.</li></ul>\n"
}