Self-hosted Kubernetes enforcement plane written in Rust.
Ferrum connects admission-time policy with runtime eBPF enforcement. Signed policy bundles are compiled outside the admission hot path, rolled out through Kubernetes, kept as last-known-good state on the node, and evaluated against runtime events before enforcement actions are taken.
Kubernetes workload
│
▼
Admission webhook ──► signed policy bundle
│ │
▼ ▼
workload runs node LKG state
│ │
▼ │
eBPF sys_enter ────────────────┘
│
▼
runtime policy decision
│
└──► enforcement / event
Validate the repository and example policy:
cargo test --workspace
cargo run -p ferrum-cli -- validate policies/examples/prod-restricted.yaml
cargo run -p ferrum-cli -- lint-deploy deployInstall the Kubernetes manifests with Kustomize:
kubectl apply -k deployThe default build intentionally has no live datapath. Real kernel attachment uses the attach feature and Kubernetes workload metadata uses apiserver; the product combination is attach,apiserver and is Linux-only because it depends on the kernel/eBPF stack. Read MVP-1 Boundary before treating a capability as proven.
Что именно MVP-1 делает, чего он не делает и что про него верят без
доказательства — docs/MVP-1-BOUNDARY.md. Три колонки,
четвёртой нет: в «Делает» не попадает ничего, что не было исполнено названным
#[test] или названной стадией Jenkins. Гейт
crates/ferrum-testkit/tests/boundary_gate.rs роняет сборку, когда строка
переживает свою ссылку, и когда тест в ferrum-testkit/tests,
ferrum-agent/tests, ferrum-admission/tests или ferrum-ebpf/tests не
процитирован ни одной строкой.
Читайте его до статус-встречи, а не после. Запись из ядра доходит до правила
подписанного bundle и до настоящего SIGKILL — это исполнено на настоящем
ядре, и с билда #52 обеими датапейсными стадиями в CI, на aarch64-ноде.
Стендов теперь три: x86_64 (Linux 6.18.44, Firecracker) — тот, где стык гоняли
руками первым, а 2026-09-01 добавился x86_64 с Linux 7.2.2, где руками прошли
обе датапейсные стадии, все шесть мутаций стыка и оба кластерных гейта. Он же
первое ядро, на котором датапейс не теряет ни одной точки. Образы собираются
на ноде в каждом билде, docker push по-прежнему не делал никто, но образ
агента там впервые запущен — на настоящем ядре, с подписанным bundle из
кластера, attached: true; пинов агент не ставит, к API server не обращался
никто.
| Crate | Роль |
|---|---|
ferrum-common |
общие типы ошибок и мелочь, которую тянут все |
ferrum-ids |
идентификаторы и набор syscall датапейса как артефакт |
ferrum-api |
CRD / YAML типы ferrum.io/v1 |
ferrum-proto |
EnforcementEvent, EventEnvelope — формат записи наружу |
ferrum-crypto |
подпись bundle (Ed25519), X.509 и mTLS material, офлайн-выпуск webhook PKI |
ferrum-policy |
инварианты (waiver без TTL не существует) |
ferrum-compiler |
offline compile, не hot path webhook |
ferrum-wasm-abi / ferrum-wasm-host |
ABI и хост для будущих wasm-правил |
ferrum-ebpf |
userspace loader FEBP, prefilter, декодер kernel-записей |
ferrum-ebpf-progs |
eBPF datapath: sys_enter tracepoint, aya-ebpf под target_arch = "bpf" |
ferrum-k8smeta |
cgroup→pod индекс, watch Pod/Namespace/ServiceAccount, label cache |
ferrum-admission |
validating/mutating webhook, fail-closed, hot-reload bundle, ротация serving-сертификата |
ferrum-agent |
единственный BPF-носитель, LKG на диске, respond через SIGKILL |
ferrum-controller |
reconcile CRD → compile → rollout через Secret, публикация состояния |
ferrum-export |
JSONL-сток с ротацией и ограниченной очередью |
ferrum-cli |
ferrumctl: validate, compile, sign, verify, lint-deploy, gen-webhook-pki |
ferrum-testkit |
приёмка, replay записанных ring-байт, гейты границы и дерева установки |
Trust roots везде caller-supplied. Ни один crate не ходит в Rekor, CT log или иную сеть за корнем доверия и не выпускает CA в рантайме.
Две фичи выключены по умолчанию, и оба выключения намеренные:
attach(ferrum-agent→ferrum-ebpf) — настоящий kernel attach: нужныCAP_BPFи собранный под bpf-таргет ELF изferrum-ebpf-progs;apiserver(ferrum-agent→ferrum-k8smeta) — Pod metadata поspec.nodeName=$NODE. Без неё cgroup-индекс никто не наполняет, и агент идётDegraded, а не делает вид, что у namespaced-политик просто нет workload.
Продуктовая комбинация — attach,apiserver, она линкуется под musl. Собирается
она только на Linux: aya тянет netlink и SYS_bpf из libc, которых на
darwin нет, так что на маке cargo build --features attach падает на
aya (lib) — это хост, а не регрессия. Сборка по умолчанию проверяет bundle и
не имеет датапейса; так и называйте её.
cargo test --workspace
cargo run -p ferrum-cli -- validate policies/examples/prod-restricted.yaml
cargo run -p ferrum-cli -- lint-deploy deployСкрипт вердикта — Jenkinsfile в корне, джоба ferrum на локальном Jenkins
:8081. Двадцать шесть стадий, разложенных по пяти исполнителям:
- на ноде —
SAST (semgrep), три стадии образов (Agent image,Admission image,Controller image) иDatapath tracefs: всем нужен docker CLI, а сокет, проброшенный в контейнер, был бы тем самым hostPath, который правила рантайма убивают.Datapath tracefsмонтирует tracefs одноразовым привилегированным контейнером, не исполняющим ни строки репозитория: изнутри сборочного контейнера это потребовало быCAP_SYS_ADMIN, который ему не дают; - в rust-контейнере родной архитектуры — группа
Build(Format,Clippy,Test,BPF ELF), группаChecks(Crate boundary,Validate policies,Security: policy invariants,Security: MVP acceptance,Security: supply chain) и группаDatapath(BPF attach) — последняя стоит ближе к концу пайплайна: ей нужно настоящее ядро с tracefs, и её падение на ноде без него больше не уносит с собой supply chain и всё остальное, чему ядро не нужно; - на ноде же — группа
Datapath join(Datapath cgroup,BPF join,BPF join mutations), которая поднимает и останавливает свой контейнер сама. Стыку нужна писуемая cgroup2, а Docker монтирует cgroupfs на чтение; remount делает снаружи одноразовый привилегированный контейнер, и для этого нужен docker CLI, которого вложенная в docker-группу стадия не получает (JENKINS-30600).BPF attachосталась в группеDatapathнамеренно: она зелёная, и переносить её вместе со стыком значило бы рискнуть тем, что уже доказано; - группа
Link(Agent binary,Admission binary,Controller binary) — тоже в контейнере родной архитектуры, но цельx86_64-unknown-linux-muslберётся кросс-компиляцией:rustcидёт нативно, C-половинуringкомпилируетgcc-x86-64-linux-gnuиз штатного Debian, линкуетrust-lldтем musl, который везёт rustup. Цель из архитектуры ноды не выводится намеренно: нода — aarch64, а стенд стыка x86_64, и бинарь под arm64 оставил бы стадию зелёной, а утверждение про продуктовую комбинацию — про то, чего на том стенде не будет.
Пайплайн проходит целиком: последний прогон — билд #57 (8841639,
31.08.2026), Finished: SUCCESS, пропусков нет. Первым зелёным целиком был
#52; стадий тогда была двадцать одна, и BPF join прошла в нём впервые за всю
историю репозитория — шесть тестов из шести, четыре печатают SIGKILL,
подтверждённый waitpid, а BPF join mutations убила все шесть мутаций. С #16
по #51 пайплайн был красным, и чем именно — в границе, по билдам.
FERRUM_BPF_ELF_REQUIRED превращает пропуск в падение, чтобы стадия не могла
пройти, не исполнившись. На машине без ядра пайплайн красный, и это не
настраивается: гейт, умеющий себя пропустить, здесь считается дефектом.
U на строке Jenkinsfile::<стадия> в границе по-прежнему не значит «зелено
в CI» — гейт проверяет только то, что стадия с таким именем есть в файле. Что
именно исполнялось и где, читайте в «Как читать колонку „Исполняется“» в
MVP-1 boundary.
.github/workflows/ci.yml гоняет на GitHub Actions четыре стадии из того же
Jenkinsfile и с теми же командами: Format, Clippy, Test и группу
Checks целиком (Crate boundary, Validate policies,
Security: policy invariants, Security: MVP acceptance,
Security: supply chain). Скрипты шагов совпадают со стадиями построчно, и
это держит deploy_gate.rs::actions_parity: два CI, гоняющие похожее, дают
два разных вердикта об одном дереве.
Датапейс там не исполняется и исполняться не будет: BPF ELF, BPF attach,
BPF join, BPF join mutations, Datapath tracefs, Datapath cgroup
требуют настоящего ядра с tracefs, прав CAP_BPF/CAP_PERFMON, писуемой
cgroup2 и pid-namespace хоста, а раннер ubuntu-latest не даёт ничего из
этого. Стадия с именем BPF attach, зелёная без исполненного attach, была бы
ровно тем гейтом, умеющим себя пропустить, от которого написан весь этот
проект. По той же причине в Actions нет стадий образов и SAST (semgrep):
им нужен docker CLI ноды. Вердикт по датапейсу даёт локальный Jenkins, и
только он.
На GitHub этот воркфлоу ещё не исполнялся ни разу: поставленный файл — не прогон, и «зелено в Actions» здесь не написано, пока прогона нет.
Toolchain — 1.97.1 (rust-toolchain.toml), kube 1.x + k8s-openapi 0.25.
kube-derive тулчейн уже пускает, но фича derive не включена: типы
ferrum-api остаются serde-эквивалентом тех же манифестов. Nightly нужен
только ferrum-ebpf-progs (build-std под bpf-таргет).
deploy/ — дерево манифестов на три компонента: deploy/controller,
deploy/admission (Deployment, Service, ValidatingWebhookConfiguration как
шаблон), deploy/agent (DaemonSet observe плюс отдельный
optional-respond.yaml с hostPID и CAP_KILL). Два ServiceAccount у агента,
respond выключен по умолчанию.
Ставится оно одной командой, и deploy/README — про то, что именно она делает
и чего не делает:
kubectl apply -k deploy
kustomize, а не Helm: kubectl apply -k не требует второго бинаря, чарта в
репозитории и ещё одной цепочки поставки через воздушный зазор, а манифесты
остаются теми же файлами, которые читают все гейты этого дерева. Умолчания —
restricted, и второй копии этой позиции нет: values-файла не существует, любое
послабление — отдельный набранный руками apply. Единственное, что закрытому
контуру придётся поменять, — реестр образов; для этого есть
overlays/mirrored-registry, четыре строки, переносящие установку в тот
реестр, который разрешает prod-restricted.
Вне установки по умолчанию остаются трое, и у каждого причина в
deploy/README: deploy/agent (отдельный корень — его Pod не стартует на узле
без bpffs на /sys/fs/bpf), deploy/agent/optional-respond.yaml (hostPID и
CAP_KILL включаются набранной командой, а не наследуются) и
validatingwebhookconfiguration.yaml (появляется только после
ferrumctl gen-webhook-pki и применяется последним — с failurePolicy=Fail
он начинает отказывать в Pod'ах в момент появления).
Дерево не документ, а вход трёх разных гейтов, и они утверждают разное.
ferrumctl lint-deploy проверяет манифесты на инварианты threat model
(приватный ключ в дереве, caBundle-заглушка, hostPID без respond,
спроецированный токен под apiserver-watch и прочее).
crates/ferrum-testkit/tests/deploy_gate.rs читает текст — какие файлы ставит
какой корень, restricted ли умолчания, — и называется читающим текст, потому
что прошлый цикл показал цену обратного: два CRD из семи проходили здесь всё и
отвергались настоящим apiserver целиком. Про устанавливаемость утверждает
только crates/ferrum-testkit/tests/install_gate.rs: он ставит -k deploy в
настоящий kind на кластер, который FERRUM не видел, и ждёт, пока workload'ы
поднимутся. Serving PKI выпускается офлайн: ferrumctl gen-webhook-pki,
ротация — под тем же CA, которому кластер уже доверяет.
Образы: Dockerfile, Dockerfile.admission, Dockerfile.controller. Собираются
они в каждом билде на ноде локального Jenkins, а публикуются — релизным
воркфлоу по тегу; чем именно и как это проверить, ниже.
Образы едут в ghcr.io/onixus/ релизным воркфлоу
.github/workflows/release.yml — по git-тегу
вида v0.1.0, то есть по тому самому тегу, который закрепляют манифесты
deploy/**. Три образа, каждый подписан cosign и несёт аттестованный SBOM:
ghcr.io/onixus/ferrum-agentghcr.io/onixus/ferrum-admissionghcr.io/onixus/ferrum-controller
Подпись keyless: ключа нет ни в дереве, ни в секретах репозитория. Fulcio выписывает сертификат на один запуск под OIDC-токен GitHub, приватная половина умирает вместе с job, факт подписи уходит в Rekor. Проверяется не «ключ, который мы вам дали», а идентичность — этот воркфлоу, этот репозиторий, этот тег.
Это другой домен подписи, чем у PolicyBundle, и путать их нельзя. Bundle
подписывается Ed25519-seed'ом из Secret, который монтирует контроллер, в
контексте BUNDLE_SIGNATURE_CONTEXT; в CI этот ключ не попадает ни в каком
виде, и ни один ключ между доменами не переиспользуется. Проверка подписи образа
не заменяет проверку подписи bundle: первая говорит, откуда взялся бинарь,
вторая — откуда взялась политика, которую он исполняет.
SBOM считается syft по опубликованному образу, а не по дереву. Образ
собран на scratch и несёт один статический бинарь, поэтому список зависимостей
внутри него берётся не из репозитория: бинарь слинкован cargo-auditable, и
.dep-v0 в нём проверяется той же сборкой, которая его кладёт в образ. Тот же
SBOM прикладывается к GitHub Release файлом — но файл рядом с релизом не
доказательство, доказательство здесь аттестация.
Нужны cosign ≥ 3.1, jq, и для последнего
шага cargo install cargo-audit.
TAG=v0.1.0
IMAGE=ghcr.io/onixus/ferrum-agent # то же для ferrum-admission и ferrum-controller
IDENTITY="https://github.com/onixus/Ferrum/.github/workflows/release.yml@refs/tags/$TAG"
ISSUER=https://token.actions.githubusercontent.com1. Подпись и происхождение. Команда падает, если образ подписан не этим воркфлоу, не в этом репозитории, не на этом теге — или не подписан вовсе:
cosign verify \
--certificate-identity "$IDENTITY" \
--certificate-oidc-issuer "$ISSUER" \
"$IMAGE:$TAG"2. Digest, который подписан. Тег переставляется, digest — нет. Подписан digest, и в кластер надо ставить его, а не тег:
DIGEST=$(cosign verify \
--certificate-identity "$IDENTITY" \
--certificate-oidc-issuer "$ISSUER" \
"$IMAGE:$TAG" 2>/dev/null \
| jq -r '.[0].critical.image."docker-manifest-digest"')
echo "$IMAGE@$DIGEST"3. SBOM. Аттестация подписана той же идентичностью и прикреплена к digest, поэтому проверяется, а не принимается на слово:
cosign verify-attestation --type spdxjson \
--certificate-identity "$IDENTITY" \
--certificate-oidc-issuer "$ISSUER" \
"$IMAGE@$DIGEST" \
| jq -r '.payload' | base64 -d | jq '.predicate' > sbom.spdx.json4. Что внутри бинаря — из самого бинаря. Ответ берётся из артефакта, а не из репозитория, которому получатель ещё ничего не должен:
docker create --name ferrum-check "$IMAGE@$DIGEST"
docker cp ferrum-check:/usr/local/bin/ferrum-agent ./ferrum-agent
docker rm ferrum-check
cargo audit bin ./ferrum-agentРасхождение README с тем, что воркфлоу делает на самом деле, — тихий отказ:
инструкция с идентичностью, которой Fulcio не выпишет, не пройдёт ни у кого, и
красным от этого не станет ничего. Поэтому идентичность, издатель, тег и имена
образов сверяются с файлом воркфлоу гейтом
deploy_gate.rs::the_documented_verification_is_the_one_the_release_performs, а
сам релиз исполняет команды 1 и 3 на только что подписанном образе,
прежде чем закончить.
Ни один из этих образов ещё не опубликован. Воркфлоу на GitHub не запускался
ни разу, в ghcr.io/onixus/ нет ни одного тега, и ни одна команда выше ещё не
проходила против настоящего опубликованного артефакта. Пока это так, раздел
описывает процедуру, а не свидетельствует о ней; в
границе строки «опубликовано» и «подписано» нет и до
первого настоящего релиза не будет.
Тега v0.1.0 не существует. Он не проставлен, релиза под ним нет, и всё ниже —
описание того, что этим тегом будет выпущено, а не отчёт о выпуске. Тег ставит
владелец репозитория; ни один воркфлоу и ни один агент этого не делает.
Версия одна на всё дерево: workspace.package.version = "0.1.0", все
восемнадцать crate берут её через version.workspace = true, ни один не
объявляет свою. Отсюда и имя тега: v + эта версия — ровно та строка, которую
закрепляют deploy/** (ghcr.io/onixus/*:v0.1.0) и которую пропускает фильтр
триггера release.yml (v[0-9]+.[0-9]+.[0-9]+). Три конца сведены гейтом
deploy_gate.rs::the_version_this_workspace_carries_is_the_tag_its_manifests_pin:
разойтись версии в манифесте, в Cargo.toml и в фильтре тега молча не могут.
Что тег произведёт, если воркфлоу отработает:
- три образа в
ghcr.io/onixus/—ferrum-agent,ferrum-admission,ferrum-controller, тегомv0.1.0, собранные изDockerfile,Dockerfile.admission,Dockerfile.controller; образ агента несёт датапейсный ELF, собранный тем же прогоном; - подпись cosign на digest каждого — keyless, без ключа в дереве и в секретах;
- SBOM SPDX на каждый образ, посчитанный
syftпо опубликованному образу: аттестацией на digest и файлом в GitHub Release.
Что тег не произведёт и что в релиз не входит: датапейсные стадии остаются на локальном Jenkins — публичный CI исполняет только userspace-половину; Helm chart и kustomize overlay в дереве нет; e2e против настоящего apiserver не исполнялся, поэтому релиз не является утверждением, что компоненты работают в живом кластере. Что исполнено, а что нет, — по-прежнему только граница, и релиз ни одной её строки не меняет.
Пока тега нет, kubectl apply -f deploy/ даёт три ImagePullBackOff, и это
корректное поведение дерева, которое ничего не публиковало.
- MVP-1 boundary — что исполнено, что нет, что заявлено
- RFC-02 — crate-границы, CRD, threat model агента, CIS/MITRE
- CRD catalog
- SECURITY.md — куда сообщать об уязвимости, окно ответа и что здесь вне модели угроз
- ROADMAP.md — фазы, критерии закрытия и открытые решения
- deploy/admission/README, deploy/agent/README — установка и ротация
Apache-2.0 — LICENSE в корне, [workspace.package] license в Cargo.toml.
Выбор сделан владельцем проекта 2026-08-31: дерево было под GPL-3.0-only, и
переход на Apache-2.0 открывает встраивание и OEM ценой отказа от защиты от
проприетарного форка. Ни одна зависимость к этому не принуждала — deny.toml
разрешает у зависимостей только пермиссивное, — так что смена не потребовала
ничьего согласия, кроме владельца. Разбор развилки и то, что решение
необратимо, — ROADMAP.md, раздел «Лицензия».