Repository navigation
Expand file tree
/
Copy pathvulnerability-checker.py
More file actions
238 lines (196 loc) · 9.78 KB
/
Copy pathvulnerability-checker.py
File metadata and controls
238 lines (196 loc) · 9.78 KB
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
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
#!/usr/bin/env python3
import sys
import argparse
import os
import re
def _parse_vulnerability_spec(spec):
"""Parse a vulnerability spec into (operator, version)."""
if spec.strip() == '*':
return '*', '*'
match = re.match(r'^(<=|>=|<|>)?\s*(\S+)$', spec.strip())
if not match:
return None
operator = match.group(1) or '=='
version = match.group(2)
return operator, version
def _parse_version_parts(version):
"""Extract numeric version parts from a semver-like string."""
match = re.match(r'^v?(\d+(?:\.\d+)*)', version.strip())
if not match:
return None
return tuple(int(part) for part in match.group(1).split('.'))
def _compare_versions(installed_version, vuln_version):
"""Compare two semver-like versions numerically.
Returns -1, 0, 1 for less-than, equal, greater-than.
Returns None when either version cannot be parsed numerically.
"""
installed_parts = _parse_version_parts(installed_version)
vuln_parts = _parse_version_parts(vuln_version)
if installed_parts is None or vuln_parts is None:
return None
max_len = max(len(installed_parts), len(vuln_parts))
installed_parts = installed_parts + (0,) * (max_len - len(installed_parts))
vuln_parts = vuln_parts + (0,) * (max_len - len(vuln_parts))
if installed_parts < vuln_parts:
return -1
if installed_parts > vuln_parts:
return 1
return 0
def _is_vulnerable(installed_version, operator, vuln_version):
"""Evaluate whether an installed version satisfies vulnerability spec."""
if operator == '*':
return True
comparison = _compare_versions(installed_version, vuln_version)
if operator == '==':
if comparison is None:
return installed_version == vuln_version
return comparison == 0
if comparison is None:
return False
if operator == '<':
return comparison < 0
if operator == '<=':
return comparison <= 0
if operator == '>':
return comparison > 0
if operator == '>=':
return comparison >= 0
return False
def find_vulnerabilities(lock_file_path, vulnerabilities_file_path, debug=False, silent=False):
"""
Scans a single lock file for packages listed in the vulnerabilities file.
This function uses a regular expression to find all occurrences of a package
definition (handling various lockfile formats and indentations) and then
checks a "window" of text following each match for a vulnerable version.
Args:
lock_file_path (str): The path to the yarn.lock or package-lock.json file.
vulnerabilities_file_path (str): The path to the text file listing
vulnerabilities in "package-name version" format.
debug (bool, optional): If True, prints verbose debug output to stderr. Defaults to False.
Returns:
list: A list of unique (name, version) tuples for each vulnerability found.
"""
def write_debug(message):
"""Helper function to print to the debug console if enabled."""
if debug:
# Print to stderr to separate debug info from standard output
print(message, file=sys.stderr)
try:
with open(vulnerabilities_file_path, 'r', encoding='utf-8') as f:
vulnerable_packages = []
for line in f:
line = line.strip()
if line and not line.startswith('#'):
# Accept lines in "package-name version" format and ignore trailing comments.
# Version spec supports exact version and range operators like <1.15.0.
parsed = re.match(r'^(\S+)\s+(\S+)(?:\s+#.*)?$', line)
if parsed:
spec = _parse_vulnerability_spec(parsed.group(2))
if spec:
operator, version = spec
vulnerable_packages.append((parsed.group(1), operator, version, parsed.group(2)))
except FileNotFoundError:
print(f"Error: Vulnerabilities file not found at '{vulnerabilities_file_path}'")
return []
try:
with open(lock_file_path, 'r', encoding='utf-8') as f:
lock_content = f.read()
except FileNotFoundError:
print(f"Error: Lock file not found at '{lock_file_path}'")
return []
found_vulns = []
# Match version fields in both yarn.lock and package-lock.json sections.
json_version_regex = re.compile(r'"_?version"\s*:\s*"([^"]+)"')
yarn_version_regex = re.compile(r'\bversion\s+"([^"]+)"')
# Find package definition keys in both lockfile formats.
definition_pattern = re.compile(r'^\s*(".+?"|[^:\s]+)\s*:', re.MULTILINE)
write_debug(f"\n--- Debug Log for {lock_file_path} ---")
for name, operator, vuln_version, raw_spec in vulnerable_packages:
write_debug(f"\n[CHECKING] Vulnerability: {name} {raw_spec}")
for match in definition_pattern.finditer(lock_content):
# The part of the string that defines the package (e.g., `"chalk"` or `"chalk@^4.0.0"`)
definition = match.group(1)
# Check if this definition is for the package we are looking for.
# This handles all cases: quoted, unquoted, yarn version ranges, and node_modules paths.
is_match = (
definition.startswith(f'"{name}@') or
definition.startswith(f'{name}@') or
definition == f'"node_modules/{name}"'
)
if is_match:
# Keep a larger bounded window to find a nearby version field reliably.
# package-lock entries place "version" very close to the package key,
# while yarn entries include a nearby `version "..."` line.
section_start = match.start()
search_window = lock_content[section_start:section_start + 2000]
version_match = json_version_regex.search(search_window) or yarn_version_regex.search(search_window)
if version_match:
installed_version = version_match.group(1)
if not silent:
print(f" 👀 Package {name} found with version {installed_version} (checking spec: {raw_spec}).")
write_debug(f" [FOUND] Found an installed version: {installed_version}")
if _is_vulnerable(installed_version, operator, vuln_version):
if not silent:
print(f" 🚨 MATCH: {name} version {installed_version} matches vulnerability spec {raw_spec}.")
found_vulns.append((name, raw_spec))
else:
write_debug(f" [NO MATCH] Installed version ({installed_version}) does not match vulnerability spec ({raw_spec}).")
else:
if not silent:
print(f" ⚠️ Warning: Version number for package {name} not found!")
return list(set(found_vulns)) # Return unique findings
def main():
"""Main function to parse arguments and run the vulnerability scanner on a folder."""
parser = argparse.ArgumentParser(
description="A quick and simple script to scan a folder for vulnerable packages in lock files.",
epilog="Example: python vulnerability-checker.py ./project vulnerabilities.txt --debug"
)
parser.add_argument("folder", help="Path to the folder to scan for lock files.")
parser.add_argument("vulnfile", help="Path to the text file listing vulnerable packages (format: 'package-name version').")
parser.add_argument("--debug", action="store_true", help="Enable verbose debug output on the console.")
parser.add_argument("--silent", action="store_true", help="Suppress all output except the final summary.")
args = parser.parse_args()
vuln_file = args.vulnfile
target_folder = args.folder
all_found_vulns = {}
lock_files_scanned = False
if not args.silent:
print(f"🔍 Scanning folder '{target_folder}' for lock files...")
# Walk through the directory tree
for root, dirs, files in os.walk(target_folder):
# Exclude node_modules directories from the search
if 'node_modules' in dirs:
dirs.remove('node_modules')
for filename in files:
if filename in ('package-lock.json', 'yarn.lock'):
lock_files_scanned = True
lock_file_path = os.path.join(root, filename)
if not args.silent:
print(f"\n-- Scanning '{lock_file_path}'...")
found = find_vulnerabilities(lock_file_path, vuln_file, args.debug, args.silent)
if found:
if not args.silent:
print("\n 🚨 Found vulnerabilities from the list were found.")
all_found_vulns[lock_file_path] = found
else:
if not args.silent:
print("\n ✅ No vulnerabilities from the list were found.")
if not lock_files_scanned:
if not args.silent:
print("\nNo 'package-lock.json' or 'yarn.lock' files were found in the specified folder.")
sys.exit(0)
print("\n" + "="*33)
print(" 🚨 VULNERABILITIES SUMMARY 🚨")
print("="*33)
if all_found_vulns:
for file_path, vulns in all_found_vulns.items():
print(f"\nIn file: {file_path}")
for name, version in sorted(vulns):
print(f" - {name} {version}")
print("\nPlease update or replace these packages.")
sys.exit(1) # Exit with a non-zero status code to indicate failure
else:
print("\n✅ No vulnerable packages from your list were found in any scanned files.\n")
sys.exit(0) # Exit with a zero status code for success
if __name__ == "__main__":
main()