Интересное начало, не так ли? Меня зовут Роман, и я младший инженер информационной безопасности Ozon. В этой статье я расскажу о проблеме отсутствия авторизации доступа к Docker daemon / Docker Engine API / командам Docker при работе с контейнерами в экосистеме Docker и как это можно решить при помощи 11 почти стихотворных строчек bash.
Говоря о стихотворчестве, первое, что приходит мне в голову, это уроки литературы, где моим самым любимым романом был «Евгений Онегин». В школе учитель литературы говорила нам: «Это вы сейчас это не понимаете... Уже потом, спустя год, пять или даже 20 лет, вы вновь прикоснётесь к книгам, которые, казалось бы, вы уже знаете вдоль и поперёк. И вот тогда вы поймёте всё то, что мы с вами тут обсуждаем, или даже откроете для себя что-то, о чем и не догадываетесь».
Решившись перечитать роман Александра Сергеевича Пушкина «Евгений Онегин», я был приятно удивлён тем, что сумел открыть для себя что-то новое о давно забытых героях, быть может, даже рассмотреть их отношения с другой стороны. Так, например, я никогда не задумывался о том, что Татьяне Лариной могло быть 14 лет в момент первой встречи с Евгением, что Ленский со своей «платонической» любовью довёл «до ручки» Ольгу, или что Евгений, возможно, позже влюбился в Татьяну из-за её положения. Как подрастающий инженер информационной безопасности я внезапно задался вопросом: «А как бы выглядел роман "Евгений Онегин", если бы он был написан сотрудником информационной безопасности?». Кхм-кхм, «С героем моего Романа без предисловий, сей же час позвольте познакомить вас»...
Проблема с авторизацией доступа, или Как Bug поселился в поместье
В Docker не предусмотрено никакой авторизации доступа к Docker daemon / Docker Engine API / командам Docker при работе с контейнерами. Согласно официальной документации:
Docker's out-of-the-box authorization model is all or nothing. Any user with permission to access the Docker daemon can run any Docker client command.
Теперь давайте смоделируем ситуацию:
Вам необходимо, чтобы несколько команд разработчиков на одной виртуальной машине могли в режиме realtime взаимодействовать друг с другом и вести разработку с помощью Docker.
Поскольку разработчики будут иметь доступ к Docker, это значит, что у них будет доступ к /var/run/docker.sock
. Это позволит отправлять любые запросы Docker Daemon, даже те, которые помогут ему выбраться из контейнера и завладеть root.
Следуя принципу наименьших привилегий, нельзя доверять большому количеству человек, надеясь, что никто ничего не сломает. Однозначно может найтись кто-то, кто захочет напакостить, остановить наш контейнер, украсть секреты из него или просто случайно поломать его.
И здесь возникают две проблемы безопасности:
-
Разработчики могут запускать любые команды через Docker Daemon, создавая bad container, позволяющие получить привилегированный доступ на хосте.
-
Разработчики могут взаимодействовать с контейнерами других команд, даже с теми, которые им не принадлежат.
Если для решения первой проблемы, достаточно обратиться к интернету и найти open-source-решение, которое запрещает выполнять команды, определённые инженером (например, OPA-плагин), то со второй будет сложнее.
Поскольку готовых реализаций, позволяющих решить эту проблему, в открытом доступе найти не удалось, мной был написан плагин, который я представлю в данной статье. Достаточно будет установить и запустить его. После этого:
-
будет ограничено взаимодействие с контейнерами, которые нам не принадлежат;
-
начнут работать правила, запрещающие создавать bad container, а именно:
-
Privileged должен быть false. Привилегированные контейнеры имеют отключенный apparmor- и seccomp-профили. Также эти контейнеры имеют все Capabilities, поэтому выбраться из него не составит труда.
-
NetworkMode не должен быть host. В противном случае контейнер, имея
SYS_ADMIN
иSYS_RAW
capabilities, может перехватывать весь сетевой трафик с docker, будучи неизолированным от Docker host. Флаг--network
-
IpcMode должен быть либо пустым, либо none, либо private. В противном случае есть возможность получить доступ к механизму межпроцессного взаимодействия (IPC). Флаг
--ipc
-
Binds не должен быть host, иначе будет примонтирована корневая файловая система хоста в контейнер. Это означает, что контейнер будет иметь полный доступ к файлам и директориям на вашем хосте. Флаг
-v
-
CapAdd должен быть null, чтобы исключить навешивание дополнительных capabilities на container. Флаг
--cap-add
-
PidMode не должен быть host. Если будет выполнен запуск контейнера в pid и имеется
SYS_PTRACE
capability в неймспейсе хоста, то это позволит получить список всех процессов. Флаг--pid
-
SecurityOpt должен всегда оставаться пустым. Этот параметр отвечает за изменение apparmor- и seccomp-профилей, служащих защитным механизмом, поддерживающим изоляцию в контейнере. Флаг
--security-opt
-
Devices должен оставаться пустым. Например,
--device=/dev/sda1
позволяет получать доступ к файловой системе хоста. -
CgroupParent должен быть пустым. Если назначенная группа используется для обращения к cgroup, которая уже используется другими процессами или контейнерами, это может быть опасным.
Разработчика идентификатор как контейнера шлафор
Предлагаю сначала разобраться с разграничением доступа в другие контейнеры. Чтобы выполнить разграничение, нам нужен какой-то уникальный идентификатор пользователя, который должен попадать в наш плагин, и ID контейнера, к которому пользователь будет запрашивать доступ. Что должно выступать в качестве уникального идентификатора? Изучив немного подробней документацию, можно заметить кусок, в котором говорится:
The property HttpHeaders specifies a set of headers to include in all messages sent from the Docker client to the daemon. Docker doesn't try to interpret or understand these headers; it simply puts them into the messages. Docker does not allow these headers to change any headers it sets for itself.
То есть мы можем для пользователя определить статичный случайно сгенерированный уникальный идентификатор, который будет храниться в /.docker/config.json
в домашней директории пользователя. Получается, при каждом обращении к docker daemon этот идентификатор будет в заголовке запроса. Мы будем доставать этот идентификатор и сопоставлять с контейнером.
Хм, а как идентификатор окажется у разработчика в файле? И что делать, если команде нужно иметь доступ к контейнерам друг друга?
Думаю, что здесь придёт на помощь bash-скрипт, запустить который может root. Скрипт будет проверять, есть ли у пользователя /.docker/config.json
, если нет, то скрипт создаст конфиг. Если конфиг уже будет, но в нём не будет специального header с идентификатором, то скрипт его добавит. То есть все должно пройти беcшовно для разработчиков. Если же разработчикам необходимо иметь доступ к контейнерам друг друга, то придётся озаботиться тем, чтобы на команду генерировался один и тот же идентификатор.
А как будет происходить процесс сопоставления? Как мы узнаем, что контейнер создался и пора сопоставлять?
Чтобы ответить и на этот вопрос, обратимся к документации. После изучения станет понятно, что при работе с контейнерами в основном используется конструкция:
/v1.44/containers/{id}/stop
/v1.44/containers/{id}/kill
/v1.44/container/{id}/{action}
Замечу, что API создания контейнера выглядит следующим образом:
/v1.44/containers/create
Казалось бы, здесь может возникнуть трудность в сопоставлении при создании контейнера, однако это не так. Рассмотрим два варианта:
-
создание контейнера через docker cli.
docker run –p 80:80 custom_image
-
создание через прямой запрос к docker daemon
В первом случае выполнение команды docker run –p 80:80 custom_image
приведёт к созданию нескольких запросов:
-
/v1.44/containers/create + Body
-
/v1.44/containers/{id}/wait?condition=next-exit
-
/v1.44/containers/{id}/start
То есть отловить событие на создание контейнера у нас всё-таки получится. Что же касается второго варианта, то, создав контейнер, разработчику необходимо будет обратиться к контейнеру, если мы, конечно, ожидаем, что контейнеры не создаются кем-то просто так, оставаясь висеть как «мёртвая душа».
А что подразумевается под id контейнера?
К контейнеру можно обращаться по имени, по id любой длины, который точно и однозначно определяет контейнер, к которому обращаются. Здесь важный момент в том, что нам необходимо будет считаться с именами контейнеров, ведь при создании связки между пользователем и контейнером мы имеем только полный id контейнера.
И вот звон брегета доносит: идея плагина реализацию просит (реализация)
Рассмотрим теперь реализацию идеи. Код находится здесь — https://github.com/I-am-Roman/docker-auth-plugin. После того, как AuthHeader с помощью bash появился в /.docker/config.json
, можно подключать плагин, как это всё делается, описано здесь: https://github.com/I-am-Roman/docker-auth-plugin?tab=readme-ov-file#enable-the-authorization-plugin-on-docker-engine. Мы же перейдём к рассмотрению, как всё работает:
docker_dir="$HOME/.docker"
config_file="$docker_dir/config.json"
if [ ! -d "$docker_dir" ]; then
mkdir -p "$docker_dir"
fi
if [ ! -f "$config_file" ]; then
echo '{
"HttpHeaders": {
"AuthHeader": "'$(openssl rand -hex 16)'"
}
}' > "$config_file"
echo "Настройки успешно обновлены в $config_file"
else
if grep -q '"AuthHeader":' "$config_file"; then
echo "AuthHeader уже существует в $config_file"
else
authHeader=$(openssl rand -hex 16)
jq ". + {"HttpHeaders": {"AuthHeader": "$authHeader"}}" "$config_file" > "$config_file.tmp" && mv "$config_file.tmp" "$config_file"
echo "Настройки успешно обновлены в $config_file"
fi
fi
С самого начала при запуске мы определяем наш докер-плагин и забираем из ENV-переменных ADMIN_TOKEN — это sha256 от настоящего токена.
AdminToken = os.Getenv("ADMIN_TOKEN")
plugin.DefineAdminToken(AdminToken)
authPlugin, err := plugin.NewPlugin()
if err != nil {
log.Fatal(err)
}
Docker plugin будет работать в режиме pre-hook. То есть Docker daemon перед исполнением команды, поступившей, например, через docker cli, будет отправлять к нам запрос, задавая вопрос, можно ли выполнить запрос или нет. Мы же будем отвечать: «Да, можно» или «Нет, нельзя». Запросы будут поступать на API – AuthZReq.
Вот поступил запрос, что дальше? Перед началом нам потребуется:
-
запрос;
-
тело запроса (в формате string);
-
две map. Одна будет хранить
id:hash_key
. Другаяid:name
-
удалить версию из запроса, если она есть. Ранее уже были продемонстрированы запросы с docker daemon. Он будет отправлять нам запросы с версией docker engine. Это возможный bypass, поэтому необходимо обрезать версию, если она есть.
obj := reqURL.String()
reqBody, _ := url.QueryUnescape(string(req.RequestBody))
// Cropping the version /v1.42/containers/...
re := regexp.MustCompile(`/vd+.d+/`)
obj = re.ReplaceAllString(obj, "/")
После этого можно начинать проверку:
-
разрешенное ли это действие (проверяем на совпадение). Такие действия можно выполнять без токена;
for _, j := range AllowToDo {
if obj == j {
return authorization.Response{Allow: true}
}
}
-
запрещено ли это действие (здесь мы проверяем на то, чтобы запрос не начинался с того, что мы запретили);
for _, j := range ForbiddenToDo {
keyHash := CalculateHash(req.RequestHeaders[headerWithToken])
if yes := IsItAdmin(keyHash); yes {
return authorization.Response{Allow: true}
}
if strings.HasPrefix(obj, j) {
return authorization.Response{Allow: false, Msg: "Access denied by AuthPlugin: " + obj}
}
}
-
далее идёт проверка на то, что container создаётся или обновляется (update) без опасных параметров, но об этом чуть позже
-
далее мы проверяем через
strings.HasPrefix(obj, actionWithContainerAPI)
, что запрос предполагает какое-либо действие с container’ом:a. достаём AuthHeader;
b. считаем хэш;
c. полагаем, что контейнер создан и у нас нет его имени. Определяем имя контейнера:
i. к docker daemon можно обращаться не только через docker cli, но и напрямую. Воспользуемся этим, выполнив запрос, аналогичный
docker ps -a
;ii. дальше определяем, какие контейнеры у нас уже есть через
isItIdExist
;iii. контейнеры, которые не удалось обнаружить, мы отправим на удаление из мапы;
iv. удалять из map, параллельно итерируясь по нему, как я считаю, небезопасно, поэтому составим map из тех, кто должен быть удалён;
func CheckDatabaseAndMakeMapa() error {
ctx := context.Background()
cli, err := client.NewClientWithOpts(client.FromEnv, client.WithAPIVersionNegotiation())
if err != nil {
return err
}
defer cli.Close()
// similar to the "docker ps -a"
containers, err := cli.ContainerList(ctx, types.ContainerListOptions{All: true})
if err != nil {
return err
}
// Create map for a quick check of uniqueness
// Get info from docker daemon and confidently speak
// this container exist
doesThisIDExist := make(map[string]bool)
for _, container := range containers {
ID := container.ID[:12]
name := container.Names[0]
// Docker Daemon usually return /<nameOfContainer> that's why we need to TrimLeft a "/"
hasSlash := strings.Contains(name, "/")
if hasSlash {
name = strings.TrimLeft(name, "/")
}
doesThisIDExist[ID] = true
if _, exists := IDAndNameMapping[ID]; !exists {
IDAndNameMapping[ID] = name
}
}
// Create temporary map for key storage we need to delete from IDAndNameMapping
keysToDelete := make(map[string]bool)
for key := range IDAndNameMapping {
if !doesThisIDExist[key] {
keysToDelete[key] = true
}
}
// Delete old container also from IDAndHashKeyMapping
for oldId := range keysToDelete {
delete(IDAndNameMapping, oldId)
_, found := IDAndHashKeyMapping[oldId]
if found {
delete(IDAndHashKeyMapping, oldId)
}
}
return nil
}
d. мы достаём предполагаемый id (на самом деле мы пока не знаем, что там: имя, id или мусор) из контейнера и проверяем, что пришло в запросе:
i. если это имя, то меняем на id. Пользователь сам может определить, какой id необходимо отправить, поэтому нам нужно быть готовыми к этому;
ii. поскольку у нас в map хранятся 12-значные id, то чтобы сопоставить 8-значный id и 12-значный, нам придётся привести 12-значный к 8-значному типу;
iii. если же нам не удалось выполнить сопоставление, то в запросе не id контейнера;
func DefineContainerID(obj string) string {
partsOfApi := strings.Split(obj, "/")
containerID := partsOfApi[2]
isitNameOfContainer := false
for id := range IDAndNameMapping {
if containerID == IDAndNameMapping[id] {
isitNameOfContainer = true
// Redefining containerID
containerID = id
break
}
}
// If user sent a containerID with less, than 12 symbols, or less, than 64, but not 12
if len(containerID) != 64 && len(containerID) != 12 && !isitNameOfContainer {
IsItShortId := false
if len(containerID) > 12 {
containerID = containerID[:12]
}
for ID := range IDAndHashKeyMapping {
if ID[:len(containerID)] == containerID {
containerID = ID
IsItShortId = true
break
}
}
// We get a trash
if !IsItShortId {
return trash
}
}
return containerID[:12]
}
-
после того, как мы получили id, проверим, принадлежит ли этот контейнер уже кому-нибудь.
a. если принадлежит, то проверим, что keyHash пользователя, который хочет выполнить действие, равен keyHash, которому принадлежит контейнер.
b. также проверяем, не админ ли это.
func AllowMakeTheAction(keyHashFromMapa string, keyHash string) bool {
if keyHashFromMapa == keyHash {
return true
} else {
if yes := IsItAdmin(keyHash); yes {
return true
}
return false
}
}
keyHashFromMapa, found := IDAndHashKeyMapping[containerID]
if found {
if allow := AllowMakeTheAction(keyHashFromMapa, keyHash); allow {
return authorization.Response{Allow: true}
} else {
return authorization.Response{Allow: false, Msg: "Access denied by AuthPlugin. That's not your container"}
}
} else {
log.Println("That's container was created right now:", containerID)
IDAndHashKeyMapping[containerID] = keyHash
return authorization.Response{Allow: true}
}
-
также есть проверка на exec. Запрос от него выглядит по-другому, поэтому его придётся учитывать отдельно. Меняется лишь то, что нам не нужно сопоставлять имя и ID контейнера, поскольку это было сделано ранее.
// If it is exec, we don't need to execute CheckDatabaseAndMakeMapa
if strings.HasPrefix(obj, execAtContainerAPI) {
key, found := req.RequestHeaders[headerWithToken]
if !found {
instruction := fmt.Sprintf("Access denied by AuthPlugin. Authheader is Empty. Follow instruction - %s", manual)
return authorization.Response{Allow: false, Msg: instruction}
}
keyHash := CalculateHash(key)
containerID := DefineContainerID(obj)
if containerID == trash {
return authorization.Response{Allow: true}
}
keyHashFromMapa, found := IDAndHashKeyMapping[containerID]
if found {
if allow := AllowMakeTheAction(keyHashFromMapa, keyHash); allow {
return authorization.Response{Allow: true}
} else {
return authorization.Response{Allow: false, Msg: "Access denied by AuthPlugin. You can't exec other people's containers"}
}
}
}
-
Если мы дошли до конца функции, то разрешаем выполнить действие, чтобы не блокировать то, для чего контроль не нужен.
Более остро встаёт вопрос с docker container escape или побегом из контейнера. Поэтому компании пытаются решить сначала проблему с этим, устанавливая open-sourse / платные решения. Если бы кто-то захотел сейчас решить проблему с отсутствием авторизации в контейнере, то пришлось бы работать с несколькими плагинами. Docker позволяет установить несколько плагинов, но это такая морока. Хотелось бы прийти к тому, чтобы можно было просто «Установить и работать». Поэтому в описанном решении также было предусмотрено ограничение действий, в зависимости от прописываемых политик. Я же в свою очередь ввёл политики, которые нацелены защитить от создания bad container. Они были описаны в самом начале статьи. Перейдём к рассмотрению, как всё работает:
-
После того, как мы опустились в условие того, что контейнер создаётся или обновляется, мы должны проверить тело запроса. Именно там будет информация о том, с какими параметрами пришёл запрос на создание контейнера.
-
Проверяем, не администратор ли пытается создать контейнер. Для Admin политики не будут действовать.
if obj == creationContainerAPI || updateRegex.MatchString(obj) {
if req.RequestHeaders[headerWithToken] != "" {
keyHash := CalculateHash(req.RequestHeaders[headerWithToken])
if yes := IsItAdmin(keyHash); yes {
return authorization.Response{Allow: true}
}
}
// Allow to create without AuthHeader, because we don't have the container ID at this step
yes, failedPolicy := containerpolicy.ComplyTheContainerPolicy(reqBody)
if !yes {
msg := fmt.Sprintf("Container Body does not comply with the container policy: %s", failedPolicy)
return authorization.Response{Allow: false, Msg: "Access denied by AuthPlugin." + msg}
}
}
-
Проверяем, что контейнер соответствует политикам. Здесь остановимся поподробней и рассмотрим процесс валидации:
a. введено три правила: ExpectToSee. Происходит проверка на равенство между значением из Body и значением, прописанным в политике. DoesntExpectToSee проверяет, что ни одно значение из slice не равно тому, что прописано в политике (Разрешено всё, кроме ...). AllowToUse (Запрещено всё, кроме) проверяет, что используемые значения, используемые в slice допустимы;
b. достаются все значения из CSV-файла;
c. Body обязательно приводится к LowerCase;
d. разработчику, чтобы написать политику, нужно знать, какие переменные используются в Body, потому что потом с помощью regexpr будут вычленяться переменные и их значения.
for _, row := range records {
nameOfKey := strings.ToLower(row[0])
valueFromCSV := strings.ToLower(row[1])
typeOfData := row[2]
kindOfPolicy := row[3]
var searcher string
switch typeOfData {
case "slice":
// will ignore null and []
searcher = fmt.Sprintf(`"%s":s*[([^]]*)]`, nameOfKey)
case "string":
// will ignore null
searcher = fmt.Sprintf(`"%s":"([^"]+)"`, nameOfKey)
case "bool":
searcher = fmt.Sprintf(`"%s":([^",]+)`, nameOfKey)
}
re := regexp.MustCompile(searcher)
// if someone will want to add the same key with a forbidden value for bybass
matches := re.FindAllStringSubmatch(body, -1)
-
Также мы ищем ВСЕ найденные строки, потому что злоумышленник может через, например, Burp Suite перехватить запрос, добавив в конце запрещённые key:value.
-
Касательно AllowToUse, DoesntExpectToSee достаём переменные из политик и проверяем каждую переменную из body на соответствие политике.
for _, match := range matches {
if match != nil {
if kindOfPolicy == ExpectToSee {
if match[1] != valueFromCSV {
return false, nameOfKey
}
} else if kindOfPolicy == DoesntExpectToSee {
csv := strings.Trim(valueFromCSV, "[]")
sliceFromCSV := strings.Split(csv, ",")
// if will get: ["value1","value2","value3"]
// regexpr give us at match[1] - "value1","value2","value3"
// we should check every single value
valueOfMatches := strings.Split(match[1], ",")
for _, valueOfMatch := range valueOfMatches {
valueOfMatch := strings.Trim(valueOfMatch, """)
for _, dontExpect := range sliceFromCSV {
if dontExpect == valueOfMatch {
return false, nameOfKey
}
}
}
} else if kindOfPolicy == AllowToUse {
csv := strings.Trim(valueFromCSV, "[]")
sliceFromCSV := strings.Split(csv, ",")
valueOfMatches := strings.Split(match[1], ",")
for _, valueOfMatch := range valueOfMatches {
valueOfMatch = strings.Trim(valueOfMatch, """)
isItValueOK := false
for _, allowToUse := range sliceFromCSV {
if allowToUse == valueOfMatch {
isItValueOK = true
continue
}
}
if !isItValueOK {
return false, nameOfKey
}
}
} else {
log.Println("I don't know this policy!")
return true, ""
}
}
}
Касательно тестов:
-
Перед самой интеграцией можно запустить filename_test.go для проверки работоспособности.
-
Система, предназначенная для тестирования уже интегрированного docker plugin: https://github.com/I-am-Roman/test-system-dockerAuthPlugin
Итог как неожиданный порог
Таким вот образом, Евгений смог вернуть себе свой дом, а мы получили security-инструмент. Теперь-то можно подвести итоги.
У нас имеется реализованный DockerAuthPlugin, включающий в себя:
-
Bypass для администратора;
-
разрешение на выполнение без токена:
-
/ping
-
/images/json; docker images
-
/containers/json?all=1; docker ps -a
-
/containers/json; docker ps
-
-
запрет на выполнение:
-
/plugin; docker plugin ls, docker plugin create, docker plugin enable и другие
-
/volumes; docker volumes ls, docker volumes create и другие
-
/commit
-
-
запрет на создание/обновление контейнера со следующими настройками:
-
--privileged (запрет на создание с параметром "Privileged" не равным false)
-
--cap-add (запрет на создание с параметром "CapAdd" не равным null)
-
--security-opt (запрет на использование флага --security-opt)
-
--pid (запрет на создание с параметром "PidMode" не равным ''(пустой строке))
-
--ipc (запрет на создание с параметром "IpcMode" не равным ''(пустой строке))
-
-v (запрет на создание с параметром "Binds" не равным null)
-
--cgroup-parent (запрет на создание с параметром "CgroupParent" не равным''(пустой строке))
-
--device (запрет на создание с параметром "PathOnHost" и "PathInContainer" не равным ''(пустой строке)"
-
-
аутентификацию при использовании команд:
-
docker stop
-
docker inspect
-
docker rm
-
docker start
-
docker pause
-
docker unpause
-
docker logs
-
docker exec
-
docker port
-
docker cp
-
docker update
-
и других, требующих взаимодействия с контейнерами
-
-
Bash-скрипт для бесшовного добавления необходимого заголовка в /.docker/config.json для пользователей.
-
И создана тест-система с тест-кейсами, которая помогает выявить возможную ошибку при добавлении/удалении функционала.
Что дальше?
1. Вместо случайного токена можно использовать JWT-токен от Identity Provider. Вы заходите на некоторый ресурс, например https://docker-jwt.com, который осуществляет авторизацию SSO, после авторизации на странице появляется JWT-токен (access и refresh), который необходимо скопировать в /.docker/config.json
. Дальше на стороне плагина авторизацию осуществлять по группам в токене. Чтобы обновлять access- и refresh-токены, возможно, придётся реализовать консольную утилиту, которая бы обновляла токены. Можно будет по группам в jwt-токене проверять, может ли обладатель группы X получить доступ к контейнеру.
2. Реализовать поддержку БД, чтобы при перезапуске docker plugin все уже имеющиеся данные не стирались. В дальнейшем это позволит развить идею с ролевой моделью.
3. Научить плагин выполнять задачи параллельно/асинхронно.
Напишите в комментариях, захотелось ли перечитать «Евгения Онегина»?
(1) «СТО объявил 2023 год годом ИБ. Приоритет задач от команды ИБ вырос вдвое».
(2) RISK — проект ИБ в системе управления проектами и набор инструментов автоматизации, предназначенный для устранения проблем безопасности в Ozon.
(3) Кляня — проклиная.
Автор:
cherry_seven777