Как пароль FTP утёк в бесплатную модель ИИ: случай aredel

Кратко

22.10.2026, пока проверялись deny-правила репозитория aredel, поиск по локальной истории OpenCode показал, что пароль учётной записи FTP для деплоя дважды попадал в модель в сентябре: один раз его вставили в чат вручную, другой раз его напечатал агент, выполнивший cat файла с учётными данными. Обе сессии шли на бесплатном тарифе для участников Muse Spark 1.3, который может использовать запросы и ответы для обучения будущих моделей. Отозвать это невозможно, поэтому пароль сменили на хостинге в тот же день.

Эта страница — разбор: что случилось, почему, как это нашли, не печатая секрет снова, и что изменилось. Пароля, его длины и имени учётной записи здесь нет.

Хронология

Когда (UTC)Что случилось
2026-09-22 08:33Агент пишет скрипт деплоя и шаблон учётных данных. Шаблон говорит скопировать его в ~/.config/aredel/ftp-creds.env, вне репозитория, и заполнить.
2026-09-22 09:15Заполненный файл вместе с паролем вставляют в тот же чат сообщением пользователя.
2026-09-29 18:42В другой сессии агент выполняет cat ~/.config/aredel/ftp-creds.env. Вывод уходит в модель, и её ответ повторяет файл. Проектной конфигурации ещё не было, поэтому оболочка работала без запроса.
2026-09-30 05:13–05:40Агент загружает файл через . ~/.config/aredel/ftp-creds.env в четырёх тестовых FTP-командах. Значения используются, но не печатаются.
2026-10-14Появляется opencode.jsonc с запретом чтения *creds*. Он останавливает инструмент read, а не оболочку.
2026-10-22Поиск находит обе сессии. В оба инструмента добавляются запреты чтения и правки, а пароль меняется на хостинге.

Как это случилось

Как это нашли

Правило поиска: никогда не печатать секрет. Каждый шаг либо считает, либо перечисляет имена, либо маскирует значения.

Шаг 1 — печатает ли скрипт деплоя пароль?

grep -n "PASS" container/tools/deploy/deploy-ftp.sh | grep -i "echo\|print"

Три строки, все называют переменную в предупреждении или ошибке, ни одна не печатает её значение. Запуски скрипта людьми или агентами — не утечка.

Шаг 2 — с какого момента правило защищает файл?

git log --format="%h %ad %s" --date=short --reverse -S"*creds*" -- opencode.jsonc

Первый коммит с правилом — от 22.10.2026. Всё, что было раньше, шло без всяких правил.

Шаг 3 — какие истории вообще упоминают файл?

grep -rlE "ftp-cr.ds\.env" ~/.claude/projects

grep -ac "ftp-creds.env" ~/.local/share/opencode/opencode.db

Claude Code: только сессия, где шёл этот аудит, и только по имени; файл она никогда не читала. OpenCode: 428 строк его базы называют файл, поэтому следующие шаги смотрели туда.

Шаг 4 — где появляется настоящее значение? Каждое присваивание FTP_PASSWORD="…" в базе OpenCode выводили с замаскированным значением: буквы и цифры заменены на x и 9, рядом — строка перед ним. Заглушки, ссылки ${{FTP_PASSWORD}} и тестовые фиктивные значения с примерными хостами отпали; четыре совпадения шли сразу после строк с настоящим хостом и учётной записью.

Шаг 5 — какие сессии, какие инструменты? Запросы только на чтение к той же базе связали эти совпадения с сессиями, ролями сообщений и вызовами инструментов: две сессии из хронологии, одно сообщение пользователя и один cat через оболочку. Ни одна сессия никогда не была опубликована: таблица публикаций была пуста.

Проверьте свою историю

Если в проекте, с которым работал агент, когда-либо был секрет, поищите его в своей истории OpenCode. Скрипт ниже спрашивает старое значение, не показывая его, открывает базу только на чтение и печатает лишь когда, кто и в какой сессии значение встречается. Сохраните его как find_leaks.py:

vi find_leaks.py

import datetime import getpass import sqlite3 from pathlib import Path DB = Path.home() / ".local/share/opencode/opencode.db" def when(ms: int) -> str: moment = datetime.datetime.fromtimestamp(ms / 1000, datetime.UTC) return moment.strftime("%Y-%m-%d %H:%M UTC") def main() -> None: secret = getpass.getpass("Old password (not shown): ") con = sqlite3.connect(f"file:{DB}?mode=ro", uri=True) titles = dict(con.execute("SELECT id, title FROM session")) titles.update(con.execute("SELECT id, title FROM session_v2")) rows = con.execute("SELECT session_id, type, time_created, data FROM session_message") for session_id, kind, created, data in rows: if secret in (data or ""): print(when(created), kind, titles.get(session_id, session_id)) main()

python3 find_leaks.py

Каждая строка вывода — одно место, куда значение отправили в модель, например:

2026-09-22 09:15 UTC user Unable to update App Center

Запустите его до того, как удалять старые сессии, и сначала смените секрет: совпадение означает, что значение покинуло вашу машину. База хранит сообщения как JSON, поэтому значение с кавычками или обратными слешами хранится экранированным; ищите вместо него характерную часть. Для Claude Code тот же вопрос — обычный grep -rl по ~/.claude/projects.

Что утекло, а что нет

Что изменилось

Правила из этого случая

Автор статьи: Клавдия Кондакова