Upload
ivan-tsyganov
View
437
Download
4
Embed Size (px)
Citation preview
Цыганов Иван Positive Technologies
Почему 100% покрытие это плохо
Обо мне
✤ Спикер PyCon Russia 2016, PiterPy, PyCon Siberia 2016
✤ Люблю OpenSource
✤ Не умею frontend
✤ 15 лет практического опыта на рынке ИБ
✤ Более 650 сотрудников в 9 странах
✤ Каждый год находим более 200 уязвимостей нулевого дня
✤ Проводим более 200 аудитов безопасности в крупнейших компаниях мира ежегодно
MaxPatrol
✤ Pentest. Тестирование на проникновение.
✤ Audit. Системные проверки.
✤ Compliance. Соответствие стандартам.
✤ Одна из крупнейших баз знаний в мире
Система контроля защищенности и соответствия стандартам.
✤ Тестирование на проникновение (Pentest)
✤ Системные проверки (Audit)
✤ Соответствие стандартам (Compliance)
✤ Одна из крупнейших баз знаний в мире
Система контроля защищенности и соответствия стандартам.
✤Системные проверки (Audit)
MaxPatrol
> 50 000 строк кода
Зачем мы тестируем?
✤ Уверенность, что написанный код работает
✤ Ревью кода становится проще
✤ Гарантия, что ничего не сломалось при изменениях
Зачем проверять покрытие?
✤ Видно какой именно код протестирован
✤ Позволяет увидеть все ветви исполнения
Зачем проверять покрытие?
✤ Видно какой именно код протестирован
✤ Позволяет увидеть все ветви исполнения
✤ Метрика качества тестов (?)
Зачем нам 100%?
✤ Ачивка «У нас в проекте 100% coverage»
Зачем нам 100%?
✤ Ачивка «У нас в проекте 100% coverage»
✤ Уверенность, что код протестирован полностью
100% coverage != 100% протестировано
coverage.py
✤ Позволяет проверить покрытие кода тестами
✤ Есть плагин для pytest
✤ В основном работает
coverage.py
def get_longest(a, b): if len(a) > len(b): return a return b
assert get_longest([1,2,3], [4,5]) == [1,2,3] assert get_longest([1,2], [3,4,5]) == [3,4,5]
coverage.py
def apply_discount(prices): result = {'Total': sum(prices)} if result['Total'] >= 1000: result['Discount'] = result['Total'] * 0.25 return result['Total'] - result['Discount'] assert apply_discount([400, 600]) == 750
Name Stmts Miss Cover Missing ---------------------------------------------------------- samples/apply_discount.py 5 0 100%
coverage.py
def apply_discount(prices): result = {'Total': sum(prices)} if result['Total'] >= 1000: result['Discount'] = result['Total'] * 0.25 return result['Total'] - result['Discount'] assert apply_discount([400, 600]) == 750
>>> apply_discount([200])
coverage.py
def apply_discount(prices): result = {'Total': sum(prices)} if result['Total'] >= 1000: result['Discount'] = result['Total'] * 0.25 return result['Total'] - result['Discount'] assert apply_discount([400, 600]) == 750
>>> apply_discount([200]) Traceback (most recent call last): File "<stdin>", line 1, in <module> File "<stdin>", line 5, in apply_discount KeyError: 'Discount'
coverage.py --branch
1 def apply_discount(prices): 2 result = {'Total': sum(prices)} 3 if result['Total'] >= 1000: 4 result['Discount'] = result['Total'] * 0.25 5 return result['Total'] - result['Discount'] 6 7 assert apply_discount([400, 600]) == 750
Name Stmts Miss Branch BrPart Cover Missing --------------------------------------------------------------------- samples/apply_discount.py 5 0 2 1 85.71% 3 ->5
coverage.py --branch
1 def apply_discount(prices): 2 result = {'Total': sum(prices)} 3 if result['Total'] >= 1000: 4 result['Discount'] = result['Total'] * 0.25 5 return result['Total'] - result['Discount'] 6 7 assert apply_discount([400, 600]) == 750
Name Stmts Miss Branch BrPart Cover Missing --------------------------------------------------------------------- samples/apply_discount.py 5 0 2 1 85.71% 3 ->5
Как считать покрытие?
Все строкиРеально выполненные
строки- Непокрытые строки=
Все строки
Source
coverage.parser.PythonParser
Statements
coverage.parser.PythonParser
✤ Обходит все токены и отмечает «интересные» факты
✤ Компилирует код. Обходит code-object и сохраняет номера строк
Обход токенов
✤ Запоминает определения классов
✤ «Сворачивает» многострочные выражения
✤ Исключает комментарии
Обход байткода
✤ Полностью повторяет метод dis.findlinestarts
✤ Анализирует code_obj.co_lnotab
✤ Генерирует пару (номер байткода, номер строки)
Как считать coverage --branch?
Все переходыРеально выполненные
переходы- Непокрытые переходы=
Все переходы
Source
coverage.parser.AstArcAnalyzer
(from_line, to_line)
coverage.parser.PythonParser
coverage.parser.AstArcAnalyzer
✤ Обходит AST с корневой ноды
✤ Обрабатывает отдельно каждый тип нод отдельно
Обработка ноды
class While(stmt): _fields = ( 'test', 'body', 'orelse', )
while i<10: print(i) i += 1
Обработка ноды
class While(stmt): _fields = ( 'test', 'body', 'orelse', )
while i<10: print(i) i += 1 else: print('All done')
Выполненные строки
sys.settrace(tracefunc)Set the system’s trace function, which allows you to implement a Python source code debugger in Python.
Trace functions should have three arguments: frame, event, and arg. frame is the current stack frame. event is a string: 'call', 'line', 'return', 'exception', 'c_call', 'c_return', or 'c_exception'. arg depends on the event type.
PyTracer «call» event
✤ Сохраняем данные предыдущего контекста
✤ Начинаем собирать данные нового контекста
✤ Учитываем особенности генераторов
PyTracer «line» event
✤ Запоминаем выполняемую строку
✤ Запоминаем переход между строками
PyTracer «return» event
✤ Отмечаем выход из контекста
✤ Помним о том, что yield это тоже return
Отчет
✤ Что выполнялось
✤ Что должно было выполниться
✤ Ругаемся
Зачем такие сложности?
1 for i in some_list: 2 if i == 'Hello': 3 print(i + ' World!') 4 elif i == 'Skip': 5 continue 6 else: 7 break 8 else: 9 print(r'¯\_(ツ)_/¯')
Серебряная пуля?
Не совсем…
Что может пойти не так?
def make_user(name, email): return dict( ID=get_id(), Name=name, Role='Editor' if check_employee(email) else 'Guest' )
Что может пойти не так?
def positive_squares(items): return map(lambda x: x **2 if x>0 else x, items)
Что может пойти не так?
def positive_squares(items): return [ item **2 for item in items if item>0 ]
Что может пойти не так?
def positive_squares(items): return [ item **2 for item in items if item>0 ]
def positive_squares(items): return map(lambda x: x **2 if x>0 else x, items)
def make_user(name, email): return dict( ID=get_id(), Name=name, Role='Editor' if check_employee(email) else 'Guest' )
Что может пойти не так?
def positive_squares(items): return [ item **2 for item in items if item>0 ]
def positive_squares(items): return map(lambda x: x **2 if x>0 else x, items)
def make_user(name, email): return dict( ID=get_id(), Name=name, Role='Editor' if check_employee(email) else 'Guest' )
Непокрываемый код
def some_method(a, b, c): if a and b or c: return True return False
sys.settrace(tracefunc)
✤ Устанавливаем свою функцию трассировки
✤ Смотрим что происходит и делаем выводы
sys.settrace(tracefunc)
Ограниченное количество событий:✤ call ✤ line ✤ return ✤ exception
sys.settrace(tracefunc)
Ограниченное количество событий:✤ call ✤ line ✤ return ✤ exception
ast.NodeTransformer
✤ Обходим ноды
✤ Оборачиваем в «нечто» каждую ноду
✤ Запускаем и отслеживаем что выполнялось
ast.NodeTransformer
✤ Сложно обернуть код, не изменив логику
✤ Не все ноды можно обернуть
ast.NodeTransformer
✤ Сложно обернуть код, не изменив логику
✤ Не все ноды можно обернуть
Идея
✤ Перехватить контроль во время импорта
✤ Обойти байткод модуля
✤ Добавить вызов функции
✤ Собрать code-object
Идея
✤ Перехватить контроль во время импорта
✤ Обойти байткод модуля
✤ Добавить вызов функции
✤ Собрать code-object
OpTracehttps: //github.com/tsyganov-ivan/OpTrace
План
✤ Устанавливаем import hook
✤ Модифицируем и подменяем code-object
✤ Запускаем тесты
✤ Анализируем результаты
Import hook. Finder.
✤ Пропускаем неинтересные модули
✤ Создаем свой Loader для нужных модулей
Import hook. Loader.
✤ Получаем байт-код модуля
✤ Получаем исходный код модуля
✤ Модифицируем байт-код
✤ Возвращаем измененный байт-код
План
✤ Устанавливаем import hook
✤ Модифицируем и подменяем code-object
✤ Запускаем тесты
✤ Анализируем результаты
Wrapper. Модифицируем байт-код.
# ... wrapper = Wrapper( trace_func=self.make_visitor(module_name), mark_func=self.make_marker(module_name, source) ) new_code = wrapper.wrap_code(code) return new_code # ...
Wrapper. Callbacks.
def make_marker(self, module, source): self.module_opcodes[module] = FileOpcode(module, source) def mark(codeobj_id, opcode): self.module_opcodes[module].add(codeobj_id, opcode.offset, opcode) return mark def make_visitor(self, module): def visit(codeobj_id, opcode): self.module_opcodes[module].visit(codeobj_id, opcode.offset, opcode) return visit
dis.dis(some_method)
def some_method(a, b, c): if a and b or c: return True return False
2 0 LOAD_FAST 0 (a) 3 POP_JUMP_IF_FALSE 18 6 LOAD_FAST 1 (b) 9 POP_JUMP_IF_TRUE 18 12 LOAD_FAST 2 (c) 15 POP_JUMP_IF_FALSE 22
3 >> 18 LOAD_CONST 1 (True) 21 RETURN_VALUE
4 >> 22 LOAD_CONST 2 (False) 25 RETURN_VALUE
dis.get_instructions(some_method)
def some_method(a, b, c): if a and b or c: return True return False
Instruction(opname='LOAD_FAST', opcode=124, arg=0, ... Instruction(opname='POP_JUMP_IF_FALSE', opcode=114, ...Instruction(opname='LOAD_FAST', opcode=124, arg=1, ... Instruction(opname='POP_JUMP_IF_TRUE', opcode=115, ... Instruction(opname='LOAD_FAST', opcode=124, arg=2, ... Instruction(opname='POP_JUMP_IF_FALSE', opcode=114, ... Instruction(opname='LOAD_CONST', opcode=100, arg=1, ... Instruction(opname='RETURN_VALUE', opcode=83, arg=None ... Instruction(opname='LOAD_CONST', opcode=100, arg=2, ... Instruction(opname='RETURN_VALUE', opcode=83, arg=None ...
Wrap code. Все опкоды.
✤ Просто вызываем функцию, переданную из Loader’a
self.mark(codeobj_id, st)
Wrap code. Трассировка.
✤ Добавляем lambda-функцию в константы
constants.append( lambda co_id=codeobj_id, opcode=st: self.visit(co_id, opcode) )
Wrap code. Трассировка.
✤ Добавляем lambda-функцию в константы
constants.append( lambda co_id=codeobj_id, opcode=st: self.visit(co_id, opcode) )
PyCodeObject* PyCode_New( /* ... */ PyObject *code, PyObject *consts, PyObject *names, /* ... */)
Wrap code. Трассировка.
✤ Добавляем lambda-функцию в константы
✤ Добавляем байт-код для вызова
def make_trace(self, constant_index): yield opcode.opmap['LOAD_CONST'] yield from self.make_args(constant_index) yield opcode.opmap['CALL_FUNCTION'] yield from self.make_args(0) yield opcode.opmap['POP_TOP']
Wrap code. Трассировка.
✤ Добавляем lambda-функцию в константы
✤ Добавляем байт-код для вызова
✤ Не забываем про оригинальный опкод и его параметры!
Wrap code. Трассировка.
✤ Добавляем lambda-функцию в константы
✤ Добавляем байт-код для вызова
✤ Не забываем про оригинальный опкод и его параметры!
✤ Учитываем смещение в последующих опкодах
Wrap сode. Результат.
def some_method(a, b, c): if a and b or c: return True return False
2 0 LOAD_FAST 0 (a) 3 POP_JUMP_IF_FALSE 18 6 LOAD_FAST 1 (b) 9 POP_JUMP_IF_TRUE 18 12 LOAD_FAST 2 (c) 15 POP_JUMP_IF_FALSE 22
3 >> 18 LOAD_CONST 1 (True) 21 RETURN_VALUE
4 >> 22 LOAD_CONST 2 (False) 25 RETURN_VALUE
Wrap сode. Результат.
6 LOAD_FAST 1 (b) 9 POP_JUMP_IF_TRUE 18 . . . 3 >> 18 LOAD_CONST 1 (True) 21 RETURN_VALUE
def some_method(a, b, c): if a and b or c: return True return False
Wrap сode. Результат.
20 LOAD_CONST 5 (<function ...<locals>.<lambda>) 23 CALL_FUNCTION 0 (0 positional, 0 keyword pair) 26 POP_TOP 27 LOAD_FAST 1 (b) 30 LOAD_CONST 6 (<function ...<locals>.<lambda>) 33 CALL_FUNCTION 0 (0 positional, 0 keyword pair) 36 POP_TOP 37 POP_JUMP_IF_TRUE 60 . . . >> 60 LOAD_CONST 9 (<function ...<locals>.<lambda>) 63 CALL_FUNCTION 0 (0 positional, 0 keyword pair) 66 POP_TOP 67 LOAD_CONST 1 (True) 70 LOAD_CONST 10 (<function ...<locals>.<lambda>) 73 CALL_FUNCTION 0 (0 positional, 0 keyword pair) 76 POP_TOP 77 RETURN_VALUE
План
✤ Устанавливаем import hook
✤ Модифицируем и подменяем code-object
✤ Запускаем тесты
✤ Анализируем результаты
Тестируем. Все опкоды.
def some_method(a, b, c): if a and b or c: return True return False some_method(1, 1, 0)
Instruction(opname='LOAD_FAST', opcode=124, arg=0, ... Instruction(opname='POP_JUMP_IF_FALSE', opcode=114, ...Instruction(opname='LOAD_FAST', opcode=124, arg=1, ... Instruction(opname='POP_JUMP_IF_TRUE', opcode=115, ... Instruction(opname='LOAD_FAST', opcode=124, arg=2, ... Instruction(opname='POP_JUMP_IF_FALSE', opcode=114, ... Instruction(opname='LOAD_CONST', opcode=100, arg=1, ... Instruction(opname='RETURN_VALUE', opcode=83, arg=None ... Instruction(opname='LOAD_CONST', opcode=100, arg=2, ... Instruction(opname='RETURN_VALUE', opcode=83, arg=None ...
Тестируем. Непокрытые опкоды.
def some_method(a, b, c): if a and b or c: return True return False some_method(1, 1, 0)
Instruction(opname='LOAD_FAST', arg=2, argval='c', argrepr='c', offset=12)Instruction(opname='POP_JUMP_IF_FALSE', arg=22, argval=22, argrepr='')Instruction(opname='LOAD_CONST', arg=2, argval=False, starts_line=3)Instruction(opname='RETURN_VALUE', arg=None, argval=None)
Тестируем. Непокрытые опкоды.
def some_method(a, b, c): if a and b or c: return True return False some_method(1, 1, 0)
Instruction(opname='LOAD_FAST', arg=2, argval='c', argrepr='c', offset=12)Instruction(opname='POP_JUMP_IF_FALSE', arg=22, argval=22, argrepr='')Instruction(opname='LOAD_CONST', arg=2, argval=False, starts_line=3)Instruction(opname='RETURN_VALUE', arg=None, argval=None)
План
✤ Устанавливаем import hook
✤ Модифицируем и подменяем code-object
✤ Запускаем тесты
✤ Анализируем результаты
Способа однозначно перевести любой опкод к строке кода не существует
Способа однозначно перевести любой опкод к строке кода не существует
Отчет. Ищем строки.
✤ При обходе сохраняем текущую строку
✤ При выводе опкода выводим текущую строку
Отчет. Ищем строки.
if a and b or c: Instruction(opname='LOAD_FAST', arg=2, argval='c', argrepr='c', offset=12)
if a and b or c: Instruction(opname='POP_JUMP_IF_FALSE', arg=22, argval=22, argrepr='')
return False Instruction(opname='LOAD_CONST', arg=2, argval=False, starts_line=3)
return FalseInstruction(opname='RETURN_VALUE', arg=None, argval=None)
✤ При обходе сохраняем текущую строку
✤ При выводе опкода выводим текущую строку
✤ При обходе сохраняем текущую строку
✤ При выводе опкода выводим текущую строку
Отчет. Ищем строки.
if a and b or c: Instruction(opname='LOAD_FAST', arg=2, argval='c', argrepr='c', offset=12)
if a and b or c: Instruction(opname='POP_JUMP_IF_FALSE', arg=22, argval=22, argrepr='')
return False Instruction(opname='LOAD_CONST', arg=2, argval=False, starts_line=3)
return FalseInstruction(opname='RETURN_VALUE', arg=None, argval=None)
Отчет. Позиция в строке.
✤ Строка уже известна
✤ Вычислим позицию в строке для каждого типа опкода
Отчет. Позиция в строке.
if a and b or c:
Instruction( opname='LOAD_FAST', opcode=124, offset=12, starts_line=None, is_jump_target=True, arg=2, argval='c', argrepr=‘c' )
Отчет. Позиция в строке.
if a and b or c:
Instruction( opname='LOAD_FAST', opcode=124, offset=12, starts_line=None, is_jump_target=True, arg=2, argval='c', argrepr=‘c' )
Instruction( opname='POP_JUMP_IF_FALSE', opcode=114, offset=15, starts_line=None, is_jump_target=False, arg=22, argval=22, argrepr='' )
Отчет. Позиция в строке.
✤ Покрыв 70 типов опкодов удалось получить отчет
✤ Многие опкоды невозможно покрыть
Отчет. Позиция в строке.
✤ Покрыв 70 типов опкодов удалось получить отчет
✤ Многие опкоды невозможно покрыть
----------- Report tests.test_code -------------- 1: if a and b or c: ^ LOAD_FAST 1: if a and b or c: ^^^^^^^^^^^^^^^^ POP_JUMP_IF_FALSE 3: return False ^^^^^ LOAD_CONST 3: return False ^^^^^^^^^^^^ RETURN_VALUE
Отчет. Позиция в строке.
----------- Report tests.test_code -------------- 1: if a and b or c: ^ LOAD_FAST 1: if a and b or c: ^^^^^^^^^^^^^^^^ POP_JUMP_IF_FALSE 3: return False ^^^^^ LOAD_CONST 3: return False ^^^^^^^^^^^^ RETURN_VALUE
✤ Покрыв 70 типов опкодов удалось получить отчет
✤ Многие опкоды невозможно покрыть
OpTrace. Что не так?
✤ Переменные в отчете не всегда отмечаются правильно
✤ Часть опкодов приходится пропускать
✤ Производительность неизвестна
OpTrace. Что так?
✤ Трассировка работает хорошо
✤ Идея имеет право на жизнь
OpTrace. Планы.
✤ Услышать мнение и критику сообщества
OpTrace. Планы.
✤ Услышать мнение и критику сообщества
✤ Рефакторинг
✤ Тестирование
✤ Работа над улучшением отчета
✤ Плагин для pytest
К чему это все?
Библиотеки несовершенны
100% coverage расслабляет команду
Библиотеки несовершенны
100% coverage расслабляет команду
Библиотеки несовершенны
100% coverage - просто ачивка