February 24, 2024

Detection-as-Сode. Строим пайплайн для конфига Sysmon

  1. Краткое введение в Detection-as-Code
  2. Пайплайн
  3. Выводы

Кон­фигура­цион­ный файл встро­енно­го в Windows средс­тва монито­рин­га Sysmon может раз­растать­ся до тысячи строк, которые опи­сыва­ют пра­вила отбо­ра регис­три­руемых в логах событий. Стан­дар­тный набор фун­кций Sysmon для про­вер­ки кон­фига неудо­бен и зат­рудня­ет работу ана­лити­ка. В этой статье я покажу, как сос­тавлять пай­плайн CI/CD для валида­ции кон­фига Sysmon и сде­лать невоз­можной потерю событий.

Глав­ная проб­лема при работе с Sysmon в том, что в слу­чае ошиб­ки ты получа­ешь текст с атри­бута­ми XML-фай­ла, как на кар­тинке ниже.

При­мер вывода ошиб­ки Sysmon

Ошиб­ки могут быть свя­заны с опе­чат­ками, некор­рек­тны­ми парамет­рами, новыми типами событий — все они спо­соб­ны при­вес­ти к наруше­нию про­цес­са монито­рин­га. Для авто­мати­чес­кой про­вер­ки мы мог­ли бы вос­поль­зовать­ся схе­мой XSD, которая не пос­тавля­ется вмес­те с Sysmon.

Для решения проб­лемы мы можем вос­поль­зовать­ся под­ходом Detection-as-Code (DaC). Для его реали­зации я вос­поль­зовал­ся воз­можнос­тями GitLab CI/CD.

КРАТКОЕ ВВЕДЕНИЕ В DETECTION-AS-CODE

Detection-as-Code — это под­ход вклю­чения прак­тик DevOps в деятель­ность ана­лити­ков SOC и CERT, а так­же инже­неров по внед­рению СЗИ и средств монито­рин­га. DaC поз­воля­ет струк­туриро­вать управле­ние пра­вила­ми обна­руже­ния, кон­фигура­цион­ными фай­лами, про­вес­ти тес­тирова­ние до их исполь­зования в конеч­ном про­дук­те.

В боль­шинс­тве слу­чаев для соз­дания нового пра­вила или редак­тирова­ния сущес­тву­юще­го ана­лити­ку необ­ходимо открыть встро­енный редак­тор исполь­зуемо­го про­дук­та. Изме­нения, вно­симые в поль­зователь­ском интерфей­се, труд­но и неудоб­но отсле­живать. Под­ход DaC поз­воля­ет быть уве­рен­ным, что мы ничего не сло­маем. В про­цес­се CI/CD соз­дава­емый кон­тент тес­тиру­ется до того, как он будет дос­тавлен в целевую сис­тему.

ПАЙПЛАЙН

Этапы

Мой пай­плайн вклю­чает пять эта­пов:

  1. ".pre" — сбор­ка Docker-кон­тей­нера.
  2. build — сбор­ка кон­фига из модулей для нуж­ной Sysmon вер­сии схе­мы.
  3. test-schema — тес­тирова­ние получен­ных кон­фигов на соот­ветс­твие схе­ме Sysmon.
  4. test-on-windows — тес­тирова­ние кон­фигов на хос­тах (при необ­ходимос­ти мож­но исполь­зовать Windows 10 и Windows 7, если нужен кон­фиг для легаси).
  5. deploy — дос­тавка кон­фига до поль­зовате­лей.
При­мер вывода ошиб­ки Sysmon

Так выг­лядит начало фай­ла .gitlab-ci.yml:

stages: - ".pre" - build - test-schema - test-on-windows - deploy workflow: rules: - changes: - templates/*.xml

Клю­чевое сло­во stages опре­деля­ет эта­пы в пай­плай­не, а workflow — пра­вило, опи­сыва­ющее, ког­да он будет запус­кать­ся. Мой пай­плайн запус­кает­ся толь­ко при изме­нении модулей кон­фига.

Нулевой этап. Сборка образа Docker

Для сбор­ки обра­за Docker я исполь­зую под­ход Docker-in-Docker. Docker-файл так­же находит­ся в репози­тории. На этом эта­пе мы уста­нав­лива­ем Ansible, pywinrm и кол­лекцию Ansible Community.Windows в собира­емый образ.

FROM python:3.12-bookworm LABEL maintainer="d3f0x0" RUN apt-get update && apt-get upgrade -y && pip install --upgrade pip && apt-get install ansible -y && pip3 install pywinrm && ansible-galaxy collection install community.windows && apt-get clean VOLUME [ "/data" ] WORKDIR /data CMD ["ansible-playbook", "--version"]

Job для GitLab выг­лядит вот так:

build-docker: stage: ".pre" image: docker: dind tags: - docker services: - name: docker:dind script: - echo -n "$CI_REGISTRY_PASSWORD" | docker login -u $CI_REGISTRY_USER $CI_REGISTRY --password-stdin - docker pull "$CI_REGISTRY_IMAGE:latest" || true - > docker build --pull --cache-from $CI_REGISTRY_IMAGE:latest --label "org.opencontainers.image.title=$CI_PROJECT_TITLE" --label "org.opencontainers.image.url=$CI_PROJECT_URL" --label "org.opencontainers.image.created=$CI_JOB_STARTED_AT" --label "org.opencontainers.image.revision=$CI_COMMIT_SHA" --label "org.opencontainers.image.version=$CI_COMMIT_REF_NAME" --tag $CI_REGISTRY_IMAGE:$CI_COMMIT_SHA . - docker push $CI_REGISTRY_IMAGE:$CI_COMMIT_SHA

В резуль­тате образ будет добав­лен в реестр Docker соот­ветс­тву­юще­го про­екта GitLab. Его мы будем исполь­зовать на осталь­ных эта­пах.

Первый этап. Билд конфига

Пер­вый шаг в обес­печении надеж­ности кон­фига Sysmon — это про­вер­ка струк­туры XML-фай­ла. XML дол­жен быть валид­ным, что­бы Sysmon кор­рек­тно интер­пре­тиро­вал кон­фиг.

Я решил раз­делить свой кон­фиг по модулям, которые может регис­три­ровать драй­вер Sysmon (ProcessCreate, NetworkConnection и так далее). Этот спо­соб исполь­зует­ся в репози­тории sysmon-modular.

При­мер вывода ошиб­ки Sysmon

Что­бы мож­но было работать с раз­ными вер­сиями Sysmon, я пре­дус­мотрел вари­ант, ког­да ито­говый кон­фиг собира­ется на осно­ве схем, пос­тавля­емых вмес­те с Sysmon.

Со­бира­ет кон­фиг скрипт merge_module.py, написан­ный на Python. Так­же исполь­зует­ся шаб­лон Jinja2 (config.xml.j2).

<Sysmon schemaversion="{{ schemaversion }}"> <HashAlgorithms>*</HashAlgorithms> <DnsLookup>True</DnsLookup> <CheckRevocation>True</CheckRevocation> <ArchiveDirectory>sysmon</ArchiveDirectory> <EventFiltering> {% for module in modules_files %} {% set event = module.split('.') %} {% if event[0] in event_schema %} {% include module %} {% endif %} {% endfor %} </EventFiltering></Sysmon>

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

SCHEMA_DIR = os.path.join(os.getcwd(),"schemas") MODULES_DIR = os.path.join(os.getcwd(),"templates") SYSMON_CONFIG_NAME = "config" if not os.path.isdir("output"): os.mkdir("output") try: for file in os.listdir(SCHEMA_DIR): schema = SysmonSchema(os.path.join(SCHEMA_DIR, file)) schemaEvents = (schema.events.keys()) environment = Environment(loader=FileSystemLoader(MODULES_DIR),trim_blocks=True, lstrip_blocks=True) template = environment.get_template("config.xml.j2") configFilename = os.path.join("output",f{schema.binaryversion}_{SYSMON_CONFIG_NAME}.xml") content = template.render(schemaversion=schema.schemaversion, modules_files=os.listdir(MODULES_DIR), event_schema=list(schema.events.keys())) with open(configFilename, "w") as obj: obj.write(content) except FileExistsError: print(f"ERROR - File not found") except Exception as e: print(f"ERROR - New exeception - {e}")%

Job в pipeline GitLab выг­лядит сле­дующим обра­зом:

merge_configs: image: CI_REGISTRY_IMAGE:$CI_COMMIT_SHA stage: build before_script: - pip install -r requirements.txt script: - python3 merge_module.py artifacts: paths: - output/* expire_in: 30 min

Об­рати вни­мание на клю­чевое сло­во artifacts, где мы опре­деля­ем, что все фай­лы из дирек­тории output/ будут переда­вать­ся меж­ду джо­бами и мы смо­жем исполь­зовать их пов­торно.

Ре­зуль­тат: соб­ранный кон­фиг Sysmon для соот­ветс­тву­ющих схем.

Пос­ле про­вер­ки струк­туры XML нуж­но про­верить парамет­ры и зна­чения в самом кон­фиге.

Второй этап. Тестирование собранных конфигов на соответствие схемам

Я поль­зуюсь нес­коль­кими схе­мами и для каж­дой соз­даю отдель­ный job на эта­пе test-schema.

test-config-schema-16: needs: [merge_configs] stage: test-schema image: CI_REGISTRY_IMAGE:$CI_COMMIT_SHA tags: - docker extends: .before_script_schema script: - python sysmonvalidate/sysmonvalidate.py output/16_config.xml schemas/16_schema.xml

Для умень­шения объ­ема кода я при­меняю атри­бут extends, который поз­воля­ет пов­торно исполь­зовать код, в моем слу­чае before_script.

.before_script_schema: before_script: - git submodule init - git submodule update

На эта­пе merge_configs мы ука­зали арте­фак­ты и кон­фиги Sysmon, которые валиди­руем на соот­ветс­твие схе­мам при помощи скрип­та sysmonvalidate.py.

python sysmonvalidate/sysmonvalidate.py output/16_config.xml schemas/16_schema.xml

Да­вай пос­мотрим, что про­веря­ется в текущей вер­сии sysmonvalidate.py.

Кор­рек­тность вер­сии кон­фига Sysmon и вер­сии схе­мы:

if config_schemaversion > float(schema.schemaversion): raise ConfigError(f"The configuration version is higher than the schema version: " f"{config_schemaversion} > {schema.schemaversion}")

Кор­рек­тность наиме­нова­ния опций:

config_options = [elem.tag for elem in root if elem.tag != 'EventFiltering'] for options in config_options:if options not in schema.get_schema_options(): raise ConfigError(f"Correctness of the names of the configuration file options.\nSysmon -> " f"ERROR: {options}\n")

Зна­чение атри­бута groupRelation:

for rulegroup in rule_group_element: next_object = get_next_object(rulegroup) if rulegroup.attrib['groupRelation'] not in ['and', 'or']: raise ConfigError(f"Values of the groupRelation attribute of the RuleGroup element.\n" f"RuleGroup -> groupRelation: {next_object}\n")

Да­лее про­веря­ются:

  • наз­вание пра­вил филь­тра­ции, нап­ример Process Create;
  • зна­чение атри­бута onmatch, в котором ука­зыва­ется филь­тр для отбо­ра событий ProcessCreate onmatch="exclude"
  • дан­ные подат­рибута эле­мен­та event, нап­ример FileCreateTime;
  • ис­поль­зуемые фун­кции филь­тра­ции, нап­ример: is, is not, contains, contains any, image.

for objectrulegroup in rulegroup.findall(f".//{next_object.tag}"): # Check Names of filtering events if objectrulegroup.tag not in schema.events: raise ConfigError(f"Names of filtering events.\nRuleGroup -> ERROR: {next_object.tag}") # Check Values of the onmatch attribute of the filtering events if objectrulegroup.attrib['onmatch'] not in ['exclude', 'include']: raise ConfigError(f"Values of the onmatch attribute of the filtering events\nRuleGroup -> {next_object.tag} -> onmatch") flagRuleName = False for rule in objectrulegroup.iter(): if not flagRuleName: flagRuleName = True continue if rule.tag == "Rule": if rulegroup.attrib['groupRelation'] not in ['and', 'or']: raise ConfigError(f"Values of the groupRelation attribute of the Rule element.\n" f"Rule -> groupRelation: {next_object}\n") continue # Sub-element data of the event element if rule.tag not in schema.events[next_object.tag] and rule.tag != "Rule": raise ConfigError(f"Sub-element data of the event element\nRuleGroup -> {next_object.tag} -> ERROR: {rule.tag} = {rule.text}") # Used filters of the data element if not "condition" in rule.attrib: raise ConfigError(f"Elements without condition.\n {next_object.tag} -> {rule.tag} -> {rule.text}") if rule.attrib['condition'] not in schema.filters : raise ConfigError(f"Used filters of the data element.\nRuleGroup -> {next_object.tag} -> {rule.tag} -> {rule.attrib['condition']} " f"= {rule.text}")

Что не про­веря­ется — это типы дан­ных.

Ре­зуль­тат: кон­фиг про­верен на соот­ветс­твие схе­мам.

Третий этап. Тестирование на хостах

Как говорит­ся, тес­тов мало не быва­ет, поэто­му на эта­пе test-on-windows тес­тирова­ние про­водит­ся пря­мо на хос­тах. Я исполь­зую нес­коль­ко вир­туаль­ных машин, нес­коль­ко вер­сий Sysmon (их мож­но най­ти в Internet Archive) и роль Ansible.

INFO

Я опти­мизи­ровал роль Ansible, которую нашел на GitHub под свою задачу, ее мы рас­смот­рим далее.

Job выг­лядит сле­дующим обра­зом:

test-windows-16: stage: test-on-windows needs: [merge_configs, test-config-schema-16] image: CI_REGISTRY_IMAGE:$CI_COMMIT_SHA tags: - docker script: - cp output/16_config.xml .ansible/ansible-role-sysmon/files/config.xml - ansible-playbook -vvv -i .ansible/hosts.yml .ansible/main.yml -e ansible_password=$ANSIBLE_PASSWORD allow_failure: true

В job test-windows-16 запус­кает­ся плей­бук Ansible, который выпол­няет сле­дующие тас­ки.

Соз­дает дирек­торию, в которую копиру­ются фай­лы для будущих эта­пов:

- name: (Windows) Create installation directory ansible.windows.win_file: path: "{{ sysmon_install_path }}" state: directory

Про­веря­ет, уста­нов­лен ли Sysmon:

- name: (Windows) Check if sysmon is installed ansible.windows.win_service: name: "{{ sysmon_servicename }}" register: sysmon_installed ignore_errors: true

Уда­ляет Sysmon, если он уста­нов­лен:

- name: (Windows 7) Uninstall sysmon ansible.windows.win_command: "{{ sysmon_servicename }} -u" args: chdir: "{{ sysmon_install_path }}\" when: - sysmon_installed.exists - ansible_distribution_major_version | int == 6- name: (Windows >7) Uninstall sysmon ansible.windows.win_command: "{{ sysmon_servicename }} -u" args: chdir: "{{ sysmon_install_path }}\" when: - sysmon_installed.exists - ansible_distribution_major_version | int > 6

Ко­пиру­ет файл Sysmon:

- name: (Windows 7) Upload sysmon sysmon binary ansible.windows.win_copy: src: files/14/{{ sysmon_binary }} dest: "{{ sysmon_install_path }}\\Sysmon64.exe" when: - ansible_distribution_major_version | int == 6 - name: (Windows >7) Upload sysmon sysmon binary ansible.windows.win_copy: src: files/15/{{ sysmon_binary }} dest: "{{ sysmon_install_path }}\\Sysmon64.exe" when: - ansible_distribution_major_version | int > 6

Ко­пиру­ет кон­фигура­цион­ный файл и Eula.txt:

- name: (Windows) Upload Eula.txt ansible.windows.win_copy: src: files/Eula.txt dest: "{{ sysmon_install_path }}\\Eula.txt" - name: (Windows) Upload sysmon configuration ansible.windows.win_copy: src: files/{{ sysmon_config }} dest: "{{ sysmon_install_path }}\\sysmonconfig.xml" - name: (Windows 7) Install sysmon ansible.windows.win_command: "{{ sysmon_binary }} -i sysmonconfig.xml -accepteula" args: chdir: "{{ sysmon_install_path }}" when: - ansible_distribution_major_version | int == 6

Ус­танав­лива­ет Sysmon:

- name: (Windows >7) Install sysmon ansible.windows.win_command: "{{ sysmon_binary }} -i sysmonconfig.xml -accepteula" args: chdir: "{{ sysmon_install_path }}" when: - ansible_distribution_major_version | int > 6 - name: (Windows 7) Clean uninstall sysmon ansible.windows.win_command: "{{ sysmon_servicename }} -u" args: chdir: "{{ sysmon_install_path }}\" when: - ansible_distribution_major_version | int == 6

Уда­ляет Sysmon и заг­ружен­ные фай­лы:

- name: (Windows >7) Clean uninstall sysmon ansible.windows.win_command: "{{ sysmon_servicename }} -u" args: chdir: "{{ sysmon_install_path }}\" when: - ansible_distribution_major_version | int > 6- name: (Windows 7) Clean directory tools ansible.windows.win_file: path: C:\tools state: absent when: - ansible_distribution_major_version | int == 6 - name: (Windows >7) Clean directory tools ansible.windows.win_file: path: C:\tools state: absent when: - ansible_distribution_major_version | int > 6

Ре­зуль­тат: про­веде­но тес­тирова­ние на хос­те.

Четвертый этап. Доставка собранного конфига до получателя

Зак­лючитель­ный этап — это дос­тавка рабоче­го кон­фига. Выбор спо­соба дос­тавки зависит от метода, которым ты рас­простра­няешь Sysmon (GPO, SCCM, Ansible, PS1 и так далее).

Вот нес­коль­ко при­меров спо­собов дос­тавки:

  • заг­рузка акту­аль­ного кон­фига из репози­тория;
  • ко­пиро­вание кон­фига в SYSVOL;
  • рас­простра­нение через Ansible.

ВЫВОДЫ

Опи­сан­ный пай­плайн — это при­мер того, как мож­но орга­низо­вать про­цесс раз­работ­ки пра­вил детек­тирова­ния. Его мож­но улуч­шить, добавив:

  • соз­дание фай­ла MSI, содер­жащего кон­фиг, и исполня­емо­го фай­ла с помощью WiX Toolset;
  • ис­поль­зование пакетов;
  • ис­поль­зование релизов;
  • ис­поль­зование Terraform или Vagrant для управле­ния вир­туаль­ными машина­ми, на которых про­водят­ся тес­ты.

Эф­фектив­ная валида­ция кон­фигура­цион­ного фай­ла Sysmon тре­бует, по сути, двух тех­нологий: интегра­ции с сис­темами кон­тро­ля вер­сий и скрип­тов для валида­ции.