FSEvents: Разбираемся с мониториногом событий файловой системы в macOS


Иллюстрация работы механизма FSEvents

File System Events (далее FSEvents) - механизм, доступный в macOS начиная с версии 10.5, который позволяет подписываться на уведомления об изменениях в структуре папок, а также их содержимого. Он используется многими системными утилитами, например Time Machine. Стоит отметить, что мониторинг происходит на уровне директории: иными словами придет уведомление, что в директории изменился некий файл, но конкретной информации об изменениях в нём содержаться не будет. Для более детального мониторинга придётся спускаться на уровень ниже и использовать kqueues, либо писать собственный kext. Поэтому для написания собственного антивируса такой механизм не подойдет, а вот для своего аналога Dropbox, или чего-то подобного - вполне.

Механизм FSEvents состоит из трёх базовых частей:

  • Код ядра транслирует события в пространство пользователя;
  • Демон fseventsd обрабатывает эти события и рассылает уведомления;
  • База данных, где хранятся записанные логи.

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

Если выполнить в терминале ls -la /, можно увидеть в корне раздела директорию .fseventsd, куда упомянутый демон пишет логи. Посмотреть содержимое файлов так просто не получится, для этой цели можно воспользоваться скриптом FSEventsParser, он поможет экспортировать их в текстовом формате, либо как базу данных SQLite. Второй вариант обычно предпочтительнее, так как он позволит использовать SQL-запросы для фильтрации событий (которых обычно больше миллиона).

Расположение директории .fseventsd

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

Может показаться, что исторические данные из FSEvents - не самая полезная информация, однако косвенно они могут помочь, например при исследовании атакованной системы. Так как малварь обычно чистит за собой следы, то записи о создании подозрительных plist’ов, например в /Library/LaunchDaemons, теоретически могут помочь идентифицировать поработавшего зловреда, что станет весомым артефактом в дальнейшем расследовании. Также по записям можно увидеть активность в домашней директории пользователя, файлы перемещенные в корзину, активность в интернете и многое другое. На iOS тоже можно получить данные из FSEvents, но для этого понадобится джейлбрейк.

Подписываемся на события программно

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

Подписываемся на поток событий файловой системы

Откроем Xcode и создадим новый проект, это будет Cocoa App (на вкладке macOS), язык выберем Swift. Создадим новый класс (File > New > File > macOS > Swift file) под названием FSEventsService и напишем следующий код:

class FSEventsService {
    /// Колбек в который будут приходить события
    var callback: ((FSEvent) -> Void)?

    /// Директории, которые мы будем мониторить
    let pathsToWatch: [String]

    /// Ссылка на поток событий от файловой системы
    private var stream: FSEventStreamRef!

    init(pathsToWatch: [String], callback: @escaping (FSEvent) -> Void) {
        self.pathsToWatch = pathsToWatch
        self.callback = callback
    }
}

Здесь мы просто объявили класс и создали несколько переменных. Теперь нам нужно запустить мониторинг событий, для этого определим следующий метод:

func start() {
    var context = FSEventStreamContext(version: 0,
                                       info: nil,
                                       retain: nil,
                                       release: nil,
                                       copyDescription: nil)
    context.info = Unmanaged.passUnretained(self).toOpaque()

    let flags = UInt32(kFSEventStreamCreateFlagUseCFTypes | kFSEventStreamCreateFlagFileEvents)

    stream = FSEventStreamCreate(kCFAllocatorDefault,
                                 eventCallback,
                                 &context,
                                 pathsToWatch as CFArray,
                                 FSEventStreamEventId(kFSEventStreamEventIdSinceNow),
                                 0,
                                 flags)

    FSEventStreamScheduleWithRunLoop(stream,
                                     CFRunLoopGetMain(),
                                     CFRunLoopMode.defaultMode.rawValue)
    FSEventStreamStart(stream)
}

API FSEvents - это C API, мы можем его использовать из Swift, но нам потребуется учесть несколько особенностей. Разберем код метода по частям. Сначала мы создаем контекст и в него записываем указатель на наш экземпляр класса. Это нужно для того, чтобы иметь возможность потом к нему обратиться из C-функции, которую мы передаём в качестве коллбека, куда будут приходить события.

Далее определяем 2 флага: kFSEventStreamCreateFlagUseCFTypes - говорит нашему потоку данных, чтобы он возвращал значения в типах Core Foundation, вместо чистых C-типов и kFSEventStreamCreateFlagFileEvents - просит передавать события на уровне файлов, а не на уровне директории (иначе будут приходить только уведомления об изменениях в директории, но не будет информации, что за файл изменился).

Теперь осталось создать поток событий и передать в него необходимую информацию, а именно:

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

После этого остаётся только добавить поток событий в RunLoop (часть инфраструктуры отвечающая за обработку асинхронных событий, приходящих в поток) и запустить мониторинг.

Обрабатываем полученные события

Реализация колбэка будет выглядеть так:

private let eventCallback: FSEventStreamCallback = {
    (stream: ConstFSEventStreamRef,
    contextInfo: UnsafeMutableRawPointer?,
    numEvents: Int,
    eventPaths: UnsafeMutableRawPointer,
    eventFlags: UnsafePointer<FSEventStreamEventFlags>,
    eventIds: UnsafePointer<FSEventStreamEventId>) in

    guard let contextInfo = contextInfo else { return }
    let mySelf = Unmanaged<FSEventsService>.fromOpaque(contextInfo).takeUnretainedValue()

    guard
        let callback = mySelf.callback,
        let paths = unsafeBitCast(eventPaths, to: NSArray.self) as? [String]
    else {
        return
    }

    for index in 0 ..< numEvents {
        let event = FSEvent(identifier: eventIds[index],
                            path: paths[index],
                            flags: eventFlags[index])
        callback(event)
    }
}

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

Структура с информацией о событии выглядит следующим образом:

struct FSEvent {
    let identifier: FSEventStreamEventId
    let path: String
    let flags: FSEventStreamEventFlags
}

Останавливаем получение событий

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

func stop() {
    FSEventStreamStop(stream)
    FSEventStreamInvalidate(stream)
    FSEventStreamRelease(stream)
    stream = nil
}

Вызов этого метода можно добавить, например, в deinit:

deinit {
    stop()
}

Использование класса

На этом написание простейшей обёртки закончено, мы теперь легко можем получить события от файловой системы, например:

let path = NSString(string: "~").expandingTildeInPath
fsEventsService = FSEventsService(pathsToWatch: [path]) { event in
    print("\(event.identifier) - \(event.path) - \(event.flags)")
}
fsEventsService.start()

После запуска в логе приложения можно будет увидеть строки с измененными файлами. Перед запуском нужно не забыть отключить Sandbox (выбрать проект в инспекторе слева, перейти на вкладку Capabilities, отключить App Sandox), иначе у приложения будет доступ к файловой системе только внутри песочницы.

События файловой системы, полученные в результате работы нашей обёртки для FSEvents API

Фильтрация событий

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

if eventFlags & UInt32(kFSEventStreamEventFlagItemCreated) != 0 {
    // Обрабатываем событие создания
}

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

Заключение

Сегодня мы рассмотрели реализацию собственной обертки над API FSEvents, если не считать взаимодействие с API на C, то использовать из Swift его довольно просто и не должно вызвать затруднений. На GitHub можно найти большое количество библиотек, схожих с тем, что мы реализовали сегодня. Пользоваться ими или нет, нужно решать исходя из конкретных задач: с одной стороны зачем писать лишний boilerplate, когда кто-то его уже написал за тебя, а с другой стороны в сложных решениях, всё равно приходится разбираться с внутренним устройством библиотек и докручивать их под специфичные нужды заказчика, так что может быть имеет смысл написать такой класс с нуля.

Пример использования нашего класса в простеньком GUI

Ссылки

Исходники примеров

Рекомендую почитать