-
Notifications
You must be signed in to change notification settings - Fork 337
/
auto_guided_remediation.py
executable file
·159 lines (126 loc) · 5.19 KB
/
auto_guided_remediation.py
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
#!/usr/bin/env python3
"""
Proof of concept demonstrating an automated guided remediation patching workflow.
We progressively try more and more patches until tests fail.
Requires osv-scanner to be in your PATH.
"""
import os.path
import re
import subprocess
import sys
from typing import List, Tuple
PATCH_STRATEGIES = [
['--strategy=in-place'], # Try every single transitive dependency upgrade without relocking or bumping direct dependencies.json.
['--strategy=relock'], # Relock the manifest and try direct dependency bumps.
# This could also include things like:
# '--min-severity=X' Minimum severity of vulnerabilities to consider.
# '--max-depth=Y': Maximum (shortest) dependency depth
# '--disallow-major-upgrades={true/false}': Whether or not major upgrades are done.
# etc... which can help reduce/increase the scope of changes by prioritizing vulnerabilities according to these filters.
# e.g. ['--strategy=relock', '--disallow-major-upgrades=true', '--max-depth=5'], # Relock the manifest and try direct dependency bumps.
# See `osv-scanner fix --help`.
]
if len(sys.argv) < 2:
print(f'Usage: {sys.argv[0]} <project-directory>')
sys.exit(1)
directory = sys.argv[1]
osv_fix_args = sys.argv[2:]
# check if the directory is within a git repo
if subprocess.call(['git', '-C', directory, 'rev-parse']):
print(f'{directory} is not part of a git repository')
sys.exit(1)
manifest = os.path.join(directory, 'package.json')
lockfile = os.path.join(directory, 'package-lock.json')
def run_fix(n_patches: int, avoid_pkgs: List[str], strategy: List[str]) -> Tuple[List[str], int, int]:
# restore package.json & package-lock.json
subprocess.check_call(['git', 'checkout', 'package.json', 'package-lock.json'], cwd=directory)
# run osv-fix and parse changes
cmd = ['osv-scanner', 'fix', '--non-interactive', '-M', manifest, '-L', lockfile] + osv_fix_args + strategy
# 0 is a magic value that means we try all patches.
if n_patches != 0:
cmd.extend(['--apply-top', str(n_patches)])
if len(avoid_pkgs) > 0:
cmd.extend(['--disallow-package-upgrades', ','.join(avoid_pkgs)])
try:
output = subprocess.check_output(cmd, text=True)
except subprocess.CalledProcessError as e:
output = (e.stdout or '') + (e.stderr or '')
upgraded = [m[1] for m in re.finditer(r'UPGRADED-PACKAGE: (.*),(.*),(.*)', output)]
remaining_vulns = None
unfixable_vulns = None
match = re.search(r'REMAINING-VULNS:\s*(\d+)', output)
if match:
remaining_vulns = int(match.group(1))
match = re.search(r'UNFIXABLE-VULNS:\s*(\d+)', output)
if match:
unfixable_vulns = int(match.group(1))
return upgraded, remaining_vulns, unfixable_vulns
def run_loop(strategy: List[str]) -> Tuple[List[str], int, int, List[str]]:
valid = []
avoid = []
# 0 is a special value meaning that we try applying every patch. This is
# meant as a shortcut in case this would've succeeded anyway.
n_patches = 0
print('===== Attempting auto-patch with strategy', strategy, '=====')
remaining = None
total_unfixable = None
while True:
changes, remaining, unfixable = run_fix(n_patches, avoid, strategy)
if changes == valid:
# if the result of running osv-fix hasn't changed, then we've run out of patches to apply
break
print('===== Trying to upgrade:', changes, '=====')
print('===== Current blocklist:', avoid, '=====')
# check the install & tests
if subprocess.call(['npm', 'ci'], cwd=directory) or subprocess.call(['npm', 'run', 'test'], cwd=directory): # tests failed
if n_patches == 0:
# First try with every single patch.
# Record the unfixable count using this, as it represents the real
# unfixable count if every possible package upgrade was allowed.
total_unfixable = unfixable
n_patches += 1
continue
print('===== Tests failed, blocklisting upgrades =====')
# add each new package to the avoid list
for c in changes:
if c not in valid:
avoid.append(c)
print('===== Current blocklist:', avoid, '=====')
else: # tests passed
if n_patches == 0:
valid = changes
break
# try now with the next patch
valid = changes
n_patches += 1
if valid:
print()
print('===== The following packages have been changed and verified against the tests: =====')
for v in valid:
print(v)
return valid, remaining, total_unfixable, avoid
best_strategy = None
best_changes = []
best_avoid = []
best_remaining = 10000000
best_unfixable = None
for strategy in PATCH_STRATEGIES:
changes, remaining, unfixable, avoid = run_loop(strategy)
if changes and remaining < best_remaining:
best_strategy = strategy
best_changes = changes
best_avoid = avoid
best_remaining = remaining
best_unfixable = unfixable
print()
print('===== Auto-patch completed with the following changed packages =====')
print('Best strategy:', best_strategy)
for v in best_changes:
print(v)
print('The follow packages cannot be upgraded due to failing tests:')
for v in best_avoid:
print(v)
print()
print(best_remaining, 'vulnerabilities remain')
if best_unfixable:
print(best_unfixable, 'vulnerabilities are impossible to fix by package upgrades')