Читать 6 мин

Может ли проверка зашифрованного журнала аудита обнаружить удаление?

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

Может ли проверка зашифрованного журнала аудита обнаружить удаление?

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

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

NIST описывает хеш-цепочку как структуру только для добавления, где каждый блок содержит хеш предыдущих данных. Поэтому изменение блока меняет дайджест, записанный его преемником. Это свидетельство вмешательства, а не машина времени.

Проверка цепочки проверяет связь, а не полноту

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

Предположим, файл содержит записи с 1 по 100. Запись 58 содержит дайджест записи 57, а запись 59 - дайджест записи 58. Если кто-то удалит запись 58, оставив всё остальное без изменений, запись 59 по-прежнему будет ссылаться на дайджест, которого проверяющий не найдёт. Проверка завершится ошибкой на разрыве.

Теперь удалим записи с 91 по 100. Запись 90 по-прежнему правильно ссылается на запись 89. В укороченном файле ничто не говорит, что после записи 90 когда-то следовали ещё десять записей. Проверяющий, который начинает с записи 1 и останавливается в конце файла, может вернуть успех. Он проверил историю, которую ему предъявили. Но он не проверил, была ли эта история полной.

Часто три разных утверждения объединяют в фразу «журнал защищён от изменений»:

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

Для этих утверждений нужны разные свидетельства. Линейная цепочка хорошо подтверждает первое и обнаруживает простой вариант второго. Третьему нужно независимое более позднее обязательство, которое часто называют контрольной точкой, вершиной, печатью, квитанцией или свидетелем.

Certificate Transparency разделяет эти понятия с помощью другой структуры данных. RFC 9162 говорит, что доказательство согласованности дерева Меркла может показать, что новое дерево является расширением предыдущего опубликованного дерева. Здесь важную работу выполняет вершина ранее опубликованного дерева. Без неё текущий корень ничего не говорит о записях, которые оператор журнала решил вам не показывать.

Создайте тестовый журнал, считающий данные непрозрачным шифротекстом

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

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

# make_log.py
import base64
import hashlib
import json
import os

OUT = "audit.jsonl"
ZERO = "0" * 64


def canonical(record):
    return json.dumps(record, sort_keys=True, separators=(",", ":")).encode()


def digest(record):
    return hashlib.sha256(canonical(record)).hexdigest()

prev = ZERO
with open(OUT, "w", encoding="utf-8") as f:
    for seq in range(1, 13):
        body = {
            "seq": seq,
            "ciphertext": base64.b64encode(os.urandom(24)).decode(),
            "prev": prev,
        }
        body["hash"] = digest(body)
        f.write(json.dumps(body, sort_keys=True) + "\n")
        prev = body["hash"]

print(f"wrote 12 records to {OUT}")
print(f"head seq=12 hash={prev}")

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

# verify_log.py
import hashlib
import json
import sys

ZERO = "0" * 64


def canonical(record):
    return json.dumps(record, sort_keys=True, separators=(",", ":")).encode()


def digest_without_hash(record):
    copy = dict(record)
    supplied = copy.pop("hash", None)
    return supplied, hashlib.sha256(canonical(copy)).hexdigest()


def fail(message):
    print(f"FAIL {message}")
    raise SystemExit(1)

path = sys.argv[1]
prev = ZERO
expected_seq = 1
last_hash = ZERO

with open(path, encoding="utf-8") as f:
    for line_no, line in enumerate(f, start=1):
        try:
            record = json.loads(line)
        except json.JSONDecodeError:
            fail(f"line={line_no} invalid JSON")

        if record.get("seq") != expected_seq:
            fail(f"line={line_no} expected_seq={expected_seq} got={record.get('seq')}")

        if record.get("prev") != prev:
            fail(f"line={line_no} seq={expected_seq} predecessor mismatch")

        supplied, calculated = digest_without_hash(record)
        if supplied != calculated:
            fail(f"line={line_no} seq={expected_seq} digest mismatch")

        prev = supplied
        last_hash = supplied
        expected_seq += 1

print(f"OK records={expected_seq - 1} head={last_hash}")

Запустите тестовый набор и сохраните выведенную вершину отдельно от файла журнала:

python3 make_log.py
python3 verify_log.py audit.jsonl

Результат должен выглядеть примерно так, но при каждом запуске дайджест будет другим:

wrote 12 records to audit.jsonl
head seq=12 hash=8f...c2
OK records=12 head=8f...c2

Эта пара records=12 и head=... - ваша контрольная точка. Запишите её в отдельный файл с заметками о тесте до изменения копий. Если оставить контрольную точку только в файле, который вы собираетесь атаковать, вы создадите удобную запись того, что злоумышленник может изменить.

Удаление хвоста сразу показывает ограничение

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

cp audit.jsonl tail-cut.jsonl
head -n 9 audit.jsonl > tail-cut.jsonl
python3 verify_log.py tail-cut.jsonl

Проверяющий сообщит об успехе для девяти записей. Так и должно быть. Записи с 1 по 9 по-прежнему образуют корректную цепочку. Называть это ошибкой проверяющего было бы опасно: такой подход заставил бы считать корректные частичные истории повреждёнными.

Сравните результат с контрольной точкой:

OK records=9 head=4a...91
expected records=12 head=8f...c2

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

Номер последовательности помогает человеку разобраться в проблеме. Сам по себе он не создаёт безопасность. Злоумышленник, способный переписать хвост, может изменить последний номер последовательности с 12 на 9. Номер становится полезным, когда ожидаемое значение пришло из источника, который злоумышленник не может переписать: например, из подписанной контрольной точки отдельного сервиса, защищённого артефакта выпуска или экспорта, попавшего в другой административный домен.

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

Удаление в середине должно завершаться ошибкой, но только при оговорённых условиях

Сделайте ещё одну копию и удалите запись, у которой есть и предшественник, и преемник. Запись 6 подходит хорошо.

cp audit.jsonl middle-cut.jsonl
sed '6d' audit.jsonl > middle-cut.jsonl
python3 verify_log.py middle-cut.jsonl

Результат должен выглядеть так:

FAIL line=6 expected_seq=6 got=7

Если убрать проверку последовательности, ошибка появится на один тест позже: запись 7 содержит дайджест записи 6, а проверяющий располагает дайджестом записи 5. Оставьте обе проверки. Несовпадение последовательности сразу показывает проблему оператору, а несовпадение предшественника демонстрирует нарушенную криптографическую связь.

Не утверждайте, что хеш-цепочка всегда обнаруживает удаление в середине. Она обнаруживает простое удаление, если злоумышленник не может переписать оставшуюся цепочку. Общедоступный алгоритм дайджеста позволяет любому вычислять новые хеши. Если кто-то может удалить запись 6, изменить поле предшественника в записи 7, пересчитать хеш записи 7, а затем повторить это до записи 12, перестроенный файл пройдёт проверку со своей новой вершиной.

Это не атака на коллизии и не взлом SHA-256. Это обычный пересчёт. Система приняла новую историю, потому что у неё не было защищённого свидетельства существования старой истории.

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

Поэтому тест удаления в середине должен иметь две версии:

  1. Удалить одну строку и оставить последующие байты без изменений. Цепочка должна завершиться ошибкой.
  2. Удалить одну строку и пересоздать все последующие дайджесты. Перестроенная цепочка должна не пройти сравнение с независимо сохранённой контрольной точкой.

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

Шифрование и целостность цепочки отвечают на разные вопросы

Проверяйте шифротекст, не раскрывая секреты
Проверяйте цепочку зашифрованных данных офлайн с помощью sp audit verify, без ключа хранилища.

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

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

При проектировании и составлении отчёта об инциденте разделяйте эти проверки:

  • Аутентификация записи показывает, была ли изменена одна зашифрованная запись.
  • Проверка цепочки показывает, следует ли каждая сохранившаяся запись за заявленным предшественником.
  • Сравнение с контрольной точкой показывает, доходит ли наблюдаемая история до ранее зафиксированной вершины.
  • Фиксация событий показывает, записала ли система действие в журнал вообще.

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

Модель аудита Sallyport полезна в этом контексте: журналы запусков агентов и отдельных вызовов поступают из одного зашифрованного журнала аудита с хеш-цепочкой, а sp audit verify может проверять цепочку шифротекста офлайн без ключа хранилища. Благодаря этому проверяющий может оценить непрерывность, не раскрывая учётные данные и данные действий, которые защищает журнал аудита.

Контрольную точку должно быть трудно переписать, а не просто скопировать

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

Для тестового стенда достаточно такого небольшого формата:

{
  "log_id": "agent-actions-test-a",
  "sequence": 12,
  "head": "8f...c2",
  "created_at": "2026-07-22T14:30:00Z",
  "format": "jsonl-chain-v1"
}

Не доверяйте одному полю created_at. Локальные часы полезны для сортировки и расследования, но злоумышленник, контролирующий машину, может контролировать и часы, и файл, в котором они указаны. Сила контрольной точки зависит от того, куда она попала и кто может её изменить.

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

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

Подпись контрольной точки помогает проверяющим понять, кто её выпустил. Подпись связывает данные контрольной точки с подписывающей стороной, но не делает эту сторону честной. Если один и тот же скомпрометированный процесс записывает журнал и подписывает заменяющие контрольные точки, граница доверия остаётся единственной. Разместите свидетеля там, где исходный автор журнала не сможет незаметно им управлять.

Защищённое от чтения хранилище меняет проверяемую модель угроз

Сохраняйте свидетельства каждого запуска агента
Журнал Sessions показывает запуски агентов и поддерживает мгновенный отзыв доступа, когда процесс нужно остановить.

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

Точно определяйте термин «защищённый от записи». Он должен означать, что компонент, отправляющий новую запись, не может по своему желанию читать или изменять предыдущие зашифрованные записи журнала. Это не значит, что диск не может выйти из строя, администратор не может удалить файл или операционная система не может быть скомпрометирована. Такой режим сужает один путь атаки: переписывание выбранной части истории после её просмотра.

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

Самая частая ошибка здесь - считать права доступа к хранилищу криптографическим якорем. Достаточно привилегированный злоумышленник может изменить права. Они всё равно важны: права уменьшают случайный ущерб и ограничивают список процессов, которые могут действовать. Но они не заменяют контрольную точку, пересёкшую границу, не контролируемую злоумышленником.

Проверяйте откат отдельно от усечения

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

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

Проверьте это напрямую:

  1. Сохраните audit.jsonl и его контрольную точку для 12 записей.
  2. Создайте ещё один тестовый файл с большим числом записей и сохраните его более новую контрольную точку.
  3. Замените новый файл копией из 12 записей.
  4. Проверьте восстановленный файл внутренним инструментом, а затем сравните его с более новой контрольной точкой.

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

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

Сделайте результаты проверки полезными во время расследования

Останавливайте действия на шлюзе хранилища
Шлюз хранилища запрещает любые действия, пока хранилище заблокировано, используя Secure Enclave и Touch ID в macOS.

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

Не записывайте расшифрованное содержимое в сообщение об ошибке. Инструмент проверки часто запускается в CI, пакете поддержки или захваченной сессии терминала. Если зашифрованная схема аудита раскрывает открытый текст при каждой ошибке проверки, инцидент с целостностью превращается в утечку данных.

Дисциплинированная реакция на ошибку проверки состоит из короткой последовательности:

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

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

Не обещайте больше, чем подтверждают свидетельства. Формулировка «Цепочка аудита проверена до последовательности 90 и не совпадает с сохранённой контрольной точкой последовательности 100» точна и убедительна. Фраза «Никто не менял журнал» - нет.

Важен тест на вашей настоящей границе доверия

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

Для Sallyport сохраните исходный зашифрованный журнал и запускайте sp audit verify на копии до и после каждого контролируемого изменения. Запишите, обнаруживает ли проверяющий внутренний разрыв, проходит ли проверку укороченная копия и показывает ли сохранённая вершина более короткую историю. Ответ на все три вопроса полезнее расплывчатого утверждения, что журнал защищён от вмешательства.

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

Вопросы и ответы

Что на самом деле доказывает проверка аудита по хеш-цепочке?

Это доказывает, что сохранившиеся записи образуют ту же последовательность байтов, которую проверяющий ожидает от доверенной начальной точки или контрольной точки. Это не доказывает, что никто не удалил корректный хвост, если свидетельство о более поздней вершине не сохранили отдельно.

Может ли хеш-цепочка обнаружить удаление записи из середины?

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

Может ли хеш-цепочка обнаружить удаление записей с конца?

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

Предотвращает ли шифрование журнала аудита его удаление?

Шифрование скрывает содержимое записей от тех, кто может читать файл, но не имеет материала для расшифровки. Оно не делает цепочку полной и не мешает злоумышленнику удалить непрозрачные записи с шифротекстом. Целостность и конфиденциальность - разные свойства.

Где хранить контрольные точки журнала аудита?

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

Доказывает ли проверенный журнал аудита, что каждое действие было записано?

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

Как безопасно проверить аудит Sallyport?

Перед тестом используйте точный проверяющий инструмент, который поддерживает формат журнала, а затем изменяйте только копии. Sallyport предоставляет sp audit verify, поэтому команда может проверять зашифрованный журнал шифротекста офлайн, не передавая проверяющему материалы хранилища. Оригинал должен оставаться нетронутым, а в заметках о тесте нужно зафиксировать команду, хеш файла, дату и результат.

Когда стоит использовать дерево Меркла вместо хеш-цепочки?

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

Что может сообщить проверяющий аудита после ошибки?

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

Что делать, если проверка зашифрованного аудита завершилась ошибкой?

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

Sallyport

Sallyport выполняет API-вызовы и SSH-команды за вашего ИИ-агента. Ключи остаются в локальном хранилище на вашем Mac; вы подтверждаете каждый запуск, и каждое действие попадает в запечатанный журнал.

© 2026 Sallyport · Открытый код по лицензии Apache-2.0 · Oleg Sotnikov