Работаю я SOC-аналитиком в большой компании. Сижу, разбираю очередной инцидент, заходит ко мне начальство и говорит: "Нам необходимо знать, какие ОС используются у нас в компании" - эти данные необходимы, чтобы мучить сисадминов с обновлением ОС. Та самая вечная борьба сисадминов и безопасников. В каждой большой компании ведется учет пк/серверов и ОС на них, но перебирать ~18000 устройств у меня желания не было, начал думать, как бы всё это автоматизировать.

О моей работе с JSON

После получения задачи, подумал о реализации с помощью Selenium, который я освоил на должности тестеровщика в предыдущей компании. Открываю pycharm, кладу руки на клавиатуру и ... И ничего! Сразу приходит мысль об авторизации, а указывать логин и пароль в коде - очень плохая затея, к тому же как я пройду 2FA? К счастью, KUMA, как и многие современные системы, имеет токен авторизации, т.е. нет необходимости оставлять учетные данные в коде, только отправить авторизационный токен в запросе. Но я таким никогда не занимался и понятия не имею, как авторизоваться с помощью токена. Задал вопрос Алисе, после чего понял, что лучше обратиться к документации, ибо Российский ИИ мне не помог. Прочитав документацию, приступил к написанию кода. Авторизация по токену оказалась очень легким занятием.

token = "my_token"
url = "my_api_url"
api_header={
  "Content-Type": "application/json",
  "Authorization": f"Bearer {token}"
}
# Далее необходимо сделать запрос
req = request.get(url, headers=api_header)
# Данные получены!

После этого я почувствовал себя гением, но продолжалось это чувство не долго... Оказалось, URL в консоле разработчика и для работы с API разные, о таком я не подумал и долго мучился. Коллеги подсказали нужную URL. После получения данных, я просто достаю их по ключам. Структуру json-ответа нашел также в документации. Для вашего удобства размещу её здесь. P. S. Я пытался 30 минут понять, как этот json читать.

Скрытый текст

type Response []Asset

 

type Asset struct {

ID string json:"id"

TenantID string json:"tenantID"

TenantName string json:"tenantName"

Name string json:"name"

FQDN string json:"fqdn"

IPAddresses []string json:"ipAddresses"

MACAddresses []string json:"macAddresses"

Owner string json:"owner"

OS *OS json:"os"

Software []Software json:"software"

Vulnerabilities []Vulnerability json:"vulnerabilities"

KICSRisks []*assets.KICSRisk json:"kicsVulns"

KSC *KSCFields json:"ksc"

Created string json:"created"

Updated string json:"updated"

}

 

type KSCFields struct {

NAgentID string json:"nAgentID"

KSCInstanceID string json:"kscInstanceID"

KSCMasterHostname string json:"kscMasterHostname"

LastVisible string json:"lastVisible"

}

 

type OS struct {

Name string json:"name"

Version uint64 json:"version"

}

 

type Software struct {

Name string json:"name"

Version string json:"version"

Vendor string json:"vendor"

}

 

type Vulnerability struct {

KasperskyID string json:"kasperskyID"

ProductName string json:"productName"

DescriptionUrl string json:"descriptionUrl"

RecommendedMajorPatch string json:"recommendedMajorPatch"

RecommendedMinorPatch string json:"recommendedMinorPatch"

SeverityStr string json:"severityStr"

Severity uint64 json:"severity"

CVE []string json:"cve"

ExploitExists bool json:"exploitExists"

MalwareExists bool json:"malwareExists"

}

 

type assets.KICSRisk struct {

ID int64 json:"id"

Name string json:"name"

Category string json:"category"

Description string json:"description"

DescriptionUrl string json:"descriptionUrl"

Severity int json:"severity"

Cvss float64 json:"cvss"

}

Далее просто доставал данные по ключам.

data = req.json()
data.get("name")
# И так далее, все, что мне было нужно

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

def recursive_extract(data, result=None):
        result = []
    
    if isinstance(data, dict):
        for key, value in data.items():
            if key == "тут достаю данные поля software":
                result.append(value)
            recursive_extract(value, result)
    
    return result

При чем тут KUMA?

В KUMA (Kaspersky Unified Monitoring and Analysis Platform) - SIEM-система, по мимо обычного просмотра событий, существует возможность просматривать устройства и установленное на них ПО. Что несомненно является плюсом.

Ухх... Какими только добрыми и недобрыми словами я не вспоминал разработчиков KUMA. А всё потому, что в веб-интерфейсе можно отфильтровать ПО так, как ты хочешь, т.е. отправить post запрос, и с api казалось бы всё то же самое, НО! Для работы с api в KUMA предусмотрен post запрос исключительно для создания актива (устройства), а мне нужен был фильтр по активам... Для наглядности посмотрите на изображение.

Работа через веб-интерфейс и api
Работа через веб-интерфейс и api

Также в KUMA нельзя посмотреть дистрибутив Linux. Система только может сказать, что то или иное устройство является Linux, но дистрибутива с версией не показывает.

О версионировании пакетов Linux

Обычное версионирование (семантическое): MAJOR.MINOR.PATCH (например: 2.36.105), где 2 - major, 36 - minor, 105 - patch. Major увеличивается при серьёзных несовместимых изменениях, minor при добавлении новых функций или обновлений безопасности, patch при исправле��ии багов, ошибок.

  • 1.0.0 - Стабильная версия.

  • 1.0.1 - Исправили опечатку или критический баг (Патч).

  • 1.1.0 - Добавили новый фильтр поиска (Минор).

  • 2.0.0 - Переписали систему авторизации, теперь старый метод не работает (Мажор).

Пример посложнее: 1.2.3-1ubuntu2. 1.2.3 - версия автора, не сложно догадаться где здесь major, minor, patch. 1 после тире - это номер сборки пакета (не код программы, а например: исправление путей установки). Суффикс ubuntu - говорит, что пакет собран специально для Ubuntu, а не взят напрямую из Debian (родительского дистрибутива). Цифра 2 означает, что Ubuntu делала модификации этого конкретного Debian-пакета дважды.

Более сложный пример: 2.2.6-2+deb10u9+ci1+astra1. 2.2.6-2 по аналогии с предыдущим примером. Знак + означает, что к оригинальной версии (2.2.6-2) что-то добавили. deb10 - маркер Debian 10. u9 - означает "update 9", девятое обновление этого пакета для Debian 10. ci - маркер, который означает, что пакет прошел через автоматизированную систему сборки и тестирования. Цифра 1 - означает номер сборки или итерации. astra - пакет адаптирован для Astra Linux. Цифра 1 после astra является первой версией адаптации под Astra Linux. ci1 относится к astra, т.е. его прогнали через автосборку команда Astra Linux (Скорее всего автосборка сделана командой Astra, в этом я не уверен, эксперты поправьте меня в комментариях).

Определения дистрибутивов Linux по пакетам

Я потратил большое кол-во времени, делая то, что придет в голову, не имея при этом никакой четкой структуры мысли, только идею. Мне нужно было продумать работу программы, в один момент я понял, что нужно ТЗ. Основной ид��ей было то, что алгоритм анализирует установленные пакеты на устройстве, чтобы определить:

  • Типу ОС (Windows/Linux/Unknown)

  • Конкретный дистрибутив (Ubuntu, Debian, ALT Linux и т.д.)

  • Версию дистрибутива

  • Уровень уверенности в определении

Опираясь на идею, я написал ТЗ. Приступив, я понял, что это не самое простое занятие, как козалось, и на продумывание логики ушло часа 2 с учетом.

Скрытый текст

1. ГИБКИЙ ФОРМАТ ВЫВОДА

  • Пользователь выбирает CSV или JSON

  • Опциональное разделение результатов по уровню уверенности

  • Два режима: с разделением (high/medium vs low confidence) и без

2. АЛГОРИТМИЧЕСКАЯ СУТЬ

2.1. Простое определение типа ОС

  • Windows → если в os.name есть "windows"

  • Linux → если в os.name есть "linux" или os равно null

  • Unknown → во всех остальных случаях

2.2. Сложная логика для Linux, система весов:

  • 150 баллов: Уникальные системные пакеты (ubuntu-release, alt-release)

  • 20 баллов: Менеджеры пакетов (apt, yum, rpm)

  • 15 баллов: Суффиксы в версиях (.ubuntu, .alt, .el8)

  • 10 баллов: Специфичные пакеты дистрибутива

  • 5 баллов: Косвенные признаки

2.3. ПОДДЕРЖИВАЕМЫЕ ДИСТРИБУТИВЫ

  • Ubuntu, Debian, Linux Mint

  • ALT Linux, Astra Linux

  • Fedora, CentOS, RHEL, RedOS

  • Arch Linux, openSUSE

2.4. ЛОГИКА ОПРЕДЕЛЕНИЯ

  • Сканирование пакетов → начисление баллов

  • Подсчет суммы для каждого дистрибутива

  • Выбор победителя

  • Разрешение конфликтов при равных баллах

2.5. ОПРЕДЕЛЕНИЕ ВЕРСИИ

  • Из системных пакетов (наиболее надежно)

  • Из суффиксов (.el8 → версия 8)

  • По кодовым именам (focal, bionic для Ubuntu)

2.6. УРОВЕНЬ УВЕРЕННОСТИ

  • High: ≥150 баллов или ≥50 баллов

  • Medium: 20-49 баллов

  • Low: <20 баллов или данные неполные

3. КЛЮЧЕВЫЕ ОСОБЕННОСТИ

3.1. ОБРАБОТКА НЕПОЛНЫХ ДАННЫХ

  • Работает даже при os: null

  • Обрабатывает пустой массив software

  • Использует резервные методы при недостатке данных

3.2. ИЕРАРХИЯ ПРИОРИТЕТОВ

  • Уникальные системные пакеты

  • Суффиксы в версиях

  • Менеджеры пакетов

  • Специфичные пакеты

  • Косвенные признаки

3.3. РАЗРЕШЕНИЕ КОНФЛИКТОВ

  • При равных баллах: приоритет системным пакетам

  • Далее: приоритет менеджерам пакетов

  • Затем: количество специфичных пакетов

После формирования ТЗ спросил у нейронки о признаках дистрибутивов. Вот, что она мне выдала:

Скрытый текст

{
"Ubuntu": {
"release_packages": ["ubuntu-release", "lsb-release-ubuntu"],
"package_managers": ["apt", "dpkg"],
"version_suffixes": [".ubuntu", "+ubuntu", "ubuntu"],
"specific_packages": ["ubuntu-advantage-tools", "ubuntu-minimal"],
"code_names": ["bionic", "focal", "jammy", "trusty", "xenial"],
"exclusive_suffixes": [".ubuntu", "+ubuntu"]
},
"Debian": {
"release_packages": ["debian-release"],
"package_managers": ["apt", "dpkg"],
"version_suffixes": [".deb", "+deb", "debian"],
"specific_packages": ["debian-archive-keyring"],
"code_names": ["buster", "bullseye", "bookworm", "sid"],
"exclusive_suffixes": [".deb", "+deb", "debian"]
},
"Linux Mint": {
"release_packages": ["mint-release", "linuxmint-release"],
"package_managers": ["apt", "dpkg"],
"version_suffixes": [".mint", "mint"],
"specific_packages": ["mint-common", "mintdrivers"],
"exclusive_suffixes": [".mint", "mint"]
},
"ALT Linux": {
"release_packages": ["alt-release", "altlinux-release", "alt-os-release"],
"package_managers": ["apt-rpm", "rpm-alt"],
"version_suffixes": [".alt", "+alt", "alt"],
"specific_packages": ["systemd-alt", "apt4rpm", "altlinux-repos", "Sisyphus"],
"exclusive_suffixes": [".alt", "+alt"]
},
"Astra Linux": {
"release_packages": ["astra-version", "astra-release"],
"package_managers": ["dpkg", "apt"],
"version_suffixes": [".astra", "astra", "smolensk"],
"specific_packages": ["astra-common", "astra-security"],
"exclusive_suffixes": [".astra", "astra"]
},
"Fedora": {
"release_packages": ["fedora-release"],
"package_managers": ["dnf", "yum", "rpm"],
"version_suffixes": [".fc", ".fedora"],
"specific_packages": ["fedora-obsolete-packages"],
"exclusive_suffixes": [".fc", ".fedora"]
},
"CentOS": {
"release_packages": ["centos-release"],
"package_managers": ["yum", "rpm"],
"version_suffixes": [".el", ".centos"],
"specific_packages": ["centos-release", "centos-logos"],
"exclusive_suffixes": [".centos"]
},
"RHEL": {
"release_packages": ["redhat-release"],
"package_managers": ["yum", "rpm"],
"version_suffixes": [".el", ".rhel"],
"specific_packages": ["redhat-release", "rhel-docs"],
"exclusive_suffixes": [".rhel"]
},
"RedOS": {
"release_packages": ["redos-release"],
"package_managers": ["yum", "rpm", "dnf"],
"version_suffixes": [".redos", "redos", ".el"],
"specific_packages": ["redos-release", "redos-docs"],
"exclusive_suffixes": [".redos", "redos"]
},
"Arch Linux": {
"release_packages": ["arch-release"],
"package_managers": ["pacman"],
"version_suffixes": [".arch", "arch"],
"specific_packages": ["archlinux-keyring"],
"exclusive_suffixes": [".arch", "arch"]
},
"openSUSE": {
"release_packages": ["opensuse-release"],
"package_managers": ["zypper", "yast"],
"version_suffixes": [".suse", ".opensuse"],
"specific_packages": ["yast2", "opensuse-build-key"],
"exclusive_suffixes": [".suse", ".opensuse"]
}

С этими данными уже можно было работать. Конечно, в процессе тестирования и доработки программы туда были добавлены новые признаки.

Основная функция анализа дистрибутивов. Тут все просто, куча циклов которые проверяют принадлежность пакетов и добавляют дистрибутивам баллы.

def analyze_linux(self, device: Dict) -> Dict:

    software = device.get('software', [])
    
    # Инициализация счетчиков
    scores = {distro: 0 for distro in DISTRO_RULES}
    specific_counts = {distro: 0 for distro in DISTRO_RULES}
    suffix_counts = {distro: 0 for distro in DISTRO_RULES}
    
    # Анализ каждого пакета
    for pkg in software:
        if not isinstance(pkg, dict):
            continue
            
        pkg_name = pkg.get('name', '').lower()
        pkg_version = pkg.get('version', '').lower()
        
        # 1. Проверка release-пакетов (150 баллов)
        for distro, rules in DISTRO_RULES.items():
            for release_pkg in rules['release_packages']:
                if release_pkg in pkg_name:
                    scores[distro] += WEIGHTS['release_package']
        
        # 2. Проверка менеджеров пакетов (20 баллов)
        for distro, rules in DISTRO_RULES.items():
            for manager in rules['package_managers']:
                if manager in pkg_name:
                    scores[distro] += WEIGHTS['package_manager']
        
        # 3. Проверка суффиксов версий (15 баллов)
        for distro, rules in DISTRO_RULES.items():
            for suffix in rules['version_suffixes']:
                if suffix in pkg_version:
                    scores[distro] += WEIGHTS['version_suffix']
                    suffix_counts[distro] += 1
        
        # 4. Проверка специфичных пакетов (10 баллов)
        for distro, rules in DISTRO_RULES.items():
            for specific_pkg in rules['specific_packages']:
                if specific_pkg in pkg_name:
                    scores[distro] += WEIGHTS['specific_package']
                    specific_counts[distro] += 1
    
    # 5. Косвенные признаки (5 баллов)
    for distro in scores:
        if suffix_counts[distro] >= 3:
            scores[distro] += WEIGHTS['indirect']
    
    # Выбор победителя
    result = self._select_distro_winner(scores, specific_counts, device)
    
    # Определение версии
    if result.get('os_distro') != 'не удалось определить ОС':
        version = self._determine_linux_version(device, result['os_distro'])
        result['os_version'] = version if version else 'не удалось определить версию'
    
    return result

Далее функция с выбором победителей. Логика - проста, несколько дистрибутивов набрало одинаковое кол-во баллов - необходимо выбрать только 1.

  • Уровень 0: обнаружен уникальный release-пакет (не встречающийся у других дистрибутивов) → немедленный return.

  • Уровень 1: дистрибутивы, набравшие ≥150 баллов (release-пакеты). Если один – победа, если несколько – разрешение по максимальному счёту и дополнительным признакам (Например lsb-release).

  • Уровень 2: проверка специальных случаев через checkspecial_cases() (например, комбинация apt+rpm для ALT Linux, суффикс .astra для Astra Linux и т.д.).

  • Уровень 3: обычный максимум баллов. При равенстве применяются последовательные критерии: уникальные признаки → количество специфичных пакетов → комбинации менеджеров → алфавитный порядок.

  • Уровень 4: fallback на поле os.name.

def _select_distro_winner(self, scores: Dict, specific_counts: Dict, device: Dict) -> Dict:
    default_result = {'os_type': 'linux', 'os_distro': 'не удалось определить ОС',
                      'os_version': 'не удалось определить версию', 'confidence': 'low',
                      'detection_method': 'none'}
    try:
        # ... подготовка all_packages ...

        # Приоритет 0: уникальные release-пакеты
        for pkg_name, _ in all_packages:
            for distro, rules in DISTRO_RULES.items():
                for release_pkg in rules.get('release_packages', []):
                    if release_pkg in pkg_name:
                        is_unique = True
                        for other_distro, other_rules in DISTRO_RULES.items():
                            if distro != other_distro:
                                if any(release_pkg in other_release
                                       for other_release in other_rules.get('release_packages', [])):
                                    is_unique = False
                                    break
                        if is_unique:
                            return {'os_type': 'linux', 'os_distro': distro,
                                    'confidence': 'high', 'detection_method': 'unique_release_package'}

        # Приоритет 1: release-пакеты (≥150 баллов)
        release_candidates = [(d, s) for d, s in scores.items() if s >= WEIGHTS['release_package']]
        if release_candidates:
            # ... логика выбора ...

        # Приоритет 2: специальные случаи
        special = self._check_special_cases(all_packages, scores)
        if special:
            return special

        # Приоритет 3: обычный максимум
        max_score = max(scores.values()) if scores else 0
        if max_score > 0:
            top = [d for d, s in scores.items() if s == max_score]
            # ... разрешение конфликтов (уникальные признаки, специфичные пакеты) ...

    except Exception as e:
        print(f"Ошибка в _select_distro_winner: {e}")
        return default_result
    return default_result

Функция check_special_cases() (из приоритета 2) - по сути это определение по суффиксам. Вот пример для astra linux, с остальными дистрибутивами аналогично.

def _check_special_cases(self, all_packages: List[Tuple[str, str]], scores: Dict) -> Optional[Dict]:
    package_names = [pkg_name for pkg_name, _ in all_packages]
    package_versions = [pkg_version for _, pkg_version in all_packages]
    
    has_astra_package = any('astra-' in pkg_name for pkg_name in package_names)
    has_astra_suffix = any('.astra' in pkg_version for pkg_version in package_versions)
    if has_astra_suffix or has_astra_package:
        astra_score = scores.get('Astra Linux', 0)
        if astra_score > 0:
            return {'os_type': 'linux', 'os_distro': 'Astra Linux',
                    'confidence': 'medium' if scores['Astra Linux'] >= 20 else 'low',
                    'detection_method': 'special_case_astra_linux'}

Функция определения версии. После выбора дистрибутива вызывается determinelinux_version(), которая делегирует работу специализированным функциям: determine_ubuntu_version(), determine_debian_version(), determine_alt_version(), determine_astra_version() и т.д. Приведу пример для astra linux, с остальными дистрибутивами ситуация очень похожа, изменяются по большей части только паттеры для поиска версий.

def _determine_astra_version(self, software: List[Dict]) -> Optional[str]:
    for pkg in software:
        if not isinstance(pkg, dict):
            continue
        pkg_name = pkg.get('name', '')
        pkg_version = pkg.get('version', '')
        if any(release in pkg_name for release in ['astra-release', 'astra-version']):
            if pkg_version:
                # Ищем паттерн +vX.X.X
                version_match = re.search(r'\+v?(\d+(?:\.\d+)+)', pkg_version)
                if version_match:
                    extracted = version_match.group(1)
                    if self._looks_like_astra_version(extracted):
                        return extracted
                # ... SE версии
    # ... поиск по суффиксам .astra
    return None

Заключение

Код не определяет дистрибутивы на 100%. На практике у меня получилось определить правильно 95-98% дистрибутивов.

Это был мой первый опыт использования python для подобных целей. Да, местами я анализирую код и понимаю, где-то можно оптимизировать, где-то доработать. Но я пользуюсь золотым правилом: "Работает - не трогай!". Может существует другой способ определения дистрибутивов linux, но я до него не додумался, если знаете такой, пишите в комментариях.