Инженерная статья

Контроль классов защиты файлов iOS в CI на облачном Mac

Контроль классов защиты файлов iOS в CI на облачном Mac

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

Сначала определите, какие данные нужно защищать

Для защиты файлов iOS не всегда следует выбирать максимально строгий класс. Сначала данные нужно классифицировать по тому, должен ли к ним сохраняться доступ после блокировки устройства, и только затем назначать класс защиты. Токены, офлайн-данные приложения и экспортированные пользователем конфиденциальные материалы обычно должны становиться недоступными после блокировки устройства. Очереди фоновой передачи, необходимое состояние push-уведомлений и задачи, которые обязаны продолжать работу во время блокировки, следует оценивать отдельно.

Тип данных Рекомендуемый базовый уровень Что проверять
Файл экспорта токена сеанса Полная защита Не должен читаться после блокировки
Конфиденциальные документы пользователя Полная защита Одинаковые атрибуты после создания, замены и восстановления
Состояние фоновых задач Доступно после первой разблокировки Убедиться, что доступ во время блокировки действительно необходим
Восстанавливаемый кеш В зависимости от риска Не должен содержать токены или персональные данные
База данных SQLite Как у бизнес-данных Одновременно проверять файлы WAL и SHM

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

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

Чтение конечных атрибутов файла с помощью XCTest

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

import XCTest

final class FileProtectionTests: XCTestCase {
    private func assertProtection(
        _ url: URL,
        equals expected: URLFileProtection,
        file: StaticString = #filePath,
        line: UInt = #line
    ) throws {
        XCTAssertTrue(
            FileManager.default.fileExists(atPath: url.path),
            "Expected file does not exist: \(url.path)",
            file: file,
            line: line
        )

        let values = try url.resourceValues(forKeys: [.fileProtectionKey])
        XCTAssertEqual(
            values.fileProtection,
            expected,
            "Unexpected protection at \(url.path): \(String(describing: values.fileProtection))",
            file: file,
            line: line
        )
    }

    func testSensitiveExportUsesCompleteProtection() throws {
        let root = FileManager.default.temporaryDirectory
        let url = root.appendingPathComponent("private-export.json")
        let payload = Data(#"{"status":"ready"}"#.utf8)

        try payload.write(to: url, options: [.atomic, .completeFileProtection])
        try assertProtection(url, equals: .complete)
    }
}

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

Ведите явный список исключений

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

Не забывайте о вспомогательных файлах SQLite

В режиме WAL SQLite может создавать файлы database.sqlite-wal и database.sqlite-shm. Проверка только основного файла приводит к ложному выводу о безопасности. Сначала тест должен выполнить реальную транзакцию записи и убедиться, что вспомогательные файлы появились, а затем применить ко всем трём файлам одинаковое утверждение.

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

  1. абсолютные пути к основной базе данных, WAL и SHM;
  2. фактический класс защиты каждого файла;
  3. шаг теста, в результате которого был создан файл;
  4. является ли отсутствие файла ожидаемым результатом очистки или признаком того, что тест фактически не выполнил запись.

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

Подключение контрольного шлюза на облачном Mac

Контрольный шлюз можно запускать как отдельный план тестирования, чтобы не выполнять каждый раз весь набор UI-тестов. Сначала зафиксируйте проект, Scheme, устройство симулятора и путь к пакету результатов, а затем используйте код завершения xcodebuild, чтобы определить, может ли конвейер продолжить работу.

set -euo pipefail

RESULT_DIR="${PWD}/artifacts"
RESULT_BUNDLE="${RESULT_DIR}/FileProtection.xcresult"

rm -rf "${RESULT_BUNDLE}"
mkdir -p "${RESULT_DIR}"

xcodebuild test \
  -project App.xcodeproj \
  -scheme SecurityRegression \
  -destination 'platform=iOS Simulator,name=iPhone 16' \
  -resultBundlePath "${RESULT_BUNDLE}" \
  -only-testing:AppSecurityTests/FileProtectionTests

Имя устройства должно соответствовать среде выполнения, установленной на конкретном узле. Не следует исходить из того, что все среды полностью идентичны. Перед запуском цель можно проверить командой xcrun simctl list devices available. В рабочем конвейере разрешённые устройства и версии системы следует закрепить в конфигурации. Если требуемая цель отсутствует, сборка должна завершаться ошибкой, а не незаметно переключаться на другую среду.

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

Ограничения симулятора и распространённые ложные выводы

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

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

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

Часто задаваемые вопросы

Достаточно ли проверить параметры защиты в исходном коде?

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

Заменяет ли тест в симуляторе проверку на устройстве?

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

VMMini M4

Запустите следующую задачу на выделенном физическом узле

Выберите узел и период оплаты, затем начните развертывание с фиксированной конфигурацией M4, 16GB RAM и 256GB SSD. Фактическая доступность отображается в консоли в реальном времени.

Арендовать облачный Mac сейчас