-
Notifications
You must be signed in to change notification settings - Fork 16
Expand file tree
/
Copy pathvp-scanner.php
More file actions
354 lines (301 loc) · 12 KB
/
Copy pathvp-scanner.php
File metadata and controls
354 lines (301 loc) · 12 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
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
<?php
// don't call the file directly
defined( 'ABSPATH' ) || die( 0 );
class VP_FileScan {
var $path;
var $last_dir = null;
var $offset = 0;
var $ignore_symlinks = false;
function __construct( $path, $ignore_symlinks = false ) {
if ( is_dir( $path ) )
$this->last_dir = $this->path = @realpath( $path );
else
$this->last_dir = $this->path = dirname( @realpath( $path ) );
$this->ignore_symlinks = $ignore_symlinks;
}
function get_files( $limit = 100 ) {
$files = array();
if ( is_dir( $this->last_dir ) ) {
$return = $this->_scan_files( $this->path, $files, $this->offset, $limit, $this->last_dir );
$this->offset = $return[0];
$this->last_dir = $return[1];
if ( count( $files ) < $limit )
$this->last_dir = false;
}
return $files;
}
function _scan_files( $path, &$files, $offset, $limit, &$last_dir ) {
$_offset = 0;
if ( is_readable( $path ) && $handle = opendir( $path ) ) {
while( false !== ( $entry = readdir( $handle ) ) ) {
if ( '.' == $entry || '..' == $entry )
continue;
++$_offset;
$full_entry = $path . DIRECTORY_SEPARATOR . $entry;
$next_item = ltrim( str_replace( $path, '', $last_dir ), DIRECTORY_SEPARATOR );
$next = preg_split( '#(?<!\\\\)' . preg_quote( DIRECTORY_SEPARATOR, '#' ) . '#', $next_item, 2 );
// Skip if the next item is not found.
if ( !empty( $next[0] ) && $next[0] != $entry )
continue;
if ( rtrim( $last_dir, DIRECTORY_SEPARATOR ) == rtrim( $path, DIRECTORY_SEPARATOR ) && $_offset < $offset )
continue;
if ( $this->ignore_symlinks && is_link( $full_entry ) )
continue;
if ( rtrim( $last_dir, DIRECTORY_SEPARATOR ) == rtrim( $path, DIRECTORY_SEPARATOR ) ) {
// Reset last_dir and offset when we reached the previous last_dir value.
$last_dir = '';
$offset = 0;
}
if ( is_file( $full_entry ) ) {
if ( !vp_is_interesting_file( $full_entry ) )
continue;
$_return_offset = $_offset;
$_return_dir = dirname( $full_entry );
$files[] = $full_entry;
} elseif ( is_dir( $full_entry ) ) {
list( $_return_offset, $_return_dir ) = $this->_scan_files( $full_entry, $files, $offset, $limit, $last_dir );
}
if ( count( $files ) >= $limit ) {
closedir( $handle );
return array( $_return_offset ?? 0, $_return_dir ?? null );
}
}
closedir( $handle );
}
return array( $_offset, $path );
}
}
function vp_get_real_file_path( $file_path, $tmp_file = false ) {
global $site, $site_id;
$site_id = !empty( $site->id ) ? $site->id : $site_id;
if ( !$tmp_file && !empty( $site_id ) && function_exists( 'determine_file_type_path' ) ) {
$path = determine_file_type_path( $file_path );
$file = file_by_path( $site_id, $path );
if ( !$file )
return false;
return $file->get_unencrypted();
}
return !empty( $tmp_file ) ? $tmp_file : $file_path;
}
function vp_is_interesting_file($file) {
$scan_only_regex = apply_filters( 'scan_only_extension_regex', '#\.(ph(p3|p4|p5|p|tml)|html|js|htaccess)$#i' );
return preg_match( $scan_only_regex, $file );
}
/**
* Uses the PHP tokenizer to split a file into 3 arrays: PHP code with no comments,
* PHP code with comments, and HTML/JS code. Helper wrapper around split_to_php_html()
*
* @param string $file The file path to read and parse
* @return array An array with 3 arrays of lines
*/
function split_file_to_php_html( $file ) {
$source = @file_get_contents( $file );
if ( $source === false ) {
$source = '';
}
return split_to_php_html( $source );
}
/**
* Uses the PHP tokenizer to split a string into 3 arrays: PHP code with no comments,
* PHP code with comments, and HTML/JS code.
*
* @param string $source The file path to read and parse.
* @return array An array with 3 arrays of lines
*/
function split_to_php_html( $source ) {
$tokens = @token_get_all( $source );
$ret = array( 'php' => array(), 'php-with-comments' => array(), 'html' => array() );
$current_line = 0;
$mode = 'html'; // need to see an open tag to switch to PHP mode
foreach ( $tokens as $token ) {
if ( ! is_array( $token ) ) {
// single character, can't switch our mode; just add it and continue
// if it's PHP, should go into both versions; mode 'php' will do that
add_text_to_parsed( $ret, $mode, $current_line, $token );
$current_line += substr_count( $token, "\n" );
} else {
// more complex tokens is the interesting case
list( $id, $text, $line ) = $token;
if ( 'php' === $mode ) {
// we're in PHP code
// might be a comment
if ( T_COMMENT === $id || T_DOC_COMMENT === $id ) {
// add it to the PHP with comments array only
add_text_to_parsed( $ret, 'php-with-comments', $current_line, $text );
// special case for lines like: " // comment\n":
// if we're adding a comment with a newline, and the 'php' array current line
// has no trailing newline, add one
if ( substr_count( $text, "\n" ) >= 1 && ! empty( $ret['php'][ $current_line ] ) && 0 === substr_count( $ret['php'][ $current_line ], "\n" ) ) {
$ret['php'][ $current_line ] .= "\n";
}
// make sure to count newlines in comments
$current_line += substr_count( $text, "\n" );
continue;
}
// otherwise add it to both the PHP array and the with comments array
add_text_to_parsed( $ret, $mode, $current_line, $text );
// then see if we're breaking out
if ( T_CLOSE_TAG === $id ) {
$mode = 'html';
}
} else if ( 'html' === $mode ) {
// we're in HTML code
// if we see an open tag, switch to PHP
if ( T_OPEN_TAG === $id || T_OPEN_TAG_WITH_ECHO === $id ) {
$mode = 'php';
}
// add to the HTML array (or PHP if it was an open tag)
// if it is PHP, this will add it to both arrays, which is what we want
add_text_to_parsed( $ret, $mode, $current_line, $text );
}
$current_line += substr_count( $text, "\n" );
}
}
return $ret;
}
/**
* Helper function for split_file_to_php_html; adds a chunk of text to the arrays we'll return.
*
* @param array $parsed The array containing all the languages we'll return.
* @param string $prefix The prefix for the languages we want to add this text to.
* @param int $start_line_number The line number that this text goes on.
* @param string $all_text The text to add.
*/
function add_text_to_parsed( &$parsed, $prefix, $start_line_number, $all_text ) {
$line_number = $start_line_number;
// whitespace tokens may span multiple lines; we need to split them up so that the indentation goes on the next line
$fragments = explode( "\n", $all_text );
foreach ( $fragments as $i => $fragment ) {
// each line needs to end with a newline to match the behavior of file()
if ( $i < count( $fragments ) - 1 ) {
$text = $fragment . "\n";
} else {
$text = $fragment;
}
if ( '' === $text ) {
// check for the empty string explicitly, rather than using empty()
// otherwise things like a '0' token will get skipped, because PHP is stupid
continue;
}
if ( ! isset( $parsed[ $prefix ][ $line_number ] ) ) {
$parsed[ $prefix ][ $line_number ] = '';
}
$parsed[ $prefix ][ $line_number ] .= $text;
if ( 'php' == $prefix ) {
if ( ! isset( $parsed[ 'php-with-comments' ][ $line_number ] ) ) {
$parsed[ 'php-with-comments' ][ $line_number ] = '';
}
$parsed[ 'php-with-comments' ][ $line_number ] .= $text;
}
// the caller will also update their line number based on the number of \n characters in the text
++$line_number;
}
}
/**
* Scans a file with the registered signatures. To report a security notice for a specified signature, all its regular
* expressions should result in a match.
* @param $file the filename to be scanned.
* @param null $tmp_file used if the file to be scanned doesn't exist or if the filename doesn't match vp_is_interesting_file().
* @return array|bool false if no matched signature is found. A list of matched signatures otherwise.
*/
function vp_scan_file( $file, $tmp_file = null, $use_parser = false ) {
$real_file = vp_get_real_file_path( $file, $tmp_file );
$file_size = file_exists( $real_file ) ? @filesize( $real_file ) : 0;
if ( !is_readable( $real_file ) || !$file_size || $file_size > apply_filters( 'scan_max_file_size', 3 * 1024 * 1024 ) ) { // don't scan empty or files larger than 3MB.
return false;
}
$file_content = null;
$file_parsed = null;
$skip_file = apply_filters_ref_array( 'pre_scan_file', array ( false, $file, $real_file, &$file_content ) );
if ( false !== $skip_file ) { // maybe detect malware without regular expressions.
return $skip_file;
}
if ( !vp_is_interesting_file( $file ) ) { // only scan relevant files.
return false;
}
if ( !isset( $GLOBALS['vp_signatures'] ) ) {
$GLOBALS['vp_signatures'] = array();
}
$found = array ();
foreach ( $GLOBALS['vp_signatures'] as $signature ) {
if ( !is_object( $signature ) || !isset( $signature->patterns ) ) {
continue;
}
// if there is no filename_regex, we assume it's the same of vp_is_interesting_file().
if ( empty( $signature->filename_regex ) || preg_match( '#' . addcslashes( $signature->filename_regex, '#' ) . '#i', $file ) ) {
if ( null === $file_content || !is_array( $file_content ) ) {
$file_content = @file( $real_file );
if ( $file_content === false ) {
return false;
}
if ( $use_parser ) {
$file_parsed = split_file_to_php_html( $real_file );
}
}
$is_vulnerable = true;
$code = $file_content;
if ( $use_parser ) {
// use the language specified in the signature if it has one
if ( ! empty( $signature->target_language ) && array_key_exists( $signature->target_language, $file_parsed ) ) {
$code = $file_parsed[ $signature->target_language ];
}
}
$matches = array();
if ( ! empty( $signature->patterns ) ) {
foreach ( $signature->patterns as $pattern ) {
$match = preg_grep( '#' . addcslashes( $pattern, '#' ) . '#im', $code );
if ( empty( $match ) ) {
$is_vulnerable = false;
break;
}
$matches += $match;
}
}
// convert the matched line to an array of details showing context around the lines
$lines = array();
$lines_parsed = array();
$line_indices_parsed = array();
if ( $use_parser ) {
$line_indices_parsed = array_keys( $code );
}
foreach ( $matches as $line => $text ) {
$lines = array_merge( $lines, range( $line - 1, $line + 1 ) );
if ( $use_parser ) {
$idx = array_search( $line, $line_indices_parsed );
// we might be looking at the first or last line; for the non-parsed case, array_intersect_key
// handles this transparently below; for the parsed case, since we have another layer of
// indirection, we have to handle that case here
$idx_around = array();
if ( isset( $line_indices_parsed[ $idx - 1 ] ) ) {
$idx_around[] = $line_indices_parsed[ $idx - 1 ];
}
$idx_around[] = $line_indices_parsed[ $idx ];
if ( isset( $line_indices_parsed[ $idx + 1 ] ) ) {
$idx_around[] = $line_indices_parsed[ $idx + 1 ];
}
$lines_parsed = array_merge( $lines_parsed, $idx_around );
}
}
$details = array_intersect_key( $file_content, array_flip( $lines ) );
$details_parsed = array();
if ( $use_parser ) {
$details_parsed = array_intersect_key( $code, array_flip( $lines_parsed ) );
}
// provide both 'matches' and 'details', as some places want 'matches'
// this matches the old behavior, which would add 'details' to some items, without replacing 'matches'
$debug_data = array( 'matches' => $matches, 'details' => $details );
if ( $use_parser ) {
$debug_data['details_parsed'] = $details_parsed;
}
// Additional checking needed?
if ( method_exists( $signature, 'get_detailed_scanner' ) && $scanner = $signature->get_detailed_scanner() )
$is_vulnerable = $scanner->scan( $is_vulnerable, $file, $real_file, $file_content, $debug_data );
if ( $is_vulnerable ) {
$found[$signature->id] = $debug_data;
if ( isset( $signature->severity ) && $signature->severity > 8 ) // don't continue scanning
break;
}
}
}
return apply_filters_ref_array( 'post_scan_file', array ( $found, $file, $real_file, &$file_content ) );
}