Документ описывает модель угроз реализованного кода рабочего 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 через сырые
указатели), но не заменяет проверки ниже.
Активы:
- локальная история агента (
Hot/Warm,pulse-store) — короткая, только в памяти; Snapshot/EntityGraph(pulse-core) — текущее состояние и временной граф сущностей;- OpenMetrics-эндпоинт
/metrics(pulse-export) — единственный сетевой выход агента; - конфигурация агента (TOML,
pulse-core/src/config.rs) — управляет поведением; - конфиденциальность наблюдаемой системы: имена процессов, cmdline, cgroup, unit, container/pod, устройства — данные, которые агент видит из-за прав запускающего процесса.
Границы доверия. Агент имеет две принципиально разные границы:
- Ядро → агент (недоверенные данные).
pulse-collectчитаетprocfs/sysfs/cgroupfs. Ядро — доверенный источник формата, но содержимое (имена процессов, cmdline, имена cgroup/dir, имена устройств, имена контейнеров) контролируется любым пользователем системы, который может создать процесс с произвольнымcomm/cmdlineили cgroup с произвольным именем. Всё, что приходит из этих файлов, рассматривается как недоверенная строка: проходит через лимиты размера, санитизацию и маскировку. - Сеть → агент (недоверенный клиент). HTTP-клиент
/metrics//healthz(pulse-export/src/server.rs) полностью недоверен: произвольные байты, заголовки, длина URI, частота запросов. Агент отвечает по HTTP/1.1Connection: 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) независимо от поведения клиента.
Все чтения проходят через единую абстракцию 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, число), а не имя, что убирает имя из метрики.
- id из ≥ 8 шестнадцатеричных символов, либо «голый» id из ≥ 32 hex
(
- Хост (
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 локальный процесс мог
уронить агент, подобрав длину аргумента.
Угроза: недоверенное имя процесса/cgroup/устройства может нести ANSI/OSC-escape-коды
(ESC[, ESC]…), управляющие символы C0/C1, нулевые ширину и bidi-символы (RTL, LTR,
RLE/AL) — всё это способно переформатировать вывод TUI или «перевернуть» строки в терминале.
Защита один раз на входе, а не в каждом месте вывода:
pulse-core/src/redact.rssanitize_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.rsEntityGraph::upsert: каждое имя сущности проходит черезsanitize_display(&spec.name)при создании/обновлении (документация модуляgraph.rs: «имя сущности всегда санитизировано»). Это та самая гарантия, из-за которой вывод TUI (pulse-tui/src/screens.rs,pulse-tui/src/format.rs) и OpenMetrics-рендер не нуждаются в отдельном слое экранирования: в доверенное состояние попадают уже «чистые» строки.- Экспорт дополнительно экранирует значения лейблов для синтаксиса OpenMetrics
(
pulse-export/src/render.rsescape_label_value): сначалаsanitize_display, затем экранирование\\,\",\n,\r,\t.
Ограничение: sanitize_display намеренно не является полным парсером терминала — он
убирает опасные управляющие/escape/bidi-символы и ограничивает длину. Этого достаточно,
чтобы имя не управляло выводом; полноценная валидация «безопасного терминального вывода»
здесь не требуется, потому что точка входа одна.
cmdline легитимно может содержать секреты (--password=…, --dsn=user:secret@host,
--token=…). Агент не должен их персонифицировать в метриках или TUI.
pulse-core/src/redact.rsredact_argv(режимRedactMode::Secretsпо умолчанию,config.rsSecurity.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 читается один раз за жизнь процесса, счётчик растёт при появлении
новых процессов, а не на каждом такте; процесс, переписавший свою командную строку
после старта, будет перечитан только после перезапуска.
Процесс с PID N может завершиться, а PID N — переиспользоваться новым процессом.
Наивный pid-ключ смешал бы два разных процесса.
- Ключ процесса —
(pid, start_ticks)(pulse-collect/src/process.rsProcKey):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.rssignal_process): сигнал адресуется дескриптором процесса, а не номером. Сначала открываетсяpidfd_open, затем проверяетсяpid + start_ticks, и только потом идётpidfd_send_signal. Дескриптор привязан к конкретному процессу: даже если номер сменит владельца сразу после проверки, сигнал уйдёт исходному процессу либо не уйдёт никому. Модуль выключен по умолчанию (см. § 9). Остаточный риск: на ядрах старше 5.3pidfd_openвозвращаетENOSYS, и код падает обратно наkillпо номеру. Там окно между проверкой и сигналом сужено, но не закрыто. - ABA в графе (
pulse-core/src/graph.rsEntityId):EntityId= индекс арены + generation-счётчик; освобождённый и повторно выданный слот имеет другое generation, поэтому старыеEntityIdне «нацеливаются» на новую сущность.
Сервер собран вручную на 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 адреса»). По умолчанию bind127.0.0.1(loopback) — см.README.mdиconfig.rsExportдефолты.- Токен обязан быть ≥
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.rsRateLimiter):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.rsBudget, дефолт 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.
Процессы — источник бесконечной кардинальности (каждый запуск — новая сущность). По умолчанию экспорт их не раскрывает полностью.
pulse-export/src/render.rsallowed(по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.rsLabels), поэтому кардинальность ограничена на входе, а не на экспортере.
По умолчанию экспорт процессов выключен (README.md, «безопасные значения по умолчанию»);
включение — явный process_export в конфигурации.
Пул умеет посылать сигналы процессам (kill и т.п.) — это потенциально деструктивно.
pulse-core/src/config.rsSecurity.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), поэтому «разблокированное» действие невозможно включить ошибкой в конфиге.
Каждая граница, куда входит внешний или «большой» объект, имеет явный потолок:
| Ресурс | Потолок | Где |
|---|---|---|
| Чтение одного файла ядра | 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.
История защищена RwLock и читается одновременно TUI-потоком, экспорт-потоком, CLI и
collect-потоком. Паника в одном читателе/писателе «отравляет» RwLock, и наивный код
превратил бы это в отказ всего агента.
pulse-cli/src/runtime.rswith_history_read/with_history_write: приErr(PoisonError)продолжают работу с сохранёнными данными (into_inner), а не роняют цикл сбора — «потеря TUI-потока не должна уничтожать локальную историю агента».pulse-cli/src/main.rsrun_diff: веткаErr(poisoned) => poisoned.into_inner()— diff работает с историей даже после отравления.pulse-core/src/fs.rsFixtureFsиспользуетMutexдля состояния фикстуры (тестовый путь; вRealFsблокировок нет).
Принцип: отравление одной блокировки не является отказом безопасности — состояние истории остаётся доступным, а коллектор продолжает публиковать снапшоты.
- Контейнеры/pod определяются эвристически по cgroup-пути
(
pulse-collect/src/cgroup.rsowner_from_segment,pod_uid_from_path) — без обращения к runtime API (CRI/Docker/Kubernetes API не реализовано). Это значит, что агент не «знает» контейнер извне: он угадывает его по форме cgroup-путя. Невалидный/чужой путь классифицируется как unit, а не как контейнер (§ 2). - Целые диски (
pulse-collect/src/disk.rsis_whole_disk): присутствие/sys/block/<name>— предпочтительный сигнал; если sysfs-записи нет, применяется эвристика по имени. - Права пользователя (
README.md, «Ограничения MVP»): наблюдаемость процессов ограничена правами запускающего пользователя и настройками/proc. Агент видит только то, что видит его пользователь (см. остаточный риск § 12.1).
Ниже — реальные, не смягчённые ограничения текущей реализации. Каждая позиция — то, что не покрывается защитами выше, и что нужно учитывать при эксплуатации.
cmdline/exeчужих процессов через права ОС. Если агент запущен отroot(или с правами, дающими доступ),/proc/<pid>/cmdlineиexeлюбого процесса читаемы (pulse-collect/src/process.rslabels_for).redact_argvскрывает секретоподобные аргументы (§ 4), но несекретные argv и путиexeпроизвольных процессов раскрываются как лейблы (Labels) и в TUI. На многопользовательской машине это может раскрывать наличие и аргументы чужих процессов.- Нет TLS. Экспорт — plaintext HTTP; bearer-токен защищает аутентификацию, но не
транспорт (
pulse-export/src/server.rs;README.md— «встроенного TLS нет»). Без TLS reverse proxy или защищённого туннеля трафик/metrics(включая токен в заголовкеAuthorization) виден на канале. TLS — не реализовано в агенте. - История только в памяти.
Hot+Warm(pulse-store) — только память, без персистентности на диск («не реализовано»). История теряется при перезагрузке/крушении. Рост ограничен (Hot.capacity,Hot.max_series), так что бесконечного разрастания нет, но данные не являются долговечными. - Остановка
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-цикл не дренирует запросы в полёте — соединение, начатое в момент остановки, обрывается. - Возможная утечка разрешённых значений лейблов. Реальные идентификаторы системы
(имена unit, id контейнеров, имена/неймспейсы pod, имена устройств, sanitized cmdline)
выдаются как значения лейблов
/metricsлюбому владельцу токена (pulse-export/src/render.rslabel_fragment). Строки санитизированы (§ 3), но в мульти-арендаторских контекстах семантически чувствительны: токен даёт доступ не только к числам, но и к именам сущностей системы. - Известное advisory в графе зависимостей.
Cargo.lockсодержитlru 0.12.5, приходящий транзитивно черезratatui 0.29(cargo tree -i lru). RustSec отмечает в этой ветке unsoundnesslru::IterMut. Pulse не вызываетlruнапрямую, и достижимость уязвимого API из кода агента не доказана, поэтому позиция описана как состояние графа зависимостей, а не как эксплуатируемый дефект Pulse. Подавление флагом--ignoreзапрещено: гейт.github/workflows/supply-chain.ymlобязан продолжать сообщать об этом, пока обновлениеratatuiне уберёт затронутую версию. Черезratatuiприходит такжеpaste, помеченный как unmaintained: это гигиена поставки, а не уязвимость.
По умолчанию агент слушает только 127.0.0.1:9099 (loopback) — внешняя сеть его не видит.
Для внешней публикации:
- Bind не на loopback +
export.token_fileс токеном ≥ 16 байт — иначе агент откажется стартовать (pulse-export/src/server.rsspawn;README.md). - TLS reverse proxy или защищённый туннель обязателен (агент не умеет TLS, § 12.2):
терминируйте TLS снаружи и проксируйте
Authorization: Bearer <token>внутрь. - Оставьте включённой маскировку (
security.redact = Secrets,pulse-core/src/redact.rs) и не включайте оптовый экспорт процессов без необходимости (§ 7). - Ограничьте
export.rate_limit_per_minuteпод реальных скрейперов (pulse-export/src/limits.rsRateLimiter) — по умолчанию есть и rate limit, и бюджеты ответа (§ 6). - Не запускайте от
root, если достаточно прав пользователя: это сокращает поверхность § 12.1 (видимыеcmdline/exeчужих процессов). - Проверьте конфигурацию перед запуском:
pulse config check(валидирует и выполняет реальный такт,pulse-cli/src/main.rscheck) иpulse config print(эффективный конфиг). Неизвестные поля / небезопасный bind без токена / сломанный hysteresis → отказ старта (config.rsvalidate()).
Гейт .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 или сменила версию — тогда обоснование выше перестаёт быть верным и
обязано быть пересмотрено.
Уязвимости следует сообщать через трекер issues репозитория
https://github.com/Stolyarovmn/Pulse/issues (это фактический origin и значение Cargo.toml).
Отдельного e-mail/PGP-канала для отчётов не предусмотрено. В issue укажите: версию (вывод
pulse --version), команду запуска, config print (без реальных секретов — токен маскируйте)
и минимальное воспроизведение.
Каждый пункт привязан к файлу/модулю; «не реализовано» = не покрывается текущим кодом.
- Loopback bind по умолчанию; при внешнем bind — токен ≥ 16 байт, иначе отказ.
(
server.rsspawn,auth.rsload_token.) - Токен ≥ 16 байт, сравнение в constant-time. (
auth.rsMIN_TOKEN_BYTES,constant_time_equal.) - Rate limit + бюджеты на запрос (заголовки/URI/тело) и на ответ (16 MiB / 100 k строк).
(
server.rs,limits.rsRateLimiter/Budget,render.rsMAX_RESPONSE_BYTES.) - Маскировка cmdline включена (
security.redact = Secrets);environне читается. (redact.rsredact_argv/is_secret_key;process.rs.) - Санитизация имён на входе (escape/C0/C1/zero-width/bidi), лимит длины.
(
redact.rssanitize_display;graph.rsEntityGraph::upsert.) - Экспорт процессов opt-in (по умолчанию
Never); приTop— только top-N, лейбл толькоpid. (render.rsallowed/select_top_processes/label_fragment.) - Действия выключены (
security.allow_actions = false); короткиеSignal+ повторная проверка идентичности. (config.rs,actions.rs.) - Потолки ресурсов на все входы (файлы ядра, cmdline, лейблы, процессы, cgroup, история, конфиг, CLI-аргументы). (§ 9, таблица.)
- Отказ старта на неизвестных полях / небезопасном bind / сломанном hysteresis.
(
config.rsvalidate();main.rsload_config,check.) - Устойчивость к lock poisoning: история сохраняется после отравления.
(
runtime.rswith_history_read/with_history_write;main.rsrun_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/кластера) этот раздел нужно обновить, сохратив правило «каждая защита → файл + символ» и честный раздел остаточных рисков.