# Может ли передача окружения SSH раскрыть локальный контекст?

Передача окружения SSH может раскрыть гораздо больше локального контекста, чем нужно удалённой задаче. Канал зашифрован, но шифрование защищает только путь. Оно не делает `AWS_PROFILE`, `GIT_AUTHOR_EMAIL`, имя клиента или внутренний флаг подходящими входными данными для процесса на другой машине.

Я отношусь к каждой переданной переменной как к аргументу удалённой команды. У неё должны быть имя, причина, ответственный и тест. Шаблон из удобной конфигурации ноутбука этому требованию не отвечает: его смысл меняется, как только кто-то добавляет совпадающую локальную переменную.

Безопасная цель не сводится к пустому удалённому окружению. `sshd`, учётная запись, оболочка и обёртки создают свой базовый набор. Нужно добиться более точного результата: клиент добавляет ровно те переменные процесса, которые записаны в контракте, сервер не принимает более широкий набор, а тест подтверждает и нужные значения, и отказ для приманок.

## Передача происходит только с согласия обеих сторон SSH

В OpenSSH есть два рубежа. На клиенте `SendEnv` выбирает имена из окружения локального процесса, а клиентский `SetEnv` задаёт буквальные пары `ИМЯ=ЗНАЧЕНИЕ`. На сервере `AcceptEnv` решает, какие присланные имена попадут в окружение сеанса. Обычно имя проходит, только если клиент его предлагает, а сервер принимает.

RFC 4254 описывает нижний уровень этого механизма. До запуска оболочки или команды клиент может отправить запрос канала SSH типа `env` с одним именем и одним значением. RFC предупреждает, что бесконтрольное окружение привилегированного процесса опасно, и советует держать список разрешённых имён либо задавать переменные после снижения привилегий. Это предупреждение находится в самой спецификации протокола.

В руководствах OpenSSH указано исключение, которое часто сбивает аудиторов: при запросе псевдотерминала `TERM` всегда отправляется и принимается, потому что она нужна протоколу. Для автоматизации без терминала запускайте `ssh -T`. Так из этого пути исчезнут `TERM`, интерактивное поведение и сюрпризы стартовых файлов.

Из этого согласования следуют четыре вывода.

- Широкий `SendEnv` бездействует, пока сервер ничего не принимает, но оживает сразу после расширения `AcceptEnv`.
- Широкий `AcceptEnv` бездействует с аккуратным клиентом, но пользователь может подключиться с другой конфигурацией и прислать совпадающее имя.
- Ни одна сторона не обеспечивает весь контракт в одиночку, если конфигурациями управляют разные люди.
- Успешная команда ничего не говорит о запросах окружения, которые сервер отклонил.

OpenSSH может проигнорировать переменную и продолжить сеанс. Если удалённая оболочка уже задаёт такое же имя, проверка только конечного значения способна приписать передаче значение, которое клиент вообще не доставлял.

## Шаблоны превращают будущий локальный контекст в удалённый ввод

`SendEnv WORKFLOW_*` означает не «переменные, проверенные сегодня», а все совпадающие имена в окружении каждого будущего процесса SSH. Через несколько недель разработчик может добавить в профиль `WORKFLOW_DEBUG_DUMP`, `WORKFLOW_CUSTOMER` или `WORKFLOW_TOKEN_FILE`, и старое правило молча получит новое поведение.

Передача локали хорошо показывает рост области действия. Многие рабочие станции отправляют `LANG` и `LC_*`, чтобы интерактивная оболочка правильно выводила текст. Для человека это бывает удобно, но сборочному или развёртывающему процессу такие значения автоматически не нужны. Локаль меняет сортировку, классы символов, даты и диагностику. Задача, разбирающая вывод, может сломаться без утечки секрета.

Особенно опасны шаблоны, похожие на удобные пространства имён:

- `AWS_*` может включать профили, регионы, связанные с учётными данными значения и пути.
- `GIT_*` переносит идентичность, трассировку, альтернативные каталоги объектов или поведение askpass.
- `CI_*` часто смешивает безобидные метки сборки, контекст провайдера и временные пути.
- `LC_*` кажется косметикой, пока скрипт не начинает зависеть от стабильной сортировки.
- `APP_*` растёт вместе с приложением и редко имеет единый смысл с точки зрения безопасности.

Оценивайте отдельные имена, а не всё пространство. Рабочему процессу может быть нужен `DEPLOY_REGION`, тогда как `AWS_PROFILE` описывает рабочую станцию. Может быть нужен `BUILD_REF`, а `GIT_CONFIG_COUNT` меняет работу Git. Общий префикс помогает упорядочить имена, но не создаёт границу доверия.

Даже не являясь учётными данными, значения раскрывают контекст. Путь показывает имя пользователя и структуру репозитория, профиль указывает на аккаунт или среду, флаг трассировки отправляет данные команды в общий журнал. Агент может использовать этот контекст в следующих действиях. Утечка включает нежелательное влияние и раскрытие, а не только секретные строки.

## Безобидное исключение может расшириться через несколько месяцев

Обычно утечку создаёт дрейф конфигурации, а не одно явно опасное изменение. Сначала разработчик добавляет `SendEnv APP_*`, потому что тесту нужен `APP_COLOR=0`. Сервер запрос отвергает. Позже администратор добавляет `AcceptEnv APP_*` для другой команды на том же общем узле. Каждая проверка видит лишь половину согласования.

Следующее подключение соединяет спящие правила. В оболочке уже есть `APP_CUSTOMER=acme-lab`, `APP_TRACE=1` и `APP_CONFIG=/Users/lee/work/private/config`. Клиент предлагает всё, демон принимает всё, а диагностическая обёртка после сбоя запускает `env` и пишет вывод в доступный группе журнал. Токен никто не копировал, но локальная идентичность, клиентский контекст, путь и флаг трассировки пересекли границу без согласования.

Удаление шаблона исправляет будущие сеансы, но не старый журнал. Рассматривайте находку как небольшой инцидент:

1. Прекратите принимать широкий шаблон и проверьте конфигурацию демона.
2. Определите аккаунты, клиенты и период одновременного действия обоих правил.
3. Ищите в разрешённых журналах раскрытые имена, а не чувствительные значения.
4. Выясните, меняло ли каждое значение поведение команды или раскрывало контекст.
5. Замените шаблон точными именами и добавьте приманки для запрещённых классов.

Фраза «сервер сейчас это отвергает» не оправдывает широкое правило клиента. У спящей конфигурации нет владельца в момент пробуждения. Удаляйте лишние предложения на клиенте даже при строгом сервере и лишние разрешения на сервере даже при аккуратных клиентах.

Бывает и обратный дрейф. Сервер давно принимает `LC_*` для интерактивных пользователей, а обновлённый образ автоматизации начинает её отправлять. Развёртывание наследует выбранный разработчиком `LC_COLLATE` и меняет порядок файлов. Раскрытия может не быть, но то же согласование вызвало нарушение целостности. Проверяйте конфиденциальность и поведение вместе.

## Проверяйте конфигурацию, которую SSH действительно применит

До редактирования файла прочитайте вычисленную конфигурацию клиента. `ssh -G` применяет блоки `Host` и `Match`, включения, пользовательские и системные файлы, а затем печатает результат для указанной цели. Файл, который кажется главным, может уступить более раннему значению или получить правила из включения.

Запустите команду из той же учётной записи и того же контекста:

```sh
ssh -G deploy-prod |
  awk '$1 == "sendenv" || $1 == "setenv" { print }'
```

Типичный небезопасный результат выглядит так:

```text
sendenv LANG
sendenv LC_*
sendenv AWS_*
setenv WORKFLOW_KIND=deploy
```

`ssh -G` показывает действующие правила, а не текущие значения, поэтому проверка не вываливает учётные данные. Но затем имена и шаблоны нужно сопоставить с окружением процесса, который действительно запускает SSH.

Соберите только имена:

```sh
env | sed 's/=.*//' | LC_ALL=C sort > local-env.names
grep -E '^(LANG|LC_|AWS_|WORKFLOW_)' local-env.names
```

Регулярное выражение здесь служит средством осмотра, а не политикой. Настройте его под все шаблоны. Если SSH запускает супервизор, IDE, агент или планировщик, проверяйте его окружение, а не интерактивную оболочку. Наследование идёт по дереву процессов.

Сервер проверяйте отдельно. `sshd -T` разбирает и печатает эффективные настройки; если действуют блоки `Match`, укажите параметры подключения:

```sh
sudo sshd -T \
  -C user=deploybot,host=deploy.example,addr=192.0.2.44 |
  grep '^acceptenv'
```

Подставьте настоящие имя, узел и адрес. Документационный адрес оставляйте только в примерах. Перед перезагрузкой выполните `sudo sshd -t`: ужесточение окружения не должно ломать удалённый доступ.

## Отдельный файл клиента делает список понятным

Для автоматизации надёжнее всего использовать небольшой файл через `-F`. Руководство `ssh` говорит, что он заменяет обычный пользовательский файл и отключает системный клиентский файл. Обновление рабочей станции тогда не добавит в процесс шаблоны локали.

```sshconfig
Host deploy-prod
    HostName deploy.example
    User deploybot
    IdentityFile ~/.ssh/deploy_ed25519
    IdentitiesOnly yes
    RequestTTY no
    SendEnv DEPLOY_REGION
    SendEnv BUILD_REF
    SetEnv WORKFLOW_KIND=deploy
```

Указывайте файл явно:

```sh
ssh -F ./deploy-ssh.conf deploy-prod /usr/local/bin/release
```

`SendEnv` читает значение из окружения локального процесса `ssh` и подходит для меняющихся входов. Клиентский `SetEnv` записывает буквальное значение в конфигурации. Используйте его для постоянной несекретной метки, а не для учётных данных или часто меняющегося параметра.

Шаблон `SendEnv` с префиксом `-` удаляет ранее выбранные имена:

```sshconfig
Host deploy-prod
    SendEnv -*
    SendEnv DEPLOY_REGION BUILD_REF
```

Очистка действует только на уже накопленные имена. Более позднее включение может добавить их снова. Поэтому отдельный файл с `-F` лучше вычитания: в нём меньше движущихся частей.

Не включайте в контракт учётные данные. Переданный токен становится обычным состоянием удалённого процесса и может попасть к дочерним процессам, в трассировку, отчёт о сбое, `/proc` или случайный вывод `env`. SSH защищает транспорт, но не дальнейшую жизнь значения.

## Сервер должен принимать точные имена для ограниченного аккаунта

Сервер остаётся последним местом отказа до запуска сеанса. По умолчанию OpenSSH не принимает переменные, кроме особого случая `TERM` с псевдотерминалом. Сохраните этот глобальный режим и добавьте точные имена только для аккаунта процесса.

```sshdconfig
Match User deploybot
    AcceptEnv DEPLOY_REGION
    AcceptEnv BUILD_REF
    AcceptEnv WORKFLOW_KIND
```

Не используйте `AcceptEnv APP_*`, `AcceptEnv AWS_*` или голый шаблон. Руководство `sshd_config` прямо предупреждает, что некоторые переменные помогают обходить ограничения. Кроме того, шаблон разрешает будущие имена без нового изменения сервера.

Широкий глобальный `AcceptEnv` ослабляет все аккаунты. Узкий `Match User` не удаляет уже накопленные глобальные имена. Если интерактивным пользователям нужна локаль, ограничьте разрешение их группой и проверяйте каждый вариант через `sshd -T -C`.

Серверный `SetEnv` задаёт константы в дочерних сеансах и перекрывает значения по умолчанию, `AcceptEnv` и `PermitUserEnvironment`. Когда значение принадлежит серверу, задавайте его там, а не принимайте от клиента.

`PermitUserEnvironment` управляет `~/.ssh/environment` и опциями `environment=` в authorized_keys. OpenSSH по умолчанию его выключает. Не включайте без конкретной причины, а при включении добавьте его шаблоны в тот же аудит.

Точный список имён не проверяет значения. Разрешённый `DEPLOY_REGION` может содержать пробелы, метасимволы оболочки, перевод строки или запрещённый регион. Протокол передаёт данные, но `eval`, раскрытие без кавычек или сгенерированная конфигурация могут превратить их в синтаксис.

Проверяйте каждое значение на удалённой точке входа:

```sh
case ${DEPLOY_REGION-} in
    us-east-1|us-west-2) ;;
    *)
        printf 'invalid DEPLOY_REGION\n' >&2
        exit 64
        ;;
esac

case ${BUILD_REF-} in
    [0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f]*)
        ;;
    *)
        printf 'invalid BUILD_REF\n' >&2
        exit 64
        ;;
esac
```

Проверка должна соответствовать настоящему контракту. Для полного идентификатора задайте точную длину и убедитесь, что объект есть в репозитории. Проверка, отсекающая лишь пустую строку, слишком слаба.

После изменения смотрите эффективный результат:

```sh
sudo sshd -t
sudo sshd -T \
  -C user=deploybot,host=deploy.example,addr=192.0.2.44 |
  grep -E '^(acceptenv|permituserenvironment|setenv)'
```

Сохраните вывод вместе с ревью. Он показывает, что демон разобрал для конкретного подключения, и полезнее снимка одного фрагмента среди множества включений.

## Докажите доставку нужных значений и отказ для приманок

Хороший тест проверяет положительные и отрицательные случаи внутри удалённого процесса. Задайте уникальные маркеры для обязательных значений, приманки для старых шаблонов, запустите неинтерактивный сеанс с отдельным файлом и завершите его ошибкой при нарушении контракта.

Ниже от клиента ожидаются только `DEPLOY_REGION`, `BUILD_REF` и постоянный `WORKFLOW_KIND`:

```sh
export DEPLOY_REGION='env-audit-region'
export BUILD_REF='env-audit-ref'
export AWS_PROFILE='must-not-cross'
export LC_AUDIT_CANARY='must-not-cross'
export APP_PRIVATE_PATH='must-not-cross'

ssh -F ./deploy-ssh.conf -T deploy-prod 'sh -s' <<'REMOTE'
set -eu

test "$(printenv DEPLOY_REGION)" = 'env-audit-region'
test "$(printenv BUILD_REF)" = 'env-audit-ref'
test "$(printenv WORKFLOW_KIND)" = 'deploy'

for name in AWS_PROFILE LC_AUDIT_CANARY APP_PRIVATE_PATH; do
    if printenv "$name" >/dev/null 2>&1; then
        printf 'unexpected forwarded variable: %s\n' "$name" >&2
        exit 1
    fi
done

printf '%s\n' 'SSH environment contract passed'
REMOTE
```

Ожидаемый вывод состоит из одной строки:

```text
SSH environment contract passed
```

Затем удалите обязательное локальное значение. `SendEnv` не может отправить отсутствующее имя, поэтому удалённая проверка должна завершиться ошибкой:

```sh
unset BUILD_REF
if ssh -F ./deploy-ssh.conf -T deploy-prod 'test -n "$BUILD_REF"'; then
    printf '%s\n' 'test failed: BUILD_REF appeared unexpectedly' >&2
    exit 1
fi
```

Если без `BUILD_REF` процесс не должен идти дальше, остановите и локальный запуск через `${BUILD_REF:?BUILD_REF is required}`. Удалённая проверка всё равно нужна, поскольку она обнаруживает отказ сервера.

Приманки должны покрывать каждый найденный шаблон и быть заведомо вымышленными. Не используйте настоящие секреты: сбой может записать их в журнал.

Один успех не доказывает невозможность передачи любой переменной. Сочетайте динамический тест с двумя статическими: в `ssh -G` должны быть только точные `SendEnv`, а в `sshd -T -C` только ожидаемые `AcceptEnv`. При наличии `*` или `?` будущие имена перечислить невозможно.

Запускайте тест из каждого вида запускающего процесса. Оболочка разработчика, CI, задача редактора и автономный агент могут использовать один бинарный файл с разными окружениями и путями. Запишите путь `-F` в самом запуске.

Конечное значение не доказывает вклад SSH. Если `/etc/profile` уже задаёт `DEPLOY_REGION=us-east-1`, положительная проверка пройдёт даже после отказа сервера. Уникальный маркер и тест отсутствующего входа отличают передачу от удалённого значения по умолчанию.

Тест не утверждает, что во всём окружении только три переменные. `sshd` создаёт `HOME`, `USER`, `SHELL`, `PATH` и метаданные подключения, а другие слои добавляют свои значения. Тест подтверждает только вклад клиента. Базовое окружение сервера проверяйте отдельно.

## Формирование команды может полностью обойти SendEnv

Пустой `SendEnv` не мешает локальной оболочке раскрыть переменные прямо в строку удалённой команды:

```sh
ssh deploy-prod "release '$TENANT' '$TOKEN'"
```

Оболочка подставляет оба значения до запуска `ssh`. Они идут в зашифрованном запросе команды, а не в `env`, поэтому `AcceptEnv` их не отклонит. Они также могут появиться в списке процессов, истории, журналах CI или ошибках.

Этот префикс меняет окружение локального SSH, но значение пересечёт границу только при выборе имени:

```sh
DEPLOY_REGION=west ssh -F ./deploy-ssh.conf deploy-prod /usr/local/bin/release
```

Эти пути часто путают. Наличие значения в окружении при запуске не доказывает передачу, а отключение `SendEnv` не доказывает отсутствие подстановки в аргументы или стандартный ввод. Отслеживайте настоящий канал.

Удалённая оболочка, `/etc/environment`, PAM, принудительная команда, `sudo`, менеджер служб или контейнер могут удалить, заменить или добавить значения после SSH. Если маркер пропал, смотрите решения клиента через `ssh -vvv`, разрешённые журналы сервера и минимальную команду до обёртки. Не расширяйте `AcceptEnv` вслепую.

`sudo` не заменяет контроль SSH. sudoers может очистить или сохранить окружение привилегированного потомка, но значение уже попало в непривилегированный сеанс, где его видят скрипты и обёртки. Настраивайте оба слоя отдельно.

Удалённый узел не является пассивной трубой. Владелец аккаунта обычно может вывести окружение, а администратор контролирует машину. Если удалённый узел никогда не должен узнать значение, не отправляйте его через SSH ни в каком виде.

Не записывайте полный `env` в обычные журналы. Во время разрешённого аудита собирайте имена и раскрывайте значения только у вымышленных маркеров.

## Для SSH от агента нужна меньшая граница процесса

Автономный агент часто наследует окружение терминала, редактора или оркестратора. Оно собрано для человека, работающего с разными репозиториями и аккаунтами, а не для одной удалённой операции. Передача его пространств имён даёт агенту лишний контекст, а удалённым инструментам непроверенные входы.

Запускайте процесс с явным локальным окружением и явным файлом SSH. Минимальная обёртка требует два меняющихся значения, удаляет остальное и возвращает лишь то, что нужно клиенту:

```sh
#!/bin/sh
set -eu
: "${DEPLOY_REGION:?DEPLOY_REGION is required}"
: "${BUILD_REF:?BUILD_REF is required}"

exec env -i \
  HOME="$HOME" \
  PATH='/usr/bin:/bin:/usr/sbin:/sbin' \
  DEPLOY_REGION="$DEPLOY_REGION" \
  BUILD_REF="$BUILD_REF" \
  ssh -F ./deploy-ssh.conf deploy-prod /usr/local/bin/release
```

Настройте `PATH` под систему и оставьте список явным. `HOME` нужен в примере для known_hosts и файла идентификации. Если управляемый исполнитель передаёт пути иначе, удалите и `HOME`.

Для агентского SSH на Mac Sallyport может хранить SSH-ключ в зашифрованном хранилище и выполнять действие через `sp-ssh`, не выдавая ключ агенту. Такая модель меняет хранение учётных данных, но не отменяет точный контракт окружения вокруг разрешённой команды.

Не передавайте локальные облачные учётные данные ради удобства. Выдайте удалённой нагрузке собственную ограниченную идентичность или используйте шлюз, который сам держит секрет. Копирование окружающего секрета связывает две границы доверия и усложняет отзыв.

## Сохраняйте контракт исполняемым после изменений

Пакеты добавляют значения, администраторы объединяют фрагменты, а процессы получают новые входы. Текстовое правило не ловит такой дрейф. Проверяйте вместе отдельный клиентский файл, настройки серверного аккаунта и тест с приманками.

Запускайте проверку после изменений в следующих местах:

- пакеты SSH-клиента или операционной системы;
- пользовательские и системные включения конфигурации;
- `sshd_config`, PAM, стартовые файлы и принудительные команды;
- запуск агента, CI, менеджер служб или образ контейнера;
- список обязательных входов процесса.

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

Ошибка должна быть явной. Развёртывание, которое после сбоя передачи молча берёт удалённое значение, труднее диагностировать и проще использовать неверно. Обязательный вход должен проверяться и локальным запуском, и удалённой точкой.

Наконец, проверьте сам удалённый аккаунт. Идеальный `SendEnv` не защищает аккаунт, способный запускать любую оболочку, читать окружение соседей или менять стартовые файлы. Сделайте этот канал точным, докажите его поведение и ограничьте полномочия команды.
