Экспорт коллекции API‑запросов в файлы для git — и как мы чуть не отправили токены в открытом виде

в 12:13, , рубрики: API-secret, Http-api-client, QA-test-api, REST-API-COLLECTION, ИБ

Классика жанра)

Я делаю PolyApiIDE, расширение браузера (хром,мозилла) — клиент для API‑запросов.

Почему свое, есть же всякие Postman, Bruno и еще много всего для браузера?

Весной 2026 года у Postman бесплатная команда схлопнулась до одного человека, а место в команде стало платным. «Native Git» в их десктопе, который раньше рекламировали как замену Git‑файлам, на деле не то же самое, что папка .bru в репозитории — это синхронизация через их же облако, просто с git‑историей поверх. Для многих команд это был последний повод, который откладывали: «пока бесплатно — сидим, разберёмся потом». Разбираться пришлось.

Ну и еще один аргумент — Postman периодами переставал грузиться (интерфейс не прогружался) в ввиду видимо санкционных моментов. Приходилось подрубать VPN. Думаю я не одинок, у коллег были теже проблемы. Я не говорю что инструмент плохой, Postman — отличный инструмент.

Еще один аргумент — в последние годы я работаю над гос проектами а тут очень жесткие требования к хранению секретов и прочих историй и как то к нам постучались из ИБ и тонко намекнули что Postman не совсем дружественный инструмент... и хранить в нем секреты, не дай ты боже от прод‑окружения, — не совсем безопасно во всех смыслах) В общем так и пришли к разработке своего инструмента

Ниже — история одной конкретной фичи: зачем понадобился «git‑friendly» экспорт коллекции, как устроена стабильная сериализация и разрешение коллизий имён файлов, и какую дыру в защите секретов мы нашли и закрыли по пути. Без сравнений с другими клиентами — только то, что случилось и что мы с этим сделали.

Была фича, но её было мало

В клиенте уже год как работал экспорт коллекции как патча — одного JSON‑файла с операциями вида upsertRequest / removeRequest / setCollectionMeta. Достаточно, чтобы переслать коллеге точечное изменение: поправил заголовок в одном запросе — экспортировал — коллега применил патч у себя. Рабочая штука для передачи «из рук в руки».

Но как только кто‑то попробовал положить такой файл в git и присылать изменения через PR, стало понятно: единый JSON‑файл — плохой объект для code review. Причины две:

  1. Любое изменение одного запроса меняет весь файл. git diff на PR из «поправил один заголовок» показывает нечитаемую простыню, потому что весь патч — это один JSON‑массив операций, и diff‑алгоритм не умеет резать его по границам логических кусков.

  2. Порядок ключей «пляшет». Обычный JSON.stringify сохраняет порядок вставки свойств объекта, а не какой‑то канонический порядок. Стоит внутреннему коду перестроить объект запроса чуть иначе при двух разных экспортах — и diff показывает изменения там, где по факту ничего не изменилось.

Это классическая проблема «сериализуемый снапшот состояния vs git‑friendly формат», и решать её нужно было не как «добавить фичу», а как «спроектировать формат заново», при этом не сломав уже существующий формат патча, который другой код (applyCollectionPatch, validateImport) уже умеет читать.

Один файл на запрос, стабильный порядок ключей

Решение оставило существующий однофайловый патч как есть — им продолжают пользоваться обычные «экспортировать / переслать одним файлом». Для git добавили отдельный режим: «Экспортировать как папку», который раскладывает ту же коллекцию на файлы.

Схема простая:

  • _collection.json — метаданные коллекции (имя, описание, auth, переменные);

  • дальше по одному файлу на запрос, путь строится из структуры папок коллекции: folder1/folder2/get-users.json;

  • каждый файл‑запрос — не новый формат, а тот же самый polyapiide.collection-patch с единственной операцией upsertRequest внутри. Значит, отдельный файл сам по себе остаётся импортируемым как патч одного запроса — существующий код чтения патчей ничего не знает и знать не должен о том, что где‑то рядом лежат ещё 50 таких же файлов.

Для стабильного порядка ключей — отдельная функция, которая используется только в этом режиме экспорта:

/**
 * Stable JSON for git-friendly folder export only.
 * Do not use for live single-file patches (downloadPatchJson / existing share files).
 */
export function stableStringify(value, space = 2) {
  return `${JSON.stringify(sortKeysDeep(value), null, space)}n`;
}

Комментарий в коде тут не для красоты — это осознанное ограничение: рекурсивная сортировка ключей объекта перед сериализацией даёт стабильный, детерминированный вывод (два экспорта одной и той же коллекции без изменений — побайтово идентичные файлы), но менять на неё существующий однофайловый формат патча было решено не делать. Так проще: старый формат не трогаем ради обратной совместимости, новый — специально проектируем под git с нуля, без оглядки на то, что уже кто‑то, возможно, парсит старый вывод построчно.

Имя файла из имени запроса — и что делать с коллизиями

Имя запроса — произвольная строка пользователя: пробелы, эмодзи, слэши, кириллица. Функция превращения в безопасный slug:

function slugify(name, empty = 'request') {
  const s = String(name || '')
    .trim()
    .toLowerCase()
    .replace(/[<>:"/\|?*u0000-u001f]+/g, '-')
    .replace(/.+/g, '-')
    .replace(/s+/g, '-')
    .replace(/-+/g, '-')
    .replace(/^-+|-+$/g, '')
    .slice(0, 80);
  return s || empty;
}

Достаточно очевидная функция — вырезать запрещённые в именах файлов символы (в том числе управляющие), схлопнуть повторяющиеся дефисы, обрезать длину. Интереснее то, что происходит, когда два разных запроса в одной папке после slugify дают одно и то же имя — «Get users» и «get‑users» не редкость, когда коллекцию собирает несколько человек.

Наивное решение — просто добавлять счётчик (get-users-2.json) — плохое, потому что при повторном экспорте порядок обхода запросов не гарантированно стабилен, и то, что вчера было get-users.json, завтра может стать get-users-2.json без единого реального изменения в самом запросе — то есть diff снова врёт. Вместо этого — суффикс из хвоста ID запроса, который у объекта запроса не меняется никогда:

function idSuffix(id) {
  const s = String(id || '').replace(/[^a-zA-Z0-9]/g, '');
  return s.slice(-8) || 'id';
}

function requestFilePath(request, used) {
  const folders = (request.folderPath || [])
    .map((p) => String(p || '').trim())
    .filter(Boolean)
    .map((p) => slugify(p, 'folder'));
  const base = slugify(request.name, 'request');
  const join = (file) => (folders.length ? `${folders.join('/')}/${file}` : file);
  let rel = join(`${base}.json`);
  if (!used.has(rel)) return rel;
  rel = join(`${base}-${idSuffix(request.id)}.json`);
  if (!used.has(rel)) return rel;
  let n = 2;
  while (used.has(rel)) {
    rel = join(`${base}-${idSuffix(request.id)}-${n}.json`);
    n += 1;
  }
  return rel;
}

Порядок деградации такой: сначала пробуем чистое имя (get-users.json) — для 95% случаев коллизий вообще нет, и не нужно портить читаемые имена файлов суффиксами там, где они не нужны. Если коллизия — добавляем восемь символов из ID запроса (он у конкретного запроса не меняется между экспортами, значит имя файла для этого конкретного запроса стабильно между прогонами). Числовой суффикс — уже совсем крайний случай, на который в реальных коллекциях мы пока не натыкались, но оставили как гарантию отсутствия бесконечного цикла.

У расширения нет доступа к файловой системе — какие тут вообще есть варианты

MV3-манифест браузерного расширения не даёт произвольного доступа к диску пользователя. Варианта, по сути, два:

  1. Последовательные скачивания через <a download> для каждого файла — работает везде, но на коллекции из полусотни запросов браузер начинает показывать предупреждение «сайт пытается скачать несколько файлов», и пользователю приходится руками раскладывать полсотни файлов из папки загрузок по нужной структуре подпапок.

  2. File System Access API (window.showDirectoryPicker()) — пользователь одним кликом выбирает папку на диске, дальше можно писать в неё сколько угодно файлов и создавать вложенные подпапки без единого дополнительного диалога.

Второй вариант закрывает UX‑проблему первого, но подходит не везде — API есть в Chrome/Edge, отсутствует в части других браузеров на движке Chromium и в Firefox. Поэтому реализация — с фолбэком: если showDirectoryPicker недоступен в текущем контексте, тихо откатываемся на последовательные скачивания.

async function writeFileInDirectory(rootHandle, relPath, content) {
  const parts = String(relPath || '')
    .replace(/\/g, '/')
    .split('/')
    .map((p) => p.trim())
    .filter(Boolean);
  if (!parts.length || parts.some((p) => p === '.' || p === '..')) {
    throw new Error('Некорректный путь файла патча');
  }
  const fileName = parts.pop();
  let dir = rootHandle;
  for (const part of parts) {
    dir = await dir.getDirectoryHandle(part, { create: true });
  }
  const handle = await dir.getFileHandle(fileName, { create: true });
  const writable = await handle.createWritable();
  await writable.write(content);
  await writable.close();
}

Проверка p === '.' || p === '..' в начале — не паранойя ради галочки. Путь файла собирается из имени папки коллекции и имени запроса, оба — пользовательский ввод (пусть и прогнанный через slugify выше). slugify вырезает / и , но explicit‑проверка на ./.. — отдельный барьер именно для этого конкретного класса ошибки (path traversal), на случай если когда‑нибудь появится другой источник имени файла, который slugify не прогонит. Дешёвая строка, которая не даёт целому классу багов появиться позже, когда код вокруг неё поменяется, а про эту проверку уже забудут.

Пока собирали это — нашли дыру

По пути к «положить коллекцию в git» всплыл вопрос: а что вообще попадает в эти файлы? Ответ — всё: полные заголовки запроса, тело, значения переменных, данные авторизации. Ровно то же самое, что уже год как отправлялось в однофайловом патче при обычном «Экспортировать» — просто раньше это шло в один файл, который пересылали коллеге лично, а не в набор файлов, который потенциально коммитят в репозиторий, видимый более широкому кругу людей, чем «коллега, которому я лично переслал файл».

При этом в кодовой базе уже год как жил зрелый регэксп‑санитайзер секретов — но использовался только в одном месте: перед отправкой истории AI‑чата на сервер. Он умел вычищать JWT, Bearer/Basic‑заголовки, ключи OpenAI/Stripe/GitHub/Slack/Google/AWS по их характерным префиксам, номера карт с проверкой по алгоритму Луна, email, телефоны, паспортные номера. Экспорт патча коллекции этот санитайзер не видел вообще ‑两 совершенно разных фичи выросли независимо, и никто не связал их между собой, пока не начали проектировать git‑friendly экспорт и не задали себе вопрос «а секреты‑то мы отсюда чистим?».

Решение — не переписывать логику дважды, а вынести существующий регэксп‑движок в отдельный модуль без AI‑специфичной обвязки (там была ещё и обрезка текста до 255 символов — она осталась только в AI‑варианте):

export const SECRET_PATTERNS = [
  { type: 'jwt', re: /beyJ[A-Za-z0-9_-]{8,}.[A-Za-z0-9_-]{8,}.[A-Za-z0-9_-]{8,}b/g },
  { type: 'bearer', re: /bBearers+[A-Za-z0-9._-+=/]{8,}/gi },
  { type: 'openai_key', re: /bsk-(?:proj-|live-|test-)?[A-Za-z0-9_-]{8,}b/g },
  { type: 'stripe_secret', re: /bsk_(?:live|test)_[A-Za-z0-9]{8,}b/g },
  { type: 'github_token', re: /bgh[pousr]_[A-Za-z0-9]{20,}b/g },
  { type: 'aws_access_key', re: /bAKIA[0-9A-Z]{16}b/g },
  // ...ключи по названию поля (password/token/secret/...), email, телефон, паспорт
];

Дальше — новая функция, которая рекурсивно проходит по операциям патча (upsertRequest.request.headers[].value, .body.raw, .url.query[].value, .auth.data, setCollectionMeta.fields.variables[]) и собирает список находок с типом и замаскированным превью — сам секрет в открытом виде в этот список не попадает даже для служебного использования в UI‑предупреждении.

Нюанс, который едва не сломал обычную работу: у клиента переменные вида {{token}} — это не секрет, это ссылка на переменную окружения, где реальное значение лежит отдельно и в патч не попадает вообще. Поле с именем password, но значением {{db_password}} — ложное срабатывание, если проверять только по названию ключа:

function isVarPlaceholder(value) {
  return /^{{[^}]+}}$/.test(String(value || '').trim());
}

Без этой проверки сканер маскировал бы каждый второй запрос в обычной коллекции, где авторизация настроена штатно через переменные окружения, а не вписана в запрос буквально — то есть шумел бы на пустом месте и приучал бы людей игнорировать предупреждение, которое как раз для реальных случаев и делалось.

Предупреждать или блокировать

Дальше — вопрос не технический, а продуктовый: что делать, когда находки есть? Жёстко запретить экспорт, пока не замаскируешь? Или предупредить и дать решить самому?

Аргумент за жёсткий блок — часть людей физически не читает предупреждения и продолжит нажимать «ОК» на всё, что ни попадя. Аргумент против — часть пользователей осознанно шарит тестовые/staging‑токены с командой, у которых нет ценности как у боевого секрета, и превращать это в обязательный шаг «сначала замаскируй» на каждый экспорт — трение там, где реального риска нет.

Оставили выбор за пользователем: диалог с тремя вариантами — «Экспортировать как есть» / «Замаскировать перед экспортом» / «Отмена» — но только когда находки реально есть. Если сканер ничего не нашёл — экспорт происходит как раньше, без единого лишнего клика. Не хотелось добавлять трение туда, где его не было и в нём не было нужды.

Как проверяли, что диффы действительно чистые

Приёмочный критерий был сформулирован буквально: экспортировать коллекцию из 10+ запросов в 3+ папках, поменять один запрос, экспортировать снова, и git diff должен показать изменения ровно в одном файле — не в метаданных, не в порядке файлов, не «непонятно почему поменялось ещё вот это». Это единственный тест, который на самом деле отвечает на исходный вопрос «можно ли этим пользоваться в PR» — статус‑код успешного экспорта тут ничего не доказывает, доказывает только чистый diff на реальном изменении.

Экспорт коллекции API‑запросов в файлы для git — и как мы чуть не отправили токены в открытом виде - 1

Экспорт коллекции папкой и маскирование секретов при экспорте — сейчас часть платного тарифа PolyApiIDE; сам факт, что фича платная или бесплатная, к этой истории отношения не имеет — интересен был не тариф, а то, что одна маленькая задача «сделать диффы читаемыми» по пути открыла дыру, которая без неё могла бы провисеть незамеченной ещё долго. Тем, кто пишет похожий экспорт для своих внутренних инструментов — пригодится хотя бы чек‑лист вопросов: стабильна ли сериализация между двумя запусками без изменений, что происходит при коллизии сгенерированных имён, и кто в вашем пайплайне вообще проверяет данные на секреты перед тем, как они покинут приложение.

Желающие можете заценить само расширение, если будут мысли как улучшить пишите здесь или заведите issue на гитхабе https://github.com/yuriEfin/poly‑api‑client/issues

Автор: Gambits

Источник

* - обязательные к заполнению поля


https://ajax.googleapis.com/ajax/libs/jquery/3.4.1/jquery.min.js