Как использовать инструменты статического анализа в коде Python

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

В статье мы подробно разберем несколько инструментов статического анализа с открытым ПО для Python. Посмотрим, как они работают и улучшают процесс программирования. 

Понятие статического анализа кода 

Статический анализ кода  —  это метод исследования исходного кода программы без реального ее выполнения. Он позволяет своевременно находить ошибки, пока они не стали проблемой. Статический анализ также выявляет уязвимости, повышая безопасность кода. 

Анализ кода Python 

Продемонстрируем надежность статического анализа на примере чисто теоретического приложения. Создадим его на компьютере, на котором даже не установлен Python. 

Затем настроим статический анализатор и будем следовать его инструкциям. Возможности современных сканнеров не ограничиваются стилем и форматированием кода. Они разработаны для решения вопросов, связанных с уязвимостями SAST, проверками на null, стандартами приложений  —  все это мы рассмотрим на примере данного проекта. 

Начнем с самого приложения, которое: 

  • проверяет, содержит ли узел в двоичном дереве определенную информацию и выводит к нему путь;
  • принимает файл, разделенный на определения node и relationship
  • анализирует эти определения в дереве, после чего ищет в нем конкретный узел: 
import os

class node:
def __init__(self, id, ele, root) -> None:
self.id = id
self.root = root
self.ele = ele
self.left = None
self.right = None

def filter(dict, callback):
newDict = {}
for(key, value) in dict.items():
if(callback(key, value)):
newDict[key] = value
return newDict

def safe_get(list: list, i: int):
try:
return list[i]
except IndexError:
return None

def relationship_observer(dict, idx, line):
id, *rest = line.split(' ')
left_id = safe_get(rest, 0)
right_id = safe_get(rest, 1)
if(left_id):
dict[id].left = dict[left_id]
if(right_id):
dict[id].right = dict[right_id]

def build_trees():
dict = {}
for path, subdirs, files in os.walk('/tmp'):
for name in files:
filePath = os.path.join(path, name)
file = open(filePath)
lines = file.read().splitlines()
observer = None
for idx, line in enumerate(lines):
if(line == 'nodes'):
observer = node_observer
elif(line == 'relationships'):
observer = relationship_observer
else:
observer(dict, idx, line)
return [filter(dict, lambda elem: elem[1].root), dict]

[roots, nodes] = build_trees()

print(f'Root Count: {len(roots)}')
print(f'Node Count: {len(nodes)}')

def findNode(node, path, search):
if(node.data == search):
return node, path
else:
left = findNode(node.left, path.copy().append(node.left.id))
if(left.data == search):
return left, path

right = findNode(node.right, path.copy().append(node.right.id))
if(right.data == search):
return right, path
return None, []


search = 'FindMe!'

for root in roots:
rootNode = roots[root]
target_node, path = findNode(rootNode, [], search)
if(target_node != None):
print(f'Root node {rootNode.id} {rootNode.data} contains {search} under node {target_node.id} ({" => ".join(path)})')
break
else:
print("Not Found")

В дополнение к основному коду приложения у нас будет dockerfile:

FROM python:3
WORKDIR /app

COPY requirements.txt ./

RUN pip install - no-cache-dir -r requirements.txt

COPY . .

CMD ["python", "./main.py"]

А также docker-compose для запуска приложения: 

services:
python:
build: .
volumes:
- ./tmp:/tmp

Сначала разберем основы: мы устанавливаем расширение MyPy для VS Code и тем самым обзаводимся статической проверкой типов. После установки сразу получаем обратную связь  —  вызов метода findNode в строке 74 подчеркнут красным цветом: 

Какая полезная информация! MyPy сразу обнаруживает отсутствие позиционного аргумента в вызове метода findNode для пути path. Быстро его добавляем и приводим строку к следующему виду:

target_node, path = findNode(root, ‘’, search)

Однако path не должен быть строкой. 

Похоже, MyPy этого не понимает. Дело в том, что мы упустили из виду аннотации типов для указания ожидаемых значений. Обновим метод, исправив этот недочет: 

def findNode(node: node, path: list[str], search: str):

Немедленно поступает обратная связь  —  отправка неправильного типа при условии сохранения его в виде строки: 

Меняем его на объявление массива и обновляем вызов: 

target_node, path = findNode(root, [], search)

Но тут возникает проблема: несколько параметров имеют неправильные аннотации, а тело метода findNode подчеркнуто красным. 

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

Обеспечим класс node необходимыми аннотациями. Для этого импортируем аннотацию Optional из библиотеки типов. Она поможет исправить ошибки статической типизации: 

from typing import Optional

class node:
def __init__(self, id: str, data: str, root: int, left: Optional['node'] , right: Optional['node']) -> None:
self.id = id
self.root = root
self.data = data
self.left = left
self.right = right

Теперь получаем еще более интересный вывод MyPy в методе findNode: и внутренний вызов findNode, и path.copy().append подчеркнуты красным. 

При наведении курсора на красные подчеркивания в вызове findNode появляется сообщение об ошибке:

Argument 1 to “findNode” has incompatible type “Optional[node]”; expected “node” [arg-type]mypy(error) 

Аргумент 1 для findNode имеет несовместимый тип Optional[node]; ожидается node [arg-type]mypy(ошибка).

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

По этой причине MyPy предупреждает о предпринимаемой попытке получить доступ к свойству с возможным значением null, что вызовет ошибку во время выполнения. Добавляем несложный страховочный код для устранения ошибки анализа: 

def findNode(node: node, path: list[str], search: str):
if(node.data == search):
return node, path
else:
if(node.left):
left = findNode(node.left, path.copy().append(node.left.id), search)
if(left.data == search):
return left, path

if(node.right):
right = findNode(node.right, path.copy().append(node.right.id), search)
if(right.data == search):
return right, path
return None, []

Это решение устраняет первую ошибку. Однако при наведении курсора на красные подчеркивания в вызове path.copy().append выводится сообщение еще об одной ошибке: 

”append” of “list” does not return a value [func-returns-value]mypy(error)

append (добавление) list (списка) не возвращает значение [func-returns-value] mypy(ошибка).

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

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

def findNode(node: node, path: list[str], search: str):
if(node.data == search):
return node, path
else:
if(node.left):
left_path = path.copy()
left_path.append(node.left.id)
left = findNode(node.left, left_path, search)
if(left.data == search):
return left, path

if(node.right):
right_path = path.copy()
right_path.append(node.right.id)
right = findNode(node.right, right_path, search)
if(right.data == search):
return right, path
return None, []

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

Инструмент Bandit

Рассмотрим еще один вариант решения для избавления кода от проблем безопасности. Речь идет о Bandit, инструменте для сканирования уязвимостей SAST. Проходим по ссылке на документацию и устанавливаем его в локальную систему. После этого переключаем линтер на Bandit и применяем его к текущему файлу main.py

Bandit тут же находит ошибку! 

И он прав. В настоящее время код приложения ссылается на данные из /tmp, каталога верхнего уровня в дистрибутиве Linux и вероятного вектора атаки. 

Исходя из этого, мы должны предоставить ссылку на данные из локальной папки, связанной с кодом приложения, или разрешить параметризованные записи в точке входа кода. 

Поскольку это единственное предупреждение, просто переключаемся на локальную папку в ./data и вносим изменения в docker-compose, чтобы переместить содержимое папки данных в новую локальную папку. 

Репозиторий Bandit с открытым исходным кодом содержит в качестве примеров файлы, которые можно импортировать в проект и посмотреть, какие еще уязвимости в коде способен обнаружить Bandit.

Теперь проведем эвристический анализ кода. 

Radon

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

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

Такой низкий показатель средней сложности  —  просто успех. Однако применительно к более крупным базам кода сведения о низких результатах сложности могут и не потребоваться. В Randon также используется флаг --nc, который показывает только оценку “С” или хуже. 

Проверим ряд ключевых метрик кода с помощью команды raw

Команда выводит важную информацию о строках кода, разнице между логическими строками кода (LLOC) и исходными строками кода (SLOC), комментариях и их покрытии. Добавим 2 комментария и посмотрим, какие метрики изменятся: 

Как видно, изменились метрики комментариев.

С помощью Radon можно получить еще один интересный набор метрик, а именно метрики сложности halstead

Кроме того, эта команда выполняется отдельно для каждой функции в файлах кода с помощью флага -f.

mi  —  еще одна любопытная команда. Она выводит индекс удобства сопровождения, который собирает информацию от других команд и оценивает код на предмет легкости его обслуживания: 

Приложение с успехом проходит это испытание! Документация Radon содержит более подробную информацию по вопросам вычисления метрик. 

Заключение 

В статье приводится обзор различных инструментов для статического анализа, включая Bandit и Radon.

Bandit находит уязвимости в коде, а Radon генерирует метрики о сложности и удобстве сопровождения кода. Согласно индексу удобства сопровождения Radon приложение получает высокую оценку “A”.

Читайте также:

Читайте нас в TelegramVK и Дзен


Перевод статьи Chris Bauer: How to Use Static Analysis Tools on Python Code

Предыдущая статьяЧистота и порядок: 3 правила для идеальной базы кода
Следующая статьяСоздание пользовательских хуков React: полное руководство