Skip to content

Security: Stolyarovmn/Pulse

Security

docs/SECURITY.md

Модель угроз Pulse

Документ описывает модель угроз реализованного кода рабочего Linux MVP. Каждая защита связана с конкретным файлом и символом (крейт + функция/константа). То, что ещё не реализовано, явно помечено как «не реализовано» и в разделы «что не защищено» не засчитывается как имеющаяся защита.

Состав workspace (7 крейтов, см. Cargo.toml):

Крейт Назначение Доверенность
pulse-core модель, EntityGraph, Snapshot, redact, fs, конфиг ядро
pulse-collect коллекторы procfs/sysfs/cgroup v2 читает недоверенные данные ядра
pulse-store hot/warm история в памяти доверенное состояние
pulse-engine 8 семейств правил с гистерезисом, semantic A/B diff доверенная логика
pulse-export OpenMetrics HTTP, auth, rate limit, рендер обслуживает недоверенных HTTP-клиентов
pulse-tui диагностический TUI (ratatui/crossterm) выводит данные в терминал
pulse-cli run/serve/top/diff/scorecard/config print/check точка входа

В workspace включён lint unsafe_code = "deny" и запрещены todo/unimplemented (Cargo.toml, [workspace.lints]): в коде нет unsafe Rust. Это базовое свойство, а не отдельная «защита» — оно исключает целый класс ошибок (OOB, data race через сырые указатели), но не заменяет проверки ниже.


1. Активы и границы доверия

Активы:

  • локальная история агента (Hot/Warm, pulse-store) — короткая, только в памяти;
  • Snapshot/EntityGraph (pulse-core) — текущее состояние и временной граф сущностей;
  • OpenMetrics-эндпоинт /metrics (pulse-export) — единственный сетевой выход агента;
  • конфигурация агента (TOML, pulse-core/src/config.rs) — управляет поведением;
  • конфиденциальность наблюдаемой системы: имена процессов, cmdline, cgroup, unit, container/pod, устройства — данные, которые агент видит из-за прав запускающего процесса.

Границы доверия. Агент имеет две принципиально разные границы:

  1. Ядро → агент (недоверенные данные). pulse-collect читает procfs/sysfs/cgroupfs. Ядро — доверенный источник формата, но содержимое (имена процессов, cmdline, имена cgroup/dir, имена устройств, имена контейнеров) контролируется любым пользователем системы, который может создать процесс с произвольным comm/cmdline или cgroup с произвольным именем. Всё, что приходит из этих файлов, рассматривается как недоверенная строка: проходит через лимиты размера, санитизацию и маскировку.
  2. Сеть → агент (недоверенный клиент). HTTP-клиент /metrics//healthz (pulse-export/src/server.rs) полностью недоверен: произвольные байты, заголовки, длина URI, частота запросов. Агент отвечает по HTTP/1.1 Connection: close.

Внутри агента всё доверенное: EntityGraph, Snapshot, History, AgentStats. Гарантия направления: недоверенные данные из ядра впадают в доверенное состояние только через проверенные точки (pulse-core/src/graph.rs EntityGraph::upsert — sanitize_display(&spec.name); pulse-core/src/entity.rs Labels::set — ограничение размера/ключей; pulse-core/src/redact.rs sanitize_display). После этого TUI и экспорт читают уже «очищенные» строки.

Недоверенное не должно управлять логикой: имена/размеры из procfs не влияют на контроль потока (см. §§ 3, 10), а сетевые лимиты ограничивают расход ресурсов (§ 7) независимо от поведения клиента.


2. Недоверенные строки procfs / sysfs / cgroupfs

Все чтения проходят через единую абстракцию FsSource (pulse-core/src/fs.rs):

  • FsSource::read(path, cap) обязателен по cap: RealFs::read использует File::take(cap as u64).read_to_end(..). Это обязательный механизм, а не оптимизация: procfs сообщает размер 0, а /proc/kcore — бесконечный, без take чтение могло бы исчерпать память (pulse-core/src/fs.rs).
  • Дефолтная верхняя граница DEFAULT_CAP = 64 KiB (pulse-core/src/fs.rs).
  • FsSourceExt::read_string = String::from_utf8_lossy(read(..)): не-UTF-8 из ядра приводит к потерям (U+FFFD), а не к падению коллектора.

Точки входа по коллекторам:

  • Процессы (pulse-collect/src/process.rs): cmdline читается только при security.read_cmdline (вкл. по умолчанию), один раз на идентичность (pid, start_ticks) (кэш ProcPrev.static_labels) и через parse_cmdline — жёсткий MAX_CMDLINE_BYTES = 16 KiB и MAX_ARGS = 64 (pulse-core/src/redact.rs); exe — через read_link (символическая ссылка, ограниченная cap). /proc/<pid>/environ НЕ читается никогда — это источник реальных секретов; см. § 4.
  • journald по требованию (pulse-collect/src/details.rs): выключен по умолчанию (security.read_journal = false). При включении запускается только для раскрытой ветки/Inspector, фильтруется по trusted-полю _SYSTEMD_CGROUP, не раньше запуска текущей идентичности PID и активной проблемы, не более 12 строк и 16 КиБ. Вывод проходит sanitize_display, не сохраняется в историю и не экспортируется. Docker *-json.log напрямую не читается: Docker считает эти файлы внутренними для daemon.
  • cgroup v2 (pulse-collect/src/cgroup.rs): имя cgroup-каталога и путь — недоверенные строки; comm/cpu.stat — числовые. Идентификатор контейнера проходит валидацию по форме: префиксы runtime (docker-, cri-containerd-, containerd-, crio-, libpod-)
    • id из ≥ 8 шестнадцатеричных символов, либо «голый» id из ≥ 32 hex (owner_from_segment); невалидная строка классифицируется как unit, а не как контейнер. Подпись cgroup — это cgroup_id (inode, число), а не имя, что убирает имя из метрики.
  • Хост (pulse-collect/src/host.rs): только числовые поля (parse::field, parse_cpu_times, parse_pressure, parse_loadavg); сущность host не имеет строковых лейблов (boot_id вынесен в отдельную метрику), поэтому поверхность недоверенных строк минимальна.
  • Сетевые интерфейсы (pulse-collect/src/net.rs) и диски (pulse-collect/src/disk.rs): имена интерфейсов (/proc/net/dev) и устройств (/proc/diskstats) становятся именами сущностей и проходят через санитизацию Entity.name (§ 3).

Ключи лейблов статичны, значения ограничены и очищены (pulse-core/src/entity.rs Labels): ключи задаются кодом (&'static str), а не входными данными — кардинальность ограничена на входе, а не на экспортере. Labels::set ограничивает MAX = 16 лейблов на сущность, MAX_VALUE_LEN = 512 байт на значение, сначала снимает управляющие последовательности общей функцией санитизации, затем усекает по ближайшей границе UTF-8 не позже лимита. Это важно: String::truncate(512) внутри многобайтового символа паникует, то есть до PR-08 локальный процесс мог уронить агент, подобрав длину аргумента.


3. Терминальные escape-последовательности и bidi-символы

Угроза: недоверенное имя процесса/cgroup/устройства может нести ANSI/OSC-escape-коды (ESC[, ESC]…), управляющие символы C0/C1, нулевые ширину и bidi-символы (RTL, LTR, RLE/AL) — всё это способно переформатировать вывод TUI или «перевернуть» строки в терминале.

Защита один раз на входе, а не в каждом месте вывода:

  • pulse-core/src/redact.rs sanitize_display (лимит MAX_DISPLAY_LEN = 256):
    • ESC (0x1b) → проглатывается целиком через skip_escape_sequence (CSI с конечным байтом 0x40–0x7e, OSC до BEL или ESC \, двухбайтовые последовательности) и заменяется на U+FFFD (REPLACEMENT);
    • C0 (< 0x20) и DEL (0x7f) → U+FFFD;
    • C1 (0x80–0x9f) → U+FFFD;
    • нулевая ширина и bidi: 0x200b–0x200f, 0x202a–0x202e, 0x2060–0x2064, 0x2066–0x2069, U+FEFF → U+FFFD;
    • при превышении MAX_DISPLAY_LEN добавляется ….
  • pulse-core/src/graph.rs EntityGraph::upsert: каждое имя сущности проходит через sanitize_display(&spec.name) при создании/обновлении (документация модуля graph.rs: «имя сущности всегда санитизировано»). Это та самая гарантия, из-за которой вывод TUI (pulse-tui/src/screens.rs, pulse-tui/src/format.rs) и OpenMetrics-рендер не нуждаются в отдельном слое экранирования: в доверенное состояние попадают уже «чистые» строки.
  • Экспорт дополнительно экранирует значения лейблов для синтаксиса OpenMetrics (pulse-export/src/render.rs escape_label_value): сначала sanitize_display, затем экранирование \\, \", \n, \r, \t.

Ограничение: sanitize_display намеренно не является полным парсером терминала — он убирает опасные управляющие/escape/bidi-символы и ограничивает длину. Этого достаточно, чтобы имя не управляло выводом; полноценная валидация «безопасного терминального вывода» здесь не требуется, потому что точка входа одна.


4. Секреты в командной строке

cmdline легитимно может содержать секреты (--password=…, --dsn=user:secret@host, --token=…). Агент не должен их персонифицировать в метриках или TUI.

  • pulse-core/src/redact.rs redact_argv (режим RedactMode::Secrets по умолчанию, config.rs Security.redact):
    • --password=value / --password value: если ключ признан секретным (is_secret_key) и значение не тривиально, значение заменяется на <redacted> (REDACTED); флаг без значения скрывает следующий аргумент;
    • mask_url_userinfo маскирует user:secret@ в URL-аргументах.
  • is_secret_key (pulse-core/src/redact.rs) распознаёт секреты по таблице SECRET_KEYS с учётом точного совпадения и разделителей.
  • security.redact_high_entropy = true включает дополнительную opt-in эвристику для безымянных токенов длиной от 24 ASCII-символов: одновременно буквы и цифры, минимум 12 разных символов, энтропия Шеннона от 3.5 бит/символ. Абсолютные пути и однообразные строки исключены. Возможны ложные срабатывания на hash/UUID — поэтому настройка выключена по умолчанию. В RedactMode::Off она намеренно не действует: Off означает явный отказ от сокрытия, а не смешанную политику.
  • RedactMode::Aggressive → exe <N args hidden> (скрывает весь argv), RedactMode::Off → только санитизация (см. § 3). Выбор — в конфигурации.
  • /proc/<pid>/environ не читается вообще (pulse-collect/src/process.rs): переменные окружения — основной носитель секретов, и агент намеренно их не трогает.

Свободный текст журнала может содержать пароль, токен, тело запроса и другие данные, которые нельзя надёжно отличить от полезной диагностики. Поэтому security.read_journal — отдельный opt-in; redact_argv к журналу не применяется и включение настройки означает согласие оператора показать последние строки локально в TUI. Управляющие последовательности терминала всё равно удаляются, а объём и время ограничены.

Счётчик redactions (pulse-collect/src/process.rs) фиксирует число замаскированных аргументов и попадает в AgentStats/TUI — маскировка наблюдаема, а не «тихая». Поскольку cmdline читается один раз за жизнь процесса, счётчик растёт при появлении новых процессов, а не на каждом такте; процесс, переписавший свою командную строку после старта, будет перечитан только после перезапуска.


5. Переиспользование PID, TOCTOU, ABA

Процесс с PID N может завершиться, а PID N — переиспользоваться новым процессом. Наивный pid-ключ смешал бы два разных процесса.

  • Ключ процесса — (pid, start_ticks) (pulse-collect/src/process.rs ProcKey): start_ticks (время запуска из stat) отличает процесс-перезапуск от независимых сущностей; это же инвариант pulse-core/src/entity.rs (Process{pid, start_ticks}).
  • TOCTOU при чтении: collect (pulse-collect/src/process.rs) перечитывает stat после чтения всех полей и, если start_ticks изменился во время такта, бросает замер этого процесса и инкрементирует toctou_drops. Частично «перескочивший» процесс не попадает в метрики как чужой.
  • Забытые сущности: previous.retain(seen.contains) (тот же collect) убирает исчезнувшие процессы из состояния коллектора.
  • Сигналы (pulse-collect/src/actions.rs signal_process): сигнал адресуется дескриптором процесса, а не номером. Сначала открывается pidfd_open, затем проверяется pid + start_ticks, и только потом идёт pidfd_send_signal. Дескриптор привязан к конкретному процессу: даже если номер сменит владельца сразу после проверки, сигнал уйдёт исходному процессу либо не уйдёт никому. Модуль выключен по умолчанию (см. § 9). Остаточный риск: на ядрах старше 5.3 pidfd_open возвращает ENOSYS, и код падает обратно на kill по номеру. Там окно между проверкой и сигналом сужено, но не закрыто.
  • ABA в графе (pulse-core/src/graph.rs EntityId): EntityId = индекс арены + generation-счётчик; освобождённый и повторно выданный слот имеет другое generation, поэтому старые EntityId не «нацеливаются» на новую сущность.

6. HTTP-экспорт: bind, токен, лимиты, ошибки

Сервер собран вручную на std::net (pulse-export/src/server.rs: TcpListener/TcpStream; зависимость tiny_http в Cargo.toml присутствует, но экспорт-сервер её не использует).

Правило bind/токен (spawn, pulse-export/src/server.rs):

  • !enabled → ошибка; токен читается через load_token (pulse-export/src/auth.rs);
  • !bind.ip().is_loopback() && token.is_none() → отказ (PermissionDenied, «токен обязателен для не-loopback адреса»). По умолчанию bind 127.0.0.1 (loopback) — см. README.md и config.rs Export дефолты.
  • Токен обязан быть ≥ MIN_TOKEN_BYTES = 16 байт (auth.rs); короче → ошибка запуска.

Токен и сравнение (pulse-export/src/auth.rs):

  • parse_bearer — case-insensitive разбор Bearer; load_token обрезает ASCII-пробел;
  • constant_time_equal — XOR-аккумулятор без раннего выхода (+ XOR по разности длин, get(i).unwrap_or(&0)): не выдаёт тайминг на позицию первого несовпадения.

Проверка до рендера (handle_connection, server.rs): для /metrics токен проверяется до вызова source()/render_openmetrics; несовпадение → 401 + WWW-Authenticate: Bearer и соединение закрывается без формирования ответа. /healthz → 200 ok, / → индексс, прочее → 404. Ответ пишется write_response как HTTP/1.1 с Connection: close.

Лимиты запроса (server.rs + pulse-export/src/limits.rs):

  • Таймауты чтения/записи 5 с на соединение и, отдельно, общий бюджет чтения заголовка REQUEST_BUDGET = 10 с (read_headers), а на пути отказа 429 — DRAIN_BUDGET = 1 с (drain_request). Таймаут сокета ограничивает только паузу между чтениями: клиент, присылающий по байту чуть быстрее таймаута, без общего бюджета удерживал бы обработчик часами.
  • Лимит одновременных соединений MAX_INFLIGHT = 8 (serve): соединения обрабатываются в отдельных потоках, при переполнении — немедленный 503 без чтения тела. Это снимает head-of-line blocking: раньше одно медленное соединение задерживало scrape всем остальным до проверки токена.
  • Заголовки: MAX_HEADER_BYTES = 16 KiB (read_headers → RequestError::TooLarge), URI: MAX_URL_BYTES = 8 KiB (parse_request → TooLong → 414), заявленное тело: MAX_REQUEST_BYTES = 8 KiB (TooLarge → 413), синтаксис → 400, метод → 405.
  • Rate limit (limits.rs RateLimiter): MAX_BURST = 10, таблица MAX_TRACKED_ADDRESSES = 1024 с вытеснением самого старого (evict_oldest по last_refill), токен-бакет пополняется per_minute/60 с capacity = per_minute.min(MAX_BURST); превышение → 429 + drain_request (без RST) + закрытие.
  • Бюджет ответа (limits.rs Budget, дефолт 16 MiB / 100 000 строк, округление вниз до 4096) + жёсткий потолок рендера MAX_RESPONSE_BYTES = 16 MiB (pulse-export/src/render.rs) — анти-«снежный ком»: размер ответа ограничен независимо от того, сколько сущностей в системе.

Проверено поведенчески: server::tests::slow_client_cannot_block_the_server_indefinitely держит соединение, присылая по байту, и одновременно требует ответа 200 на /healthz.


7. Высокая кардинальность и opt-in экспорт процессов

Процессы — источник бесконечной кардинальности (каждый запуск — новая сущность). По умолчанию экспорт их не раскрывает полностью.

  • pulse-export/src/render.rs allowed (по ExportPolicy): Never → метрики процессов не выводятся; OptIn (на уровне процесса) → выводится только top-N (ProcessExportMode::Top) по process_cpu_seconds.
  • select_top_processes (тот же render.rs): top-N выбирается только при mode == Top && limit != 0 по process_cpu_seconds.
  • Лейблы процессов: label_fragment выводит только pid (число); имя процесса и cmdline никогда не попадают в лейблы /metrics (это снимает и кардинальность, и риск утечки cmdline в метриках).
  • max_series_per_metric (render.rs) ограничивает число серий на метрику; излишек учитывается в RenderStats.dropped (отчётность о «выброшенном», а не тихое отсечение).
  • Ключи лейблов статичны (entity.rs Labels), поэтому кардинальность ограничена на входе, а не на экспортере.

По умолчанию экспорт процессов выключен (README.md, «безопасные значения по умолчанию»); включение — явный process_export в конфигурации.


8. Действия по умолчанию выключены

Пул умеет посылать сигналы процессам (kill и т.п.) — это потенциально деструктивно.

  • pulse-core/src/config.rs Security.allow_actions — false по умолчанию; весь модуль действий за этим гейтом.
  • pulse-collect/src/actions.rs: Signal — намеренно короткий перечислительный набор сигналов; signal_process повторно проверяет идентичность (pid + start_ticks, см. § 5) перед отправкой.
  • Отказ старта при небезопасной конфигурации: Config::validate() (pulse-core/src/config.rs) бросает ConfigError, и AgentRuntime::start (pulse-cli/src/runtime.rs) преобразует его в отказ (io::Error), поэтому «разблокированное» действие невозможно включить ошибкой в конфиге.

9. Истощение памяти и ресурсов

Каждая граница, куда входит внешний или «большой» объект, имеет явный потолок:

Ресурс Потолок Где
Чтение одного файла ядра DEFAULT_CAP = 64 KiB pulse-collect/src/fs.rs
cmdline MAX_CMDLINE_BYTES = 16 KiB, MAX_ARGS = 64 pulse-core/src/redact.rs
Длина отображаемой строки MAX_DISPLAY_LEN = 256 pulse-core/src/redact.rs
Лейблы сущности 16 шт × 512 байт pulse-core/src/entity.rs Labels
Число процессов за такт max_processes (.max(1)) pulse-collect/src/process.rs
cgroup MAX_DEPTH = 8, max_cgroups (.max(1)); достижение потолка возвращает CollectError::Truncated, сохраняет непосещённые сущности как UNKNOWN и не сбрасывает baseline счётчиков pulse-collect/src/cgroup.rs, pulse-core/src/graph.rs
История store.max_bytes = 64 MiB по умолчанию; сначала сокращается Warm, затем Hot до двух тактов pulse-store::History::enforce_memory_ceiling
Rate-limit таблица MAX_TRACKED_ADDRESSES = 1024 pulse-export/src/limits.rs
Бюджет ответа 16 MiB / 100 000 строк pulse-export/src/limits.rs Budget
Ответ /metrics MAX_RESPONSE_BYTES = 16 MiB pulse-export/src/render.rs
Заголовки/URI/тело запроса 16 KiB / 8 KiB / 8 KiB pulse-export/src/server.rs
Конфигурация (файл) MAX_CONFIG_BYTES = 1 MiB pulse-cli/src/main.rs load_config
top --limit 1..=10 000 pulse-cli/src/main.rs top
scorecard --seconds 1..=300 pulse-cli/src/main.rs scorecard
Относительное окно diff ≤ 5 мин pulse-cli/src/main.rs run_diff

Структуры истории ограничены не только числом элементов, но и суммарной оценкой памяти. При превышении store.max_bytes сначала вытесняются старые бакеты warm, затем сокращается hot; журналы сущностей/событий и свежие значения сохраняются. Поэтому заведомо нереальный лимит может быть превышен минимальным живым состоянием, но накопительная история больше не растёт. Вытеснения наблюдаемы через History::evicted_buckets() / evicted_hot_ticks() и scorecard.


10. Отравление блокировок (lock poisoning)

История защищена RwLock и читается одновременно TUI-потоком, экспорт-потоком, CLI и collect-потоком. Паника в одном читателе/писателе «отравляет» RwLock, и наивный код превратил бы это в отказ всего агента.

  • pulse-cli/src/runtime.rs with_history_read / with_history_write: при Err(PoisonError) продолжают работу с сохранёнными данными (into_inner), а не роняют цикл сбора — «потеря TUI-потока не должна уничтожать локальную историю агента».
  • pulse-cli/src/main.rs run_diff: ветка Err(poisoned) => poisoned.into_inner() — diff работает с историей даже после отравления.
  • pulse-core/src/fs.rs FixtureFs использует Mutex для состояния фикстуры (тестовый путь; в RealFs блокировок нет).

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


11. Ограничения namespaces и контейнеров

  • Контейнеры/pod определяются эвристически по cgroup-пути (pulse-collect/src/cgroup.rs owner_from_segment, pod_uid_from_path) — без обращения к runtime API (CRI/Docker/Kubernetes API не реализовано). Это значит, что агент не «знает» контейнер извне: он угадывает его по форме cgroup-путя. Невалидный/чужой путь классифицируется как unit, а не как контейнер (§ 2).
  • Целые диски (pulse-collect/src/disk.rs is_whole_disk): присутствие /sys/block/<name> — предпочтительный сигнал; если sysfs-записи нет, применяется эвристика по имени.
  • Права пользователя (README.md, «Ограничения MVP»): наблюдаемость процессов ограничена правами запускающего пользователя и настройками /proc. Агент видит только то, что видит его пользователь (см. остаточный риск § 12.1).

12. Что НЕ защищено (остаточные риски)

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

  1. cmdline/exe чужих процессов через права ОС. Если агент запущен от root (или с правами, дающими доступ), /proc/<pid>/cmdline и exe любого процесса читаемы (pulse-collect/src/process.rs labels_for). redact_argv скрывает секретоподобные аргументы (§ 4), но несекретные argv и пути exe произвольных процессов раскрываются как лейблы (Labels) и в TUI. На многопользовательской машине это может раскрывать наличие и аргументы чужих процессов.
  2. Нет TLS. Экспорт — plaintext HTTP; bearer-токен защищает аутентификацию, но не транспорт (pulse-export/src/server.rs; README.md — «встроенного TLS нет»). Без TLS reverse proxy или защищённого туннеля трафик /metrics (включая токен в заголовке Authorization) виден на канале. TLS — не реализовано в агенте.
  3. История только в памяти. Hot + Warm (pulse-store) — только память, без персистентности на диск («не реализовано»). История теряется при перезагрузке/крушении. Рост ограничен (Hot.capacity, Hot.max_series), так что бесконечного разрастания нет, но данные не являются долговечными.
  4. Остановка serve без дрена HTTP. Сигналы SIGTERM/SIGINT/SIGHUP/SIGQUIT обрабатываются: install_signal_flag (pulse-cli/src/main.rs, pulse-tui/src/lib.rs) ставит атомарный флаг, после чего экспортёр останавливается, а collect-поток присоединяется через AgentRuntime::stop_and_join (pulse-cli/src/runtime.rs). Для TUI флаг регистрируется до включения raw-режима, поэтому внешний kill больше не оставляет терминал сломанным. Остаточный риск сохраняется: HTTP accept-цикл не дренирует запросы в полёте — соединение, начатое в момент остановки, обрывается.
  5. Возможная утечка разрешённых значений лейблов. Реальные идентификаторы системы (имена unit, id контейнеров, имена/неймспейсы pod, имена устройств, sanitized cmdline) выдаются как значения лейблов /metrics любому владельцу токена (pulse-export/src/render.rs label_fragment). Строки санитизированы (§ 3), но в мульти-арендаторских контекстах семантически чувствительны: токен даёт доступ не только к числам, но и к именам сущностей системы.
  6. Известное advisory в графе зависимостей. Cargo.lock содержит lru 0.12.5, приходящий транзитивно через ratatui 0.29 (cargo tree -i lru). RustSec отмечает в этой ветке unsoundness lru::IterMut. Pulse не вызывает lru напрямую, и достижимость уязвимого API из кода агента не доказана, поэтому позиция описана как состояние графа зависимостей, а не как эксплуатируемый дефект Pulse. Подавление флагом --ignore запрещено: гейт .github/workflows/supply-chain.yml обязан продолжать сообщать об этом, пока обновление ratatui не уберёт затронутую версию. Через ratatui приходит также paste, помеченный как unmaintained: это гигиена поставки, а не уязвимость.

13. Безопасная внешняя публикация

По умолчанию агент слушает только 127.0.0.1:9099 (loopback) — внешняя сеть его не видит. Для внешней публикации:

  1. Bind не на loopback + export.token_file с токеном ≥ 16 байт — иначе агент откажется стартовать (pulse-export/src/server.rs spawn; README.md).
  2. TLS reverse proxy или защищённый туннель обязателен (агент не умеет TLS, § 12.2): терминируйте TLS снаружи и проксируйте Authorization: Bearer <token> внутрь.
  3. Оставьте включённой маскировку (security.redact = Secrets, pulse-core/src/redact.rs) и не включайте оптовый экспорт процессов без необходимости (§ 7).
  4. Ограничьте export.rate_limit_per_minute под реальных скрейперов (pulse-export/src/limits.rs RateLimiter) — по умолчанию есть и rate limit, и бюджеты ответа (§ 6).
  5. Не запускайте от root, если достаточно прав пользователя: это сокращает поверхность § 12.1 (видимые cmdline/exe чужих процессов).
  6. Проверьте конфигурацию перед запуском: pulse config check (валидирует и выполняет реальный такт, pulse-cli/src/main.rs check) и pulse config print (эффективный конфиг). Неизвестные поля / небезопасный bind без токена / сломанный hysteresis → отказ старта (config.rs validate()).

13.1. Принятые advisory в графе зависимостей

Гейт .github/workflows/supply-chain.yml запускает cargo audit --deny warnings. Ниже — единственные advisory, по которым принято обоснованное исключение. Каждое исключение обязано называть путь эксплуатации и условие снятия; «обновимся когда-нибудь» исключением не является.

Advisory Пакет Путь в графе Почему не достижимо Когда снять
RUSTSEC-2026-0002 (unsound, IterMut нарушает Stacked Borrows) lru 0.12.5 pulse-tui → ratatui 0.29 → lru ratatui 0.29 использует LruCache ровно в одном месте — кэше раскладки layout/layout.rs:31 — и вызывает только get/put/resize/clear. IterMut не вызывается ни в ratatui, ни в Pulse: собственного кода с lru у нас нет. Переход на ratatui 0.30, где lru исключена из графа. Требует MSRV ≥ 1.86 (у ratatui 0.30.0) и разбора breaking-изменений API.
RUSTSEC-2026-0253 (unsound, LruCache::pop() не panic-safe) lru 0.12.5 тот же Эксплуатация требует ключа, чей Drop паникует, и catch_unwind вокруг операции кэша. Ключ кэша раскладки — (Rect, Layout) (layout/layout.rs:31): обычные структуры без пользовательского Drop, паниковать при уничтожении им нечем. То же.

Обновление самого lru ситуацию не решает: ratatui 0.29 требует ^0.12.0, а исправления вышли в 0.16.3 и 0.18.2 — пересечение диапазонов пусто. Последний релиз ветки 0.12.x (0.12.5) исправлений не содержит.

Гейт продолжает падать на любом другом advisory: исключения перечислены поимённо, а шаг Exception scope в том же workflow падает, если lru пришла в граф не из ratatui или сменила версию — тогда обоснование выше перестаёт быть верным и обязано быть пересмотрено.


14. Сообщение об уязвимостях

Уязвимости следует сообщать через трекер issues репозитория https://github.com/Stolyarovmn/Pulse/issues (это фактический origin и значение Cargo.toml). Отдельного e-mail/PGP-канала для отчётов не предусмотрено. В issue укажите: версию (вывод pulse --version), команду запуска, config print (без реальных секретов — токен маскируйте) и минимальное воспроизведение.


15. Чек-лист hardening

Каждый пункт привязан к файлу/модулю; «не реализовано» = не покрывается текущим кодом.

  • Loopback bind по умолчанию; при внешнем bind — токен ≥ 16 байт, иначе отказ. (server.rs spawn, auth.rs load_token.)
  • Токен ≥ 16 байт, сравнение в constant-time. (auth.rs MIN_TOKEN_BYTES, constant_time_equal.)
  • Rate limit + бюджеты на запрос (заголовки/URI/тело) и на ответ (16 MiB / 100 k строк). (server.rs, limits.rs RateLimiter/Budget, render.rs MAX_RESPONSE_BYTES.)
  • Маскировка cmdline включена (security.redact = Secrets); environ не читается. (redact.rs redact_argv/is_secret_key; process.rs.)
  • Санитизация имён на входе (escape/C0/C1/zero-width/bidi), лимит длины. (redact.rs sanitize_display; graph.rs EntityGraph::upsert.)
  • Экспорт процессов opt-in (по умолчанию Never); при Top — только top-N, лейбл только pid. (render.rs allowed/select_top_processes/label_fragment.)
  • Действия выключены (security.allow_actions = false); короткие Signal + повторная проверка идентичности. (config.rs, actions.rs.)
  • Потолки ресурсов на все входы (файлы ядра, cmdline, лейблы, процессы, cgroup, история, конфиг, CLI-аргументы). (§ 9, таблица.)
  • Отказ старта на неизвестных полях / небезопасном bind / сломанном hysteresis. (config.rs validate(); main.rs load_config, check.)
  • Устойчивость к lock poisoning: история сохраняется после отравления. (runtime.rs with_history_read/with_history_write; main.rs run_diff.)
  • TLS перед внешней публикацией — вне агента (reverse proxy/туннель). (не реализовано в агенте, § 12.2.)
  • Запуск без root, если хватает прав пользователя (сужает § 12.1).
  • Graceful drain HTTP при сигнале — не реализовано (§ 12.4).
  • Персистентность истории на диск — не реализовано (§ 12.3).
  • CRI/Docker/Kubernetes API для идентификации контейнеров — не реализовано (эвристика по cgroup-пути, § 11).
  • OTLP / Prometheus Remote Read/Write — не реализовано (единственный выход — OpenMetrics /metrics).
  • Кластер/центральный backend — не реализовано (агент локальный, local-first).

Документ описывает только реализованное состояние кода; при изменении защит (новые лимиты, новые недоверенные источники, включение TLS/eBPF/OTLP/кластера) этот раздел нужно обновить, сохратив правило «каждая защита → файл + символ» и честный раздел остаточных рисков.

There aren't any published security advisories