Номер раздела | Название раздела | Объем в страницах | Объем в кбайт |
10.29.1 | Суперкомпьютеры и Watson | 16 | 2370 |
10.29.2 | Динамика использования разных языков программирования за последнее десятилетие | 8 | 216 |
Итого | 0 | 0 |
Gerard J. Holzmann
Данная статья основана на: 'The Power of Ten -- Rules for Developing Safety Critical Code,' IEEE Computer, June 2006, pp. 93-95 (см. также www.spinroot.com), а также на статье Питера Вайнера (Peter Wayner) "Safeguard your code: 17 security tips for developers".
Не следует думать, что на свете существуют правила, следуя которым, можно не делать ошибок в программах. Но задуматься над этой проблемой и учесть опыт других, никому не помешает (Ю.А.Семенов)
Ограничиться простой блок-схемой алгоритма. Не используйте команд goto, setjmp или конструкций longjmp, и не используйте явную или неявную рекурсию.
Простота блок-схемы конвертируется в большие возможности верификации и лучшую прозрачность кода. Запрет рекурсии является, может быть, наибольшим сюрпризом. Без рекурсии мы гарантируем нециклическую функцию графа вызова, которая может быть использована анализаторами кода и может непосредственно помочь проверить, что все операции исполнения, которые должны быть связаны, действительно связаны (смотри также Правило 2). Заметим, что это правило не требует, чтобы все функции имели одну точку возврата – хотя это часто также упрощает алгоритм. Хотя существует достаточно примеров, когда ранний возврат ошибки является более простым решением.
Но: Плохо написанный код не станет сразу надежным, как только вы удалите эти языковые конструкции.
Для большинства из нас, существует только два типа программ: те, что работают, и те, что не работают. Любые разговоры о правилах, которые могут как-то повлиять на то, к какой из этих категорий будет отнесен написанный вами код, представляются не серьезными.
Возьмем команды goto в качестве примера. Goto впервые получили дурную репутацию, когда Edsger Dijkstra опубликовал свое знаменитое письмо в журнале Communications of the ACM в 1968. Это письмо высветило одно из направлений горячих дебатов, ведущихся в сфере программирования в течение последних десятилетий. Сегодня мало кто может сказать, что можно убедить кого-то изменить стиль программирования. Не существует экспериментально проверенного материала доказывающего, прав Дикстра или нет.
Существует достаточно большое число хороших программ, которые содержат инструкции goto. Конечно, большинство этих программ не являются критическими в отношении безопасности. Воспринимали ли мы проблему по-другому, если бы наша жизнь зависела от конкретного программного пакета, выполняющего правильно определенную работу каждый раз, когда он запускается? Чтобы быть конкретными, давайте будем считать, что мы говорим не чьей-то жизни, а о нашей.
Если здесь имеется отличие, тогда, что мы должны делать по-другому, когда мы пишем такой код? Следует ли нам быть немного более параноидальными, делая вещи настолько прозрачными, насколько мы это можем? Подумайте о последней найденной вами ошибке в вашей программе. Сколько раз вы смотрели на участок кода, где была эта ошибка, прежде чем заметили ее? Ошибки программы умело прячутся и они воспользуются любым нашим содействием, которое им будет оказано, когда структура кода будет сделана немного более сложной, чем это требуется.
Если ваша жизнь действительно зависит от этого, можете ли вы чувствовать себя свободно в выборе структуры или стиля программирования? Если вы действительно думаете об этом, ответ должен быть – нет. Если на кону ваша жизнь, вы сделаете все, что вы можете, чтобы убедиться, что находитесь далеко от зоны опасности, реальной или воображаемой. Если инструкции goto могут иногда сделать структуру вашего кода немного более сложной, чем она должна быть, тогда разумным шагом будет найти другой вариант для структуры кода.
Это очевидно широкая тема, которая не может быть закрыта с помощью нескольких простеньких параграфов. Но давайте согласимся, что если от этого зависит ваша жизнь, не достаточно сделать вашу программу вероятно корректной. (Мы слишком часто заблуждаемся неуловимым образом, когда пытаемся привести аргументы для этого – подумайте лишь о последних нескольких ошибках, которые вы нашли в своей программе). Недостаточно, если вы как программист убеждаете себя, что код корректен. Если требуется более десяти секунд, чтобы нарисовать блок-схему алгоритма, код должен быть переписан.
Код критический в отношении безопасности должен быть тривиально и доказуемо корректным.
Все циклы должны иметь фиксированный верхний предел.
Для средства тестирования должно быть тривиально просто, проверить статически, что заданное максимальное число циклов не может быть превышено. Если предельное число циклов не может быть проверено статически, правило считается нарушенным.
Отсутствие рекурсии и наличия связанных циклов предотвращает непредсказуемые действия кода. Это правило, разумеется, не относится к итерациям, которые работают бесконечно (напр., процесс диспетчера). В этих особых случаях используется обратное правило: статически проверяется, что итерации не могут завершиться. Одним из способов поддержать правило может стать добавление явного значения верхнего предела для всех циклов, которые имеют переменное число итераций. Когда верхний предел превзойден, выдается сигнал ошибки, а функция, содержащая ошибочную итерацию, возвращает флаг ошибки. Смотри также правило 5 об использовании assertion.
Не используйте динамического выделения памяти после инициализации. Это исключает использование malloc, sbrk, alloca и всех аналогичных операторов после инициализации процесса или треда после инициализации.
Это правило является обычным для программ, критичным по безопасности, и встречается в большинстве инструкций по программированию. Причина проста: инструкция выделения памяти, такая как malloc, и garbage collector часто имеют непредсказуемое поведение, которое может существенно повлиять на работу программы. Значительный класс ошибок кода происходит от некорректного использования операций выделения памяти: забывания освободить память или продолжения использования памяти, которая уже освобождена, попыток выделить больше памяти, чем физически доступно, пересечения границ выделенной памяти и т.д.. Заставляя все приложения находиться в пределах заранее выделенной памяти, можно исключить многие проблемы и упростить контроль использования памяти. Заметим, что единственный способ динамического распределения памяти в отсутствии ее прямого выделения, является использование стека. В отсутствии рекурсии (правило 1), верхняя граница использования стека может быть определена статически, позволяя проконтролировать то, что программа живет в пределах заранее заданных границ памяти.
Ограничьте размер кода функции 60 строками текста. Функция при распечатке должна помещаться на одной странице стандарта А4 (по одной строчке на команду или декларацию. Обычно это означает, что функция не должна иметь более 60 строк.
Каждая функция должна представлять собой логический модуль кода, который понимается и модифицируется как единый блок. Много труднее понять логический блок, который занимает несколько экранов или несколько страниц при печати. Чрезмерно длинные функции часто свидетельствуют о том, что программа плохо структурирована.
В среднем в каждой функции следует предусматривать два заявления (assertion).
Частота утверждений (assertion) в среднем должна соответствовать 2 на функцию. Утверждения используются для проверки аномальных условий, которые в принципе никогда не должны реализоваться. Утверждения никогда не должны давать каких-либо побочных эффектов и определяются как Булевы проверки. Когда assertion терпит неудачу, должны быть предприняты меры восстановления, например, путем отправки вызывающему агенту уведомления об ошибке в функции, где произошла неудача.
Статистика индустриального программирования показывает, что ошибки в коде встречаются с частотой одна на 10-100 строк программы. Преимущества перехвата дефектов увеличивается по мере увеличения числа assertion. Использование assertion часто рекомендуется как часть оборонительной стратегии в программировании. Assertion может использоваться для верификации условий до и после выполнения функции, значений параметров, возвращаемых функцией, и инвариантов циклов. Так как assertion не имеет побочных эффектов, они могут выборочно блокироваться после тестирования кодов, критических по времени исполнения. Типовым примером применением утверждений (assertion) может служить:
if (!c_assert(p >= 0) == true) { return ERROR; }
с assertion, определенным как:
#define c_assert(e) ((e) ? (true) : \ tst_debugging("%s,%d: assertion '%s' failed\n", \ __FILE__, __LINE__, #e), false)
В этом определении, __FILE__ и __LINE__ предварительно определены с помощью макро препроцессора, чтобы выдавать имя файла и номер строки кода, когда assertion терпит неудачу. Синтаксис #e помещает условие assertion e в строку, которая печатается, как часть сообщения об ошибке. В кодах, предназначенных для встроенных процессоров, конечно нет места для печати самого сообщения об ошибке – в этом случае, вызов tst_debugging превращается в no-op, а assertion превращается в чисто булеву проверку, которая делает возможной восстановление после ненормального поведения.
Декларируйте объекты данных с возможно минимальной областью действия.
Это правило поддерживает базовый принцип сокрытия данных. Ясно, что если объект находится вне области, его значение недоступно и не может быть повреждено. Аналогично, если неправильное значение объекта нужно диагностировать, немногие строки кода, где производится присвоение легко отследить и диагностировать. Это правило препятствует повторному использованию переменных для разнообразных несовместимых целей, что может осложнить поиск ошибок.
Проверяйте возвращаемые значения для non-void функций, и проверяйте допустимость значений параметров функции. Возвращаемые значения non-void функций должны проверяться любой вызывающей функцией, а корректность входных параметров должна контролироваться внутри каждой функции.
Это, возможно, наиболее часто нарушаемое правило. В его наиболее строгой форме это правило означает, что должны проверяться даже возвращаемые значения инструкций printf и закрытия файла. В случаях типа printf или close может быть вполне приемлемым возврат значения (void), что будет говорить о том, что программист решил явно игнорировать возвращаемое значение. В более сомнительных случаях следует помещать комментарий, который будет объяснять, почему возвращаемое значение не соответствует ожидаемому. В большинстве случаев, возвращаемое значение не должно игнорироваться, особенно, если указание на ошибку должно быть передано вверх по цепочке вызова. Стандартные библиотеки часто нарушают это правило с потенциально печальными последствиями. Смотри, например, что случается, если случайно исполните strlen(0), или strcat(s1, s2, -1) в случае стандартной Си-библиотеки. Придерживаясь общего правила, мы убеждаемся, что исключения должны быть обоснованными и проверяться специальными программами, выявляющими нарушения. Часто, будет проще следовать правилу, чем объяснять, почему отклонение допустимо.
Ограничьте применение препроцессора включением файлов и простыми макросами. Использование препроцессора должно быть ограничено включением файлов header и простыми макроопределениями. Объединение лексем, списки аргументов переменной длины, и рекурсивные макро вызовы не допускаются. Все макросы должны разворачиваться в завершенные синтаксические блоки. Использование директив условной компиляции также сомнительно, но не может быть безоговорочно исключено. Это означает, что будет редко нужна проверка более чем одной или двух директив условной компиляции даже в случае большой программы, помимо стандартного требования исключения одного и того же файла header. Каждое такое использование должно помечаться флагом при тестировании программы просматриваться в самом тексте кода.
Препроцессор C является мощным средством затуманивания, которое может разрушить прозрачность кода и одурманить многие программы по проверки текста кода. Влияние конструкций в случае неограниченного препроцессорного кода может быть крайне трудно дешифровать, даже с определениями формального языка в руках. В новой реализации Си-препроцессора, разработчики часто вынуждены прибегать к использованию более ранних реализаций, чтобы интерпретировать язык описаний в Си-стандарт. Обоснованность запрета условной компиляции следует считать также важной. Заметим, что только 10 директив условной компиляции могут породить до 210 (т.e., 1024) возможных версий кода, каждая из которых должна быть протестирована – порождая чудовищное увеличения объема проверок.
Ограничить использование указателей. Использовать не более одного уровня разыменования. Операции разыменования указателей не могут быть спрятаны в макро определениях или внутри деклараций typedef. Использование указателей функций следует ограничить простыми случаями.
Указатели часто используются некорректно даже опытными программистами. Они могут усложнить отслеживание или анализ потока данных в программе, особенно с помощью статических анализаторов. Указатели функций, аналогично, способны серьезно ограничить типы проверок, которые могут быть выполнены статическими анализаторами, и их следует использовать, только в случае серьезных оснований. Желательно иметь средства проверки выполнения алгоритма и иерархии вызовов функций. Например, если используются указатели функций, для анализатора может оказаться невозможным проверить отсутствие рекурсии.
Проводить компиляцию следует с разрешением всех предупреждений, а также использовать анализаторы исходного кода. Весь код должен компилироваться с первого дня разработки, при всех активированных предупреждениях. Следует добиваться отсутствия предупреждений при компиляции. Все коды должны проверяться ежедневно, по крайней мере, одним наилучшим, доступным анализатором исходного кода.
На рынке сегодня имеется несколько эффективных статических анализаторов исходных кодов, существует также несколько общедоступных программ. Не может быть никакого извинения для разработчиков программ, не использующих эту технологию. Применение этой техники следует рекомендовать не только при разработке программ, критических по безопасности. Правило отсутствия предупреждений применяется даже в случаях, когда компилятор или статический анализатор выдает ошибочное предупреждение: если компилятор или статический анализатор введены в заблуждение, код, вызвавший такую реакцию, следует переписать, чтобы он стал тривиально правильным. Многие разработчики ловят себя на том, что считают предупреждения ложными, только много позже они осознают, что сообщение было корректно, но причина была не вполне очевидна. Статические анализаторы имеют отчасти плохую репутацию из-за своих предшественников, таких как lint, который часто выдавал некорректные сообщения, но сейчас ситуация совершенно иная. Лучшие статические анализаторы сейчас являются быстродействующими программами, которые выдают по большей части полезные и точные уведомления. Их использование не должно подвергаться сомнениям в любом серьезном программном проекте.
Статические анализаторы кода C можно найти по адресу spinroot.com/static. Рекомендуется применение программных средств coverity ( www.coverity.com) , codesonar (www.grammatech.com), klocwork (www.klocwork.com) и uno (www.spinroot.com/uno) (примерно в этом порядке).
Ваша программа должна тщательно анализировать строки символов, вводимые клиентом. Хакеры часто пытаются включать символы или фрагменты программ, которые могут обрушить вашу программу (вспомним атаку типа MySQL-injection или атаку переполнения буфера).
Программа должна заносить в память ровно столько символов из числа введенных, сколько нужно и ни одним битом больше.
Чтобы исключить возможность подбора пароля с помощью скрипта, следует сделать задержку отклика, удваивающейся с увеличением номера попытки. Можно также ограничить число попыток обращения к базе данных с одного и того же IP-адреса.
Используйте криптографию для защиты данных. Конечно, отлаживать программу, где исходные данные зашифрованы сложнее, но, представьте, какие трудности это создает для хакера?
Различные блоки сервисов должны иметь отдельную авторизацию, иначе взлом одного из сервисов скомпрометирует всю систему или даже сеть.
Используйте хорошо оттестированные библиотеки, написание нового программного модуля всегда таит в себе потенциальные ошибки.
Пишите программы в виде модулей, которые взаимодействуют через внутренние, проверенные API.
Используйте код-чекеры и анализаторы для проверки даже простых программ.
Ограничивайте привилегии ровно тем минимальным уровнем, который должен быть достаточным.
Пытайтесь представить, кто и с какой целью может пытаться получить доступ к тому или иному ресурсу. Сконцентрируйтесь на мониторинге и предотвращении таких попыток.