Может ли быть надежной небезопасная система? Можно ли считать безопасной ненадежную систему? Безопасность имеет решающее значение для разработки и эксплуатации масштабируемых систем в реальных условиях, поскольку вносит важный вклад в качество, производительность и доступность продукта. В этой книге эксперты Google делятся передовым опытом, позволяющим разрабатывать масштабируемые и надежные системы, которые действительно будут безопасны.
Вам уже знакомы два бестселлера, написанные разработчиками из Google — «Site Reliability Engineering. Надежность и безотказность как в Google» и «Site Reliability Workbook: практическое применение», значит, вы понимаете, что только неуклонное следование жизненному циклу обслуживания позволяет успешно создавать, развертывать и поддерживать программные системы. Сейчас же мы предлагаем взглянуть на проектирование, реализацию и обслуживание систем с точки зрения практиков, специализирующихся на безопасности и надежности.
Мы уверены, что всем стоит задумываться об основах надежности и безопасности с самого начала процесса разработки и интегрировать эти принципы на ранних этапах жизненного цикла системы. Это важнейшая концепция, вокруг которой построен весь материал книги. В отрасли ведется множество дискуссий о том, что специалисты по защите информации все больше похожи на разработчиков ПО, а SR-инженеры и разработчики ПО больше напоминают специалистов по защите информации. Мы приглашаем вас присоединиться к обсуждению.
Когда мы говорим «вы» в книге, мы подразумеваем читателя, независимого от конкретной работы или уровня опыта. Эта книга бросает вызов традиционным представлениям о ролях специалистов. Она направлена на то, чтобы вы могли нести ответственность за безопасность и надежность на протяжении всего жизненного цикла продукта. Не нужно стараться использовать все методы, описанные здесь, в ваших конкретных обстоятельствах. Вместо этого мы советуем возвращаться к этой книге на разных этапах карьеры или по мере развития вашей организации. Помните, что идеи, которые на первый взгляд не казались ценными, могут получить новое значение.
Начните с глав 1 и 2, а затем можете читать главы, которые вас больше всего интересуют. Многие главы начинаются с предисловия или краткого обзора, в котором изложено следующее.
- Формулировка проблемы.
- На каком этапе жизненного цикла разработки ПО стоит применять описанные принципы и практики.
- Пересечения и/или компромиссы между надежностью и безопасностью, которые необходимо учитывать.
В каждой главе темы, как правило, упорядочены от фундаментальных до самых сложных. Углубленный анализ и специализированные темы мы обозначаем значком с изображением аллигатора.
В этой книге много инструментов или методов, которые считаются в профессиональной среде хорошими практиками. Не каждая идея подойдет для вашего конкретного случая использования, поэтому сначала оцените требования своего проекта и проектные решения, адаптированные к вашему конкретному ландшафту рисков.
Несмотря на то что это книга для самостоятельной работы, вы найдете ссылки на издания Site Reliability Engineering (https://landing.google.com/sre/sre-book/toc/index.html) и The Site Reliability Workbook (https://landing.google.com/sre/workbook/toc/), где эксперты из Google описывают, как именно надежность влияет на проектирование сервисов. Чтение этих книг может дать более глубокое понимание некоторых концептов, но читать их необязательно.
Мы надеемся, что вам понравится наша книга и что некоторая информация на этих страницах поможет вам повысить надежность и безопасность ваших систем.
Написание кода
В коде неизбежно будут ошибки. Но избежать распространенных уязвимостей безопасности и проблем с надежностью можно при помощи усиленных фреймворков и библиотек, устойчивых к таким проблемам.
В этой главе мы делимся шаблонами разработки ПО, которые нужно применять во время реализации проекта. Сначала мы рассмотрим пример с серверным RPC, изучим, как фреймворки помогают автоматически обеспечивать требуемые свойства безопасности и противостоять типичным антипаттернам надежности. Мы сосредоточимся на простоте кода — научимся контролировать накопление технического долга и при необходимости делать рефакторинг кодовой базы. В конце вас ждут советы о том, как правильно выбрать инструменты и использовать по максимуму языки разработки.
Безопасность и надежность не могут быть легко интегрированы в ПО. Поэтому важно учитывать их при разработке на самых ранних этапах. Добавление этих функций после запуска болезненно, менее эффективно и может потребовать изменения других важных предположений о кодовой базе (подробнее это описано в главе 4).
Первый и самый важный шаг в уменьшении проблем безопасности и надежности — обучение разработчиков. Но даже самые подготовленные инженеры могут ошибаться — эксперты по безопасности могут писать небезопасный код, а SR-инженеры могут пропускать проблемы с надежностью. Трудно одновременно учитывать множество соображений и компромиссов, связанных с созданием безопасных и надежных систем, особенно если вы ответственны и за создание ПО.
Не полагайтесь только на разработчиков для проверки кода на предмет безопасности и надежности. Вместо этого поручите SR-инженерам и экспертам по безопасности анализировать код и программные разработки. У такого подхода тоже есть недостатки. Проверки кода вручную не найдут всех пробелов, и ни один рецензент не обнаружит все потенциальные проблемы безопасности. В силу своего опыта или интересов рецензенты могут стремиться найти новые классы атак, проблемы высокого уровня в проектных решениях или интересные недостатки в криптографических протоколах. А вот просмотр сотен шаблонов HTML на наличие ошибок межсайтового скриптинга (XSS) или проверка логики обработки ошибок для каждого RPC в приложении — не самые интересные задачи.
Проверки кода могут найти не все уязвимости, но у них есть и другие преимущества. Развитая культура проверок побуждает разработчиков структурировать свой код так, чтобы можно было легко просматривать свойства безопасности и надежности. В этой главе мы обсуждаем стратегии, позволяющие сделать эти свойства очевидными для рецензентов и внедрения автоматизации в процесс разработки. Эти стратегии помогут разгрузить команду разработчиков. Тогда они смогут сосредоточиться на других вопросах. Это приведет к формированию культуры безопасности и надежности (см. главу 21).
Основы обеспечения безопасности и надежности
Как уже говорилось в главе 6, безопасность и надежность приложения зависят от заданных для домена инвариантов. Например, приложение защищено от атак с использованием внедрения SQL, если все его запросы к БД состоят только из управляемого разработчиком кода, а внешние входные данные передаются через привязки параметров запроса. Веб-приложение может предотвратить XSS-атаки, если весь пользовательский ввод, вставленный в HTML-формы, правильно экранирован или не позволяет встраивать любой исполняемый код.
РАСПРОСТРАНЕННЫЕ ИНВАРИАНТЫ БЕЗОПАСНОСТИ И НАДЕЖНОСТИ
Почти у любого многопользовательского приложения есть инварианты безопасности, определяющие, какие действия с данными пользователи могут производить. Каждое действие должно последовательно поддерживать эти инварианты. Для предотвращения каскадных сбоев в распределенной системе приложение должно следовать разумным политикам, таким как откат повторных попыток при сбое RPC. Точно так же, чтобы избежать повреждения памяти и проблем с безопасностью, программы на C++ должны обращаться только к допустимым местам в памяти.
В теории вы можете создать безопасное и надежное ПО, тщательно написав код приложения, поддерживающий эти инварианты. Но по мере роста числа желаемых свойств и размера кодовой базы это будет сделать почти невозможно. Неразумно ожидать от разработчика, что он станет экспертом во всех этих областях или будет постоянно сохранять бдительность при написании или обзоре кода.
При самостоятельном просмотре каждого изменения людям будет сложно поддерживать глобальные инварианты, ведь рецензенты не всегда могут отслеживать общий контекст. Если рецензенту нужно знать, какие параметры функции передаются пользователем, а какие аргументы содержат только контролируемые разработчиком доверенные значения, он должен быть знаком со всеми переходными вызывающими функциями. Рецензенты вряд ли смогут сохранить это состояние в долгосрочной перспективе.
Лучший подход — обеспечение безопасности и надежности в общих фреймворках, языках и библиотеках. В идеале библиотеки предоставляют только интерфейс, препятствующий написанию кода с общими классами уязвимостей безопасности. Несколько приложений могут использовать все библиотеки или фреймворки. Когда эксперты решают проблему, они избавляются от нее во всех приложениях, поддерживаемых фреймворком. Это позволяет лучше масштабировать такой инженерный подход. Вероятность проникновения будущих уязвимостей будет ниже, если вместо проверки вручную использовать централизованный защищенный фреймворк. Конечно, ни один фреймворк не может защитить от всех уязвимостей безопасности, и злоумышленники все еще могут обнаружить непредвиденный класс атак ошибки в реализациях фреймворков. Но если вы найдете новую уязвимость, ее можно будет устранить в одном месте (или нескольких), а не по всей кодовой базе.
Приведем конкретный пример: внедрение SQL (SQL injection, SQLI) занимает первое место в списках распространенных уязвимостей безопасности OWASP (https://oreil.ly/TnBaK) и SANS (https://oreil.ly/RWvPF). По нашему опыту, когда вы используете защищенную библиотеку данных, такую как TrustedSqlString (см. подраздел «Уязвимости SQL-внедрений: TrustedSqlString» далее в этой главе), эти типы уязвимостей становятся незначительными. Типы делают эти предположения явными и автоматически применяются компилятором.
Преимущества использования фреймворков
Большинство приложений имеют схожие строительные блоки для обеспечения безопасности (аутентификация и авторизация, ведение журнала, шифрование данных) и надежности (ограничение скорости, балансировка нагрузки, логика повторных попыток). Разработка и поддержка таких блоков с нуля для каждого сервиса дорого стоит и приводит к разным ошибкам в каждом отдельном сервисе.
Благодаря фреймворкам можно повторно использовать код. Чтобы не учитывать все влияющие на функциональность аспекты безопасности и надежности, разработчикам нужно только настроить конкретный блок. Например, разработчик может указать, какая информация из учетных данных входящего запроса важна для авторизации, не беспокоясь о достоверности этой информации, ведь она подтверждается фреймворком. Разработчик может указать и необходимые для регистрации данные, не беспокоясь о хранении или копировании. Фреймворки облегчают и распространение обновлений, так как достаточно применить обновление только в одном месте.
Используя фреймворки, вы повышаете производительность всех разработчиков в организации. Это способствует формированию культуры безопасности и надежности (см. главу 21). Группа экспертов эффективнее спроектирует и разработает строительные блоки для фреймворка, чем отдельная команда самостоятельно реализует функции безопасности и надежности. Если команда безопасности занимается криптографией, все остальные могут использовать эти знания. Разработчики, использующие фреймворки, могут не беспокоиться о внутренних деталях, а вместо этого сосредоточиться на бизнес-логике приложения.
Фреймворки сильнее повышают производительность, предлагая инструменты, с которыми легко интегрироваться. Они могут предоставлять инструменты, автоматически экспортирующие основные операционные метрики, такие как общее количество запросов, количество неудачных запросов с разбивкой по типу ошибки или задержке каждого этапа обработки. Вы можете воспользоваться этими данными для создания автоматических панелей мониторинга и оповещений для сервиса. Фреймворки упрощают интеграцию с инфраструктурой балансировки нагрузки. Поэтому сервис может автоматически перенаправлять трафик с перегруженных экземпляров или ускорять работу новых при большой нагрузке. В итоге построенные поверх фреймворков сервисы показывают более высокую надежность.
Использование фреймворков упрощает работу с кодом. Оно четко отделяет бизнес-логику от общих функций. Благодаря этому разработчики могут уверенно делать заявления о безопасности или надежности сервиса. В целом фреймворки снижают сложность — когда код между несколькими сервисами однороднее, легче следовать общепринятым практикам.
Необязательно всегда разрабатывать собственные фреймворки. Во многих случаях лучше повторно использовать существующие решения. Почти любой специалист по безопасности посоветует вам не проектировать и не внедрять свою криптографическую среду. Вместо этого вы можете взять хорошо зарекомендовавшую себя и широко используемую среду, такую как Tink (обсуждается в пункте «Пример: безопасные криптографические API и криптографический фреймворк Tink» в главе 6).
До использования конкретного фреймворка оцените его состояние безопасности. Мы предлагаем использовать активно поддерживаемые фреймворки и постоянно обновлять зависимости кода для добавления последних обновлений безопасности для любого кода, от которого зависит ваш собственный.
Рассмотрим практический пример, демонстрирующий преимущества фреймворков: фреймворк для создания серверов RPC.
Пример: фреймворк для серверов RPC
У многих серверов RPC схожая структура. Они обрабатывают специфичную для запросов логику и выполняют следующие действия:
- регистрация;
- аутентификация;
- авторизация;
- регулирование (ограничение скорости).
Вместо реализации этой функциональности для каждого отдельного сервера RPC используйте фреймворк, способный скрыть детали реализации этих строительных блоков. Тогда разработчики просто должны настроить каждый шаг в соответствии с потребностями своего сервиса.
На рис. 12.1 показана возможная архитектура фреймворка, основанная на предварительно определенных перехватчиках (interceptors), отвечающих за каждый из ранее упомянутых этапов. Вы можете использовать перехватчики и для пользовательских шагов. Каждый перехватчик определяет действие, которое должно быть произведено до и после выполнения конкретной логики RPC. Каждый этап может сообщить об ошибке, препятствующей дальнейшему внедрению перехватчиков. Но когда это происходит, следующие шаги каждого уже использованного перехватчика выполняются в обратном порядке. Фреймворк между перехватчиками может прозрачно выполнять дополнительные действия. Например, экспортировать статистику ошибок или метрики производительности. Эта архитектура приводит к четкому разделению логики, выполняемой на каждом этапе, благодаря чему повышается простота и надежность.
Рис. 12.1. Поток управления в потенциальном фреймворке для серверов RPC: типичные этапы инкапсулированы в предопределенные перехватчики. Для примера выделена авторизация
В этом примере предшествующий этап перехватчика ведения журнала может регистрировать вызов, а последующий — состояние операции. Теперь, если запрос не авторизован, логика RPC не выполняется, но ошибка «Отказано в доступе» регистрируется правильно. Далее система вызывает этапы перехватчиков аутентификации и регистрации (даже если они пусты) и только после отправляет ошибку клиенту.
Перехватчики разделяют состояние через объект контекста, который они передают друг другу. Например, перед этапом аутентификации перехватчик может обрабатывать все криптографические операции, связанные с обработкой сертификатов (обратите внимание на повышенную безопасность от повторного использования специализированной криптографической библиотеки, а не самостоятельной ее реализации). После система упаковывает извлеченную и проверенную информацию о вызывающем объекте во вспомогательный объект, добавленный ею в контекст. Следующие перехватчики могут легко получить доступ к этому объекту.
Далее фреймворк может использовать объект контекста для отслеживания времени выполнения запроса. Если на каком-то этапе ясно, что запрос не будет выполнен раньше установленного срока, система может автоматически отменить его. Уведомив клиента, вы повысите надежность и сэкономите ресурсы.
Хороший фреймворк должен позволять работать с зависимостями сервера RPC. Например, с другим сервером, отвечающим за хранение журналов. Вы можете зарегистрировать их как мягкие или жесткие зависимости, и фреймворк будет постоянно отслеживать их доступность. Если он обнаруживает, что жесткая зависимость недоступна, фреймворк может остановить сервис. После этого он сообщает о собственной недоступности и автоматически перенаправляет трафик в другие экземпляры.
Рано или поздно перегрузка, проблемы с сетью или другие неполадки приведут к недоступности зависимости. Часто лучше повторить запрос, но выполняйте повторы осторожно, чтобы избежать каскадного сбоя (схожего с падением домино). Самое популярное решение — повторная попытка экспоненциального отката. Хороший фреймворк должен обеспечивать поддержку такой логики, а не требовать от разработчика реализации логики для каждого вызова RPC.
Среда, корректно обрабатывающая недоступные зависимости и перенаправляющая трафик во избежание перегрузки сервиса или его зависимостей, повышает надежность самого сервиса и всей экосистемы. Эти улучшения требуют минимального участия разработчиков.
Фрагменты кода. Примеры с 12.1 по 12.3 показывают перспективы разработчика серверов RPC при работе с фреймворком, ориентированным на безопасность или надежность. Они написаны на Go и используют буферы протоколов Google (https://oreil.ly/yzES2).
Пример 12.1. Начальные определения типов (предшествующий этап перехватчика может изменить контекст. Перехватчик аутентификации может добавить проверенную информацию о вызывающем объекте)
type Request struct { Payload proto.Message } type Response struct { Err error Payload proto.Message } type Interceptor interface { Before(context.Context, *Request) (context.Context, error) After(context.Context, *Response) error } type CallInfo struct { User string Host string ... }
Пример 12.2. Пример перехватчика авторизации, разрешающего только запросы от пользователей из белого списка
type authzInterceptor struct { allowedRoles map[string]bool } func (ai *authzInterceptor) Before(ctx context.Context, req *Request) (context.Context, error) { // Переменная callInfo была заполнена фреймворком callInfo, err := FromContext(ctx) if err != nil { return ctx, err } if ai.allowedRoles[callInfo.User] { return ctx, nil } return ctx, fmt.Errorf("Unauthorized request from %q", callInfo.User) } func (*authzInterceptor) After(ctx context.Context, resp *Response) error { return nil // После обработки RPC здесь больше нечего делать }
Пример 12.3. Пример перехватчика регистрации, который регистрирует каждый входящий запрос (этап «до»), а потом все неудавшиеся запросы с их статусами (этап «после»). WithAttemptCount — это предоставляемая фреймворком опция вызова RPC, реализующая экспоненциальный откат
type logInterceptor struct { logger *LoggingBackendStub } func (*logInterceptor) Before(ctx context.Context, req *Request) (context.Context, error) { // Переменная callInfo была заполнена фреймворком callInfo, err := FromContext(ctx) if err != nil { return ctx, err } logReq := &pb.LogRequest{ timestamp: time.Now().Unix(), user: callInfo.User, request: req.Payload, } resp, err := logger.Log(ctx, logReq, WithAttemptCount(3)) return ctx, err } func (*logInterceptor) After(ctx context.Context, resp *Response) error { if resp.Err == nil { return nil } logErrorReq := &pb.LogErrorRequest{ timestamp: time.Now().Unix(), error: resp.Err.Error(), } resp, err := logger.LogError(ctx, logErrorReq, WithAttemptCount(3)) return err }
Распространенные уязвимости безопасности
В больших кодовых базах несколько классов составляют большинство уязвимостей безопасности, несмотря на постоянные усилия по обучению разработчиков и внедрению анализа кода. OWASP и SANS публикуют списки общих классов уязвимостей. В табл. 12.1 перечислены десять самых распространенных рисков уязвимости в соответствии с OWASP (https://oreil.ly/bUZq8) и некоторые возможные подходы к снижению каждого из них на базовом уровне.
Таблица 12.1. Десять самых распространенных рисков уязвимости в соответствии с OWASP
* См. таблицу предотвращения XXE (https://oreil.ly/AOYev) для получения дополнительной информации.
Уязвимости SQL-внедрений: TrustedSqlString
Внедрение SQL (https://xkcd.com/327) — распространенный класс уязвимостей безопасности. Когда непроверенные фрагменты строк вставляются в SQL-запрос, злоумышленники могут вводить команды для БД. Ниже приведена простая веб-форма для сброса пароля:
db.query("UPDATE users SET pw_hash = '" + request["pw_hash"] + "' WHERE reset_token = '" + request.params["reset_token"] + "'")
Здесь запрос пользователя направляется на сервер с определенным reset_token, специфичным для его учетной записи. Но из-за конкатенации строк злонамеренный пользователь может создать свой reset_token с дополнительными командами SQL (например, ‘ or username =’admin) и внедрить этот токен на сервер. В результате можно сбросить хеш пароля другого пользователя — в этом случае учетной записи администратора.
Уязвимости SQL-внедрений сложнее обнаружить в обширных кодовых базах. Движок БД может помочь предотвратить уязвимости внедрения SQL, предоставив связанные параметры и подготовленные операторы:
Query q = db.createQuery( "UPDATE users SET pw_hash = @hash WHERE token = @token"); q.setParameter("hash", request.params["hash"]); q.setParameter("token", request.params["token"]); db.query(q);
Простое руководство по использованию подготовленных операторов не приводит к масштабируемому процессу безопасности. Вам нужно будет проинформировать каждого разработчика об этом правиле, а специалисты по безопасности должны будут просмотреть весь код приложения для проверки согласованного использования подготовленных утверждений. Вместо этого вы можете спроектировать API базы данных так, чтобы смешивание пользовательского ввода и SQL стало невозможным. К примеру, вы можете создать отдельный тип с именем TrustedSqlString и принудительно установить, что все строки SQL-запроса создаются из входных данных, контролируемых разработчиком. В Go вы можете реализовать тип таким образом:
struct Query { sql strings.Builder; } type stringLiteral string; // Вызывайте эту функцию только со строковыми литеральными параметрами func (q *Query) AppendLiteral(literal stringLiteral) { q.sql.writeString(literal); } // q.AppendLiteral("foo") будет работать, q.AppendLiteral(foo) — нет
Эта реализация гарантирует, что содержимое q.sql полностью конкатенировано из строковых литералов, присутствующих в вашем исходном коде, и пользователь не может предоставить свои строковые литералы. Для обеспечения этого соглашения в масштабе вы можете использовать механизмы конкретного языка, чтобы убедиться, что AppendLiteral вызывается только со строковыми литералами. Например:
В Go
Используйте пакетно-закрытый псевдоним типа (stringLiteral). Код за пределами пакета не может ссылаться на этот псевдоним. Но строковые литералы неявно преобразуются в этот тип.
В Java
Используйте средство проверки подверженного ошибкам кода Error Prone (https://errorprone.info/), предоставляющее аннотацию @CompileTimeConstant для параметров.
В C++
Используйте конструктор шаблона, зависящий от каждого символьного значения в строке.
Вы можете найти похожие механизмы для других языков.
Некоторые функции нельзя создать самостоятельно только при помощи константы времени компиляции. Например, приложение для анализа данных, по своей конструкции выполняющее произвольные SQL-запросы, предоставленные пользователем — владельцем данных. Для обработки сложных случаев использования в Google мы применяем способы обойти ограничения типа только с разрешения инженера по безопасности. Наш API базы данных имеет отдельный пакет unsafequery, экспортирующий отдельный тип unsafequery.String, который может быть создан из произвольных строк и добавлен в SQL-запросы. Только небольшая часть наших запросов использует непроверенные API. Бремя анализа новых применений SQL-запросов, не безопасных по своей сути, и других ограниченных шаблонов API возлагается на одного (чередующегося) инженера на полставки из сотен или тысяч активных разработчиков. См. следующий раздел «Уроки оценки и создания фреймворков», чтобы узнать о других преимуществах рассмотренных исключений.
Предотвращение XSS: SafeHtml
Подход к безопасности на основе типов, рассмотренный в предыдущем разделе, не специфичен для SQL-внедрений. Google использует более сложную версию того же проектного решения для уменьшения уязвимости межсайтового скриптинга в веб-приложениях.
XSS-уязвимости возникают, когда веб-приложение создает ненадежный ввод без предварительной очистки. Например, приложение может интерполировать контролируемое злоумышленником значение $address в HTML-фрагмент, такой как , который виден другому пользователю. Далее атакующий может установить $address как и выполнить произвольный код в контексте страницы другого пользователя.
У HTML нет эквивалента привязки параметров запроса. Вместо этого ненадежные значения должны быть как следует очищены или экранированы до их вставки в HTML-страницу. Кроме того, различные атрибуты и элементы HTML имеют разную семантику. Поэтому разработчикам приложений нужно обрабатывать значения по-разному в зависимости от используемого контекста. Например, контролируемый злоумышленником URL-адрес может вызвать выполнение кода по схеме javascript:.
Система типов позволяет учитывать эти требования: можно вводить разные типы для значений, предназначенных для разных контекстов. Например, SafeHtml для представления содержимого HTML-элемента и SafeUrl для URL-адресов, по которым можно безопасно переходить. Каждый из типов служит (неизменяемой) оберткой вокруг строки. Соглашения поддерживаются конструкторами, доступными для каждого типа. Они составляют надежную кодовую базу, отвечающую за обеспечение свойств безопасности приложения.
Google создал разные библиотеки для разных вариантов использования. Отдельные HTML-элементы могут быть созданы с помощью методов конструктора, которым нужен правильный тип для каждого значения атрибута, и SafeHtml для содержимого элемента. Система шаблонов со строгим контекстным экранированием гарантирует соглашение SafeHtml для более сложного HTML. Он выполняет следующие действия.
- Анализирует частичный HTML-код в шаблоне.
- Определяет контекст для каждой точки подстановки.
- Требует, чтобы программа передала значение правильного типа либо правильно экранировала или очистила ненадежные строковые значения.
Например, если у вас есть следующий шаблон замыкания:
попытка использовать строковое значение для $url не удастся:
templateRendered.setMapData(ImmutableMap.of(«url», some_variable));
Вместо этого разработчик должен предоставить значение TrustedResourceUrl, например:
templateRenderer.setMapData( ImmutableMap.of("x", TrustedResourceUrl.fromConstant("/script.js")) ).render();
Вы не захотите встраивать HTML в веб-интерфейс приложения, если он происходит из ненадежного источника, ведь это приведет к легко эксплуатируемой XSS-уязвимости. Вместо этого воспользуйтесь средством очистки HTML, анализирующим его и совершающим проверки во время выполнения, чтобы определить, что каждое значение соответствует соглашению. Средство очистки удаляет не соответствующие соглашению элементы или те, для которых невозможно проверить соглашение во время выполнения. Вы можете использовать средство очистки для взаимодействия с другими системами, не использующими безопасные типы, потому что многие фрагменты HTML при очистке неизменны.
Разные библиотеки построения HTML направлены на разные компромиссы между производительностью разработки и читаемостью кода. Но все они обеспечивают одинаковые соглашения и должны быть в равной степени заслуживающими доверия (за исключением ошибок в их надежных реализациях). Фактически для уменьшения нагрузки на обслуживание в Google мы генерируем функции построения на разных языках из декларативного файла конфигурации. В нем перечислены HTML-элементы и необходимые соглашения для значений каждого атрибута. Некоторые из наших средств очистки HTML и систем шаблонов используют одинаковый файл конфигурации.
Зрелая реализация с открытым исходным кодом безопасных типов для HTML доступна в шаблонах замыкания (https://oreil.ly/VrN4w). Сейчас предпринимаются усилия по внедрению защиты на основе типов (https://oreil.ly/VrN4w) в качестве веб-стандарта.
Уроки оценки и создания фреймворков
Ранее мы обсуждали, как структурировать библиотеки для установки свойств безопасности и надежности. Но вы не можете элегантно выразить все такие свойства в процессе проектирования API, а иногда даже не можете легко изменить API. Это можно сделать при взаимодействии со стандартизированным DOM API, предоставляемым браузером.
Вместо этого введите проверки во время компиляции, чтобы разработчики не использовали рискованные API. Плагины для популярных компиляторов, такие как Error Prone (https://errorprone.info/) для Java и Tsetse (https://tsetse.info/) для TypeScript, могут запрещать рискованные шаблоны кода.
Опыт показал, что ошибки компилятора обеспечивают немедленную и действенную обратную связь. Встроенные инструменты (например, средства статического анализа кода) или инструменты для проверки кода во время рецензирования обеспечивают обратную связь намного позже. Обычно к тому времени, когда код отправляется на проверку, у разработчиков есть готовая рабочая версия. Не очень приятно узнавать, что нужно выполнить какие-то преобразования для использования строго типизированного API в конце разработки.
Намного проще предоставить разработчикам ошибки компилятора или более быстрые механизмы обратной связи, такие как плагины IDE, подчеркивающие проблемный код. Разработчики быстро решают проблемы с компиляцией. Позже им приходится исправлять другие найденные несоответствия, такие как тривиальные опечатки и синтаксические ошибки. Разработчики уже работают над конкретными строками кода, поэтому у них есть полный контекст и вносить изменения становится проще — например, изменить строковый тип на SafeHtml.
Чтобы упростить разработчику задачу, предложите автоматические исправления. Они станут отправной точкой для безопасного решения. Например, когда вы обнаруживаете вызов функции с SQL-запросом, то можете автоматически вставить TrustedSqlBuilder.fromConstant с параметром запроса. Даже если полученный код не компилируется (запрос может быть строковой переменной, а не константой), разработчики знают, что делать. Им не нужно беспокоиться о механических деталях API, ища нужную функцию, добавляя правильные декларации импорта и т. д.
Поскольку цикл обратной связи быстрый и исправить каждый шаблон несложно, разработчики охотнее пользуются безопасными по своей природе API, даже когда мы не можем доказать, что их код небезопасен, или когда они пишут хороший безопасный код с использованием небезопасных API. Наш опыт отличается от информации, описанной в исследовательской литературе, которая фокусируется на снижении ложноположительных и ложноотрицательных показателей.
Мы поняли, что фокусировка на этих показателях часто приводит к сложным проверкам, требующим гораздо больше времени для получения результатов. Например, проверка может анализировать потоки данных всей программы через сложное приложение. Часто разработчикам сложно объяснить, как устранить обнаруженную с помощью статического анализа проблему. Так происходит потому, что работу средства проверки труднее объяснить, чем простое синтаксическое свойство. Понимание результатов занимает столько же времени, сколько и поиск ошибки в GDB (отладчик GNU). Но исправление ошибки безопасности типов во время компиляции при написании нового кода обычно сложнее исправления обычной ошибки типа.
Простые, безопасные, надежные библиотеки для общих задач
Может быть сложно создать безопасную библиотеку, охватывающую всевозможные варианты использования и надежно обрабатывающую каждый из них. Разработчик приложения, работающий с системой шаблонов HTML, может написать такой шаблон:
<a onclick="showUserProfile('{{username}}');">Show profile</a>">
Чтобы защититься от XSS, если username контролируется злоумышленником, система шаблонов должна включать три разных уровня контекста: одинарные кавычки строки внутри JavaScript в атрибуте HTML-элемента. Трудно создать систему шаблонов, способную обрабатывать все возможные комбинации крайних случаев, и использовать эту систему тоже будет непросто. В других областях эта проблема может стать еще сложнее. Бизнес-требования могут диктовать, кто может выполнять действие, а кто нет. Если ваша библиотека авторизации так же выразительна (и ее трудно анализировать), как язык программирования общего назначения, вы не сможете выполнить все требования разработчиков.
Вместо этого вы можете начать с простой небольшой библиотеки, охватывающей только общие случаи. Простые библиотеки легче объяснить, документировать и использовать. Это упрощает взаимодействие разработчиков и помогает убедить других разработчиков использовать защищенную библиотеку. Иногда лучше предлагать разные библиотеки, оптимизированные для разных вариантов использования. Например, у вас могут быть как системы шаблонов HTML для сложных страниц, так и библиотеки для построения коротких фрагментов.
Вы можете приспособить другие варианты использования с проверенным экспертами доступом к неограниченной, рискованной библиотеке, обходящей гарантии безопасности. Если вы видите повторяющиеся похожие запросы для варианта использования, поддержите эту функцию в изначально безопасной библиотеке. Как мы заметили в подразделе «Уязвимости SQL-внедрений: TrustedSqlString» ранее в этой главе, нагрузка при рецензировании обычно управляема.
Из-за небольшого объема запросов на рецензирование специалисты по безопасности могут подробно изучить код и предложить обширные улучшения, а обзоры — уникальные сценарии использования, мотивирующие их и предотвращающие ошибки из-за повторов и усталости. Исключения действуют и как механизм обратной связи: если разработчикам постоянно нужны исключения для варианта использования, авторам библиотеки стоит рассмотреть возможность создания отдельной библиотеки для этого варианта использования.
Стратегия внедрения
Наш опыт показал, что использование типов для свойств безопасности полезно новому коду. У приложений, созданных в одной широко используемой внутренней веб-среде Google, изначально разработанной с безопасными типами для HTML, было меньше зарегистрированных XSS-уязвимостей (на два порядка), чем у приложений, написанных без них, несмотря на тщательное рецензирование кода. Несколько обнаруженных уязвимостей были вызваны компонентами приложения, не использующими безопасные типы.
Адаптировать существующий код для использования безопасных типов труднее. Даже если вы начинаете с новой кодовой базы, вам нужна стратегия для миграции унаследованного кода. Вы можете обнаружить новые классы проблем безопасности и надежности, от которых нужно защититься, или может понадобиться усовершенствование существующих соглашений.
Мы экспериментировали с несколькими стратегиями рефакторинга существующего кода. Обсудим два самых успешных подхода в следующих подразделах. Для этих стратегий нужно получить доступ и изменить весь исходный код приложения. Бо́льшая часть исходного кода Google хранится в одном репозитории с централизованными процессами для внесения, сборки, тестирования и отправки изменений. Рецензенты тоже применяют общие стандарты удобочитаемости и организации кода. Это уменьшает сложность изменения незнакомой кодовой базы. В других средах масштабные рефакторинги могут быть сложнее. Так легче получить общее согласие, поэтому каждый владелец кода готов принять изменения в своем исходном коде, что помогает формированию культуры безопасности и надежности.
В руководстве по стилю Google для всей компании есть концепция читабельности языка: подтверждение того, что инженер понимает лучшие практики Google и стиль написания кода для данного языка. Читаемость — основа качества кода. Инженер должен либо сделать код на языке, с которым работает, читаемым, либо получить обратную связь от человека, который может эту читаемость обеспечить. Если код очень сложен или критически важен, проверять его должны отдельные разработчики. Такой способ может быть наиболее продуктивным и эффективным для улучшения качества кодовой базы.
Постепенное внедрение
Часто невозможно исправить всю кодовую базу за раз. Разные компоненты могут быть в разных репозиториях. Создание, просмотр, тестирование и отправка одного изменения, затрагивающего несколько приложений, часто ненадежны и подвержены ошибкам. Вместо этого в Google мы изначально освобождаем устаревший код от принудительного применения и рассматриваем существующие небезопасные использования API по одному.
Если у вас уже есть API базы данных с функцией doQuery
(String sql
), вы можете добавить перегрузку doQuery
(TrustedSqlString sql
) и ограничить небезопасную версию для существующих вызывающих объектов. С помощью фреймворка Error Prone вы можете добавить аннотацию @RestrictedApi
(whitelistAnnotation={LegacyUnsafeStringQueryAllowed.class}
) и @LegacyUnsafeStringQueryAllowed
для всех существующих вызывающих объектов.
Далее, внедрив Git-хуки, анализирующие каждое изменение, вы можете запретить новому коду использовать перегрузку строк. Другой вариант — ограничить видимость небезопасного API. Например, белые списки видимости Bazel (https://oreil.ly/ajmrr) позволят пользователю вызывать API, только когда член команды безопасности одобрит запрос (pull request, PR). Если ваша кодовая база активно разрабатывается, она будет органично двигаться в направлении безопасного API. По достижении точки, в которой только малая часть вызывающих объектов использует устаревший API на основе строк, вы можете вручную очистить оставшийся код. На этом этапе кодовая база будет защищена от SQL-внедрений.
Унаследованные преобразования
Часто лучше объединить все ваши механизмы исключения в одну функцию, очевидную в читаемом исходном коде. Можно создать функцию, принимающую произвольную строку и возвращающую безопасный тип. С ее помощью вы сможете заменить все вызовы API со строковым типом на более точные. Обычно типов будет намного меньше, чем функций, их потребляющих. Вместо того чтобы ограничивать и контролировать удаление многих устаревших API (например, каждого DOM API, использующего URL), удалите только одну устаревшую функцию преобразования для каждого типа.
Простота — залог безопасного и надежного кода
Стремитесь к чистоте и простоте кода. Есть несколько публикаций на эту тему, поэтому здесь мы сосредоточимся на двух незамысловатых историях, опубликованных в блоге Google Testing (https://testing.googleblog.com/). В обеих историях раскрыты стратегии, позволяющие избежать быстрого увеличения сложности кода.
Избегайте многоуровневого вложения
Многоуровневое вложение — это распространенный антипаттерн, приводящий к простым ошибкам. Если ошибка будет на самом распространенном пути кода, ее зафиксируют модульные тесты. Но они не всегда проверяют пути обработки ошибок в многоуровневом вложенном коде. Ошибка может привести к снижению надежности (например, в случае сбоя сервиса при неправильной обработке ошибки) или к уязвимости в системе безопасности (например, ошибке проверки авторизации при неправильной обработке).
Можете ли вы обнаружить ошибку в коде на рис. 12.2? Обе версии равнозначны.
Ошибки «wrong encoding» и «unauthorized» меняются местами. Это легче увидеть в реорганизованной версии, так как проверки происходят сразу после обработки ошибок.
Рис. 12.2. Ошибки сложнее обнаружить в коде с несколькими уровнями вложенности
Устранение «запахов» YAGNI
Иногда разработчики перерабатывают решения, добавляя функциональность, которая может пригодиться в будущем, «на всякий случай». Это противоречит принципу YAGNI (You Aren’t Gonna Need It, «вам это не нужно») (https://oreil.ly/K4Oan), по которому нужно реализовывать только необходимый на данный момент код. Код YAGNI добавляет ненужную сложность, потому что его необходимо документировать, тестировать и поддерживать. Рассмотрим следующий пример:
class Mammal { ... virtual Status Sleep(bool hibernate) = 0; }; class Human : public Mammal { ... virtual Status Sleep(bool hibernate) { age += hibernate ? kSevenMonths : kSevenHours; return OK; } };
Код Human::Sleep должен обрабатывать случай, когда hibernate имеет значение true, хотя все вызывающие объекты всегда должны передавать значение false.
Вызывающие объекты должны обрабатывать возвращенное состояние, даже если оно всегда должно быть равно OK. Вместо этого, пока вам не понадобятся классы, отличные от Human, этот код можно упростить до следующего:
class Human { ... void Sleep() { age += kSevenHours; } };
Если предположения разработчика о возможных требованиях к будущей функциональности верны, можно легко добавить эту функциональность позже, следуя принципу поэтапной разработки и проектирования. В нашем примере будет проще создать интерфейс Mammal
с более подходящим общим API при обобщении на основе нескольких существующих классов.
Подытожим: отказ от кода YAGNI ведет к повышению надежности, а более простой код влечет за собой уменьшение числа ошибок безопасности, меньшее количество возможностей совершать ошибки и уменьшение времени, затрачиваемого разработчиком на поддержку неиспользуемого кода.
Погашение технического долга
Разработчики обычно отмечают места, требующие дополнительного внимания, с помощью аннотаций TODO или FIXME. В краткосрочной перспективе эта привычка может ускорить добавление самых важных функций и позволить команде справиться с работой раньше, но она несет технический долг. Это не обязательно плохая практика, если у вас есть четкий процесс (и выделенное время) для погашения такого долга.
Технический долг может содержать ошибочную обработку исключительных ситуаций и введение в код слишком сложной логики (часто написанной для обхода других областей технического долга). Любое из этих действий может привести к проблемам с безопасностью и надежностью, которые редко обнаруживаются во время тестирования (из-за недостаточного охвата редких случаев) и в итоге становятся частью производственной среды.
Есть разные способы погашения технического долга.
- Ведение информационных панелей с показателями работоспособности кода. Они вариативны: от простых информационных панелей, показывающих охват тестами или количество и средний возраст TODO, до более сложных, включающих такие показатели, как цикломатическая сложность (https://oreil.ly/_N25V) или индекс удобства сопровождения (https://oreil.ly/_N25V).
- Использование инструментов анализа: средства статического анализа кода для обнаружения распространенных дефектов вроде мертвого кода, ненужные зависимости или специфические для языка ошибки. Часто такие инструменты могут автоматически исправить ваш код.
- Отправка уведомлений, когда показатели работоспособности кода падают ниже предопределенных пороговых значений или когда число автоматически обнаруживаемых проблем становится слишком большим.
Важно сохранять командную культуру, которая поддерживает хорошее состояние кода и фокусируется на нем. Здесь немаловажно лидерство. Вы можете запланировать регулярные недели исправлений, в течение которых разработчики сосредоточатся на улучшении работоспособности кода и исправлении ошибок, а не на добавлении новых функций. Вы можете поддерживать постоянный вклад в улучшение качества кода в команде с помощью бонусов или других способов поощрения.
Рефакторинг
Рефакторинг — самый эффективный способ сохранить кодовую базу чистой и простой. Он нужен даже для исправной кодовой базы — при расширении существующего набора функций, изменении сервера и т. д.
Рефакторинг особенно полезен при работе со старыми, унаследованными кодовыми базами. Его первый шаг — измерение покрытия кода и увеличение этого покрытия до достаточного уровня. Чем выше покрытие, тем выше ваша уверенность в безопасности рефакторинга. Но даже стопроцентное тестирование не может гарантировать успех, потому что тесты могут быть бессмысленны. Вы можете решить эту проблему с помощью других видов тестирования, таких как нечеткое тестирование, описанное в главе 13.
Независимо от причин рефакторинга вы всегда должны следовать одному золотому правилу: никогда не смешивайте рефакторинг и функциональные изменения в одном коммите в хранилище кода. Рефакторинг изменений важен и сложен для понимания. Если коммит тоже содержит функциональные изменения, риск того, что автор или рецензент могут пропустить ошибки, возрастает.
Полный обзор методов рефакторинга выходит за рамки этой книги. Для получения дополнительной информации по этой теме есть отличная книга Мартина Фаулера и статьи Райта и др. (2013), Вассермана (2013), Потвина и Левенберга (2016).
Бетси Бейер — технический писатель Google в Нью-Йорке, специализируется на обеспечении надежности информационных систем. Она соавтор книг «Site Reliability Engineering. Надежность и безотказность как в Google» и «Site Reliability Workbook. Практическое применение». На пути к своей нынешней карьере Бетси изучала международные отношения и английскую литературу, а также получила степень в Стэнфордском и Тулейнском университетах.
Пол Бланкиншип руководит командой технических авторов в отделе безопасности и конфиденциальности Google. Помогал разрабатывать внутреннюю политику безопасности и конфиденциальности Google. Он не только технический писатель, но и музыкант.
Петр Левандовски является старшим штатным инженером по надежности сайтов. Последние девять лет он занимался улучшением безопасности инфраструктуры Google. Как технический руководитель по безопасности производственной среды, Петр отвечает за гармоничное сотрудничество команд, ответственных за надежность и безопасность. На своей предыдущей должности он возглавлял команду, ответственную за надежность критически важной инфраструктуры безопасности Google. До прихода в Google Петр создал стартап, работал в CERT Polska и получил степень в области компьютерных наук в Варшавском политехническом университете.
Ана Опреа специализируется на безопасности, SRE, планировании и стратегии для технической инфраструктуры Google. До этого она занимала должности разработчика программного обеспечения, технического консультанта и сетевого администратора.
Адам Стабблфилд — выдающийся инженер и региональный технический руководитель по безопасности в Google. За последние восемь лет он помог создать бо́льшую часть базовой инфраструктуры безопасности Google. Адам получил степень Ph. D. в области компьютерных наук в Университете Джона Хопкинса.
Более подробно с книгой можно ознакомиться на сайте издательства:
По факту оплаты бумажной версии книги на e-mail высылается электронная книга.
Для Хаброжителей скидка 25% по купону — Cистемы
ссылка на оригинал статьи https://habr.com/ru/articles/833942/
Добавить комментарий