SecurityChecker::isDev()   A
last analyzed

Complexity

Conditions 3
Paths 2

Size

Total Lines 8
Code Lines 4

Duplication

Lines 0
Ratio 0 %

Importance

Changes 3
Bugs 0 Features 0
Metric Value
cc 3
eloc 4
c 3
b 0
f 0
nc 2
nop 1
dl 0
loc 8
rs 10
1
<?php
2
3
namespace Signify\SecurityChecker;
4
5
use Composer\Semver\Semver;
6
use FilesystemIterator;
7
use GuzzleHttp\Client as GuzzleClient;
8
use InvalidArgumentException;
9
use LogicException;
10
use RecursiveDirectoryIterator;
11
use RecursiveIteratorIterator;
12
use RecursiveRegexIterator;
13
use RegexIterator;
14
use Symfony\Component\Yaml\Yaml;
15
use ZipArchive;
16
17
class SecurityChecker
18
{
19
    public const ADVISORIES_URL = 'https://codeload.github.com/FriendsOfPHP/security-advisories/zip/master';
20
    // Don't allow execution, since we're grabbing files from a source we don't control.
21
    public const FILE_PERMISSIONS = 0666;
22
23
    private $advisories;
24
    private $options;
25
26
    /**
27
     * @param array $options The options for this checker.
28
     * @throws InvalidArgumentException if the advisories directory isn't writable.
29
     * @throws LogicException if the request for the advisories package returns a response code >= 300
30
     */
31
    public function __construct(array $options = [])
32
    {
33
        // Set options.
34
        $this->options = array_merge(
35
            [
36
                'advisories-dir' => sys_get_temp_dir() . '/signify-nz-security/advisories',
37
                'advisories-stale-after' => 86400, // 24 hrs in seconds.
38
                'guzzle-options' => [],
39
            ],
40
            $options
41
        );
42
43
        $this->validateOptions();
44
45
        // Get the advisories.
46
        $this->fetchAdvisories();
47
        $this->instantiateAdvisories();
48
    }
49
50
    /**
51
     * Checks a composer.lock file for vulnerable dependencies.
52
     *
53
     * @param string|array $lock The absolute path to the composer.lock file, or the json_decoded array.
54
     * @param boolean $includeDev If false, the dev dependencies won't be checked.
55
     * @return array
56
     * @throws InvalidArgumentException When the lock file does not exist or contains data in the wrong format.
57
     */
58
    public function check($lock, bool $includeDev = true): array
59
    {
60
        if (is_string($lock)) {
61
            if (!is_file($lock)) {
62
                throw new InvalidArgumentException('Lock file does not exist.');
63
            }
64
            $lockContents = json_decode(file_get_contents($lock), true);
65
            if (!is_array($lockContents)) {
66
                throw new InvalidArgumentException('Lock file does not contain correct format.');
67
            }
68
        } elseif (is_array($lock)) {
0 ignored issues
show
introduced by
The condition is_array($lock) is always true.
Loading history...
69
            $lockContents = $lock;
70
        } else {
71
            throw new InvalidArgumentException(
72
                '$lock must be the absolute path to the composer.lock file, '
73
                . 'or the json_decoded associative array of the composer.lock contents.'
74
            );
75
        }
76
        return $this->checkFromJson($lockContents, $includeDev);
77
    }
78
79
    /**
80
     * Checks JSON in the format of a composer.lock file for vulnerable dependencies.
81
     *
82
     * @param array $lock The json_decoded array in the format of a composer.lock file
83
     * @param boolean $includeDev If false, the dev dependencies won't be checked.
84
     * @return array
85
     * @throws InvalidArgumentException When the lock file does not exist
86
     */
87
    protected function checkFromJson(array $lock, bool $includeDev): array
88
    {
89
        $vulnerabilities = [];
90
        $zeroUTC = strtotime('1970-01-01T00:00:00+00:00');
91
        // Check all packages for vulnerabilities.
92
        foreach ($this->getPackages($lock, $includeDev) as $package) {
93
            $advisories = [];
94
            // Check for advisories about this specific package.
95
            if (array_key_exists($package['name'], $this->advisories)) {
0 ignored issues
show
Bug introduced by
It seems like $this->advisories can also be of type mixed; however, parameter $array of array_key_exists() does only seem to accept ArrayObject|array, maybe add an additional type check? ( Ignorable by Annotation )

If this is a false-positive, you can also ignore this issue in your code via the ignore-type  annotation

95
            if (array_key_exists($package['name'], /** @scrutinizer ignore-type */ $this->advisories)) {
Loading history...
96
                $normalisedVersion = $this->normalizeVersion($package['version']);
97
                foreach ($this->advisories[$package['name']] as $advisory) {
98
                    // Check each branch of the advisory to see if the installed version is affected.
99
                    foreach ($advisory['branches'] as $branchName => $branch) {
100
                        if ($this->isDev($package['version'])) {
101
                            // For dev packages, skip if not using the advisory branch.
102
                            $branchName = StringUtil::removeFromEnd($branchName, '.x');
103
                            if ($branchName !== $normalisedVersion) {
104
                                continue;
105
                            }
106
                            // For dev packages, skip if the advisory branch is older than the installed version.
107
                            $packageTimestamp = strtotime($package['time'] . ' UTC');
108
                            if ($packageTimestamp === $zeroUTC || $packageTimestamp > $branch['time']) {
109
                                continue;
110
                            }
111
                        } else {
112
                            // For stable packages, skip if installed version doesn't satisfy the advisory constraints.
113
                            if (!Semver::satisfies($package['version'], implode(',', $branch['versions']))) {
114
                                continue;
115
                            }
116
                        }
117
                        // If we got this far, the advisory applies for the installed package.
118
                        // Unset the unnecessary information.
119
                        unset($advisory['branches']);
120
                        unset($advisory['reference']);
121
                        $advisories[] = $advisory;
122
                        // Break the branch loop - we've already confirmed this advisory.
123
                        break;
124
                    }
125
                }
126
            }
127
            // Add relevant advisories to the resultant vulnerabilities array.
128
            if (!empty($advisories)) {
129
                $vulnerabilities[$package['name']] = [
130
                    'version' => $package['version'],
131
                    'advisories' => $advisories,
132
                ];
133
            }
134
        }
135
        return $vulnerabilities;
136
    }
137
138
    /**
139
     * Get the array of options for this checker.
140
     *
141
     * @return string[]
142
     */
143
    public function getOptions(): array
144
    {
145
        return $this->options;
146
    }
147
148
    /**
149
     * Get an option for this checker.
150
     *
151
     * @param string $option The option to get.
152
     * @return mixed The option value, or null if it doesn't exist.
153
     */
154
    public function getOption(string $option)
155
    {
156
        if (isset($this->options[$option])) {
157
            return $this->options[$option];
158
        }
159
        return null;
160
    }
161
162
    /**
163
     * Get an array of packages which are included in the composer lock.
164
     *
165
     * @param array $lock Composer lock JSON as an associative array.
166
     * @return array
167
     */
168
    public function getPackages(array $lock, bool $includeDev = true): array
169
    {
170
        $packages = [];
171
        $packageKeys = ['packages'];
172
        if ($includeDev) {
173
            $packageKeys[] = 'packages-dev';
174
        }
175
        foreach ($packageKeys as $key) {
176
            if (!array_key_exists($key, $lock)) {
177
                continue;
178
            }
179
            $packages = array_merge($packages, $lock[$key]);
180
        }
181
        return $packages;
182
    }
183
184
    protected function validateOptions()
185
    {
186
        // Confirm advisories directory can be written to (and create it if needs be)
187
        $advisoriesDir = $this->getOption('advisories-dir');
188
        $old_umask = umask(0);
189
        if ((!is_dir($advisoriesDir) && !mkdir($advisoriesDir, 0777, true)) || !is_writable($advisoriesDir)) {
0 ignored issues
show
Bug introduced by
It seems like $advisoriesDir can also be of type null; however, parameter $filename of is_dir() does only seem to accept string, maybe add an additional type check? ( Ignorable by Annotation )

If this is a false-positive, you can also ignore this issue in your code via the ignore-type  annotation

189
        if ((!is_dir(/** @scrutinizer ignore-type */ $advisoriesDir) && !mkdir($advisoriesDir, 0777, true)) || !is_writable($advisoriesDir)) {
Loading history...
Bug introduced by
It seems like $advisoriesDir can also be of type null; however, parameter $directory of mkdir() does only seem to accept string, maybe add an additional type check? ( Ignorable by Annotation )

If this is a false-positive, you can also ignore this issue in your code via the ignore-type  annotation

189
        if ((!is_dir($advisoriesDir) && !mkdir(/** @scrutinizer ignore-type */ $advisoriesDir, 0777, true)) || !is_writable($advisoriesDir)) {
Loading history...
190
            umask($old_umask);
191
            throw new InvalidArgumentException("Directory '$advisoriesDir' must be writable.");
192
        }
193
        umask($old_umask);
194
    }
195
196
    /**
197
     * Normalise a dev package version to easily compare with advisory branches.
198
     *
199
     * @param string $version
200
     * @return string
201
     */
202
    protected function normalizeVersion(string $version): string
203
    {
204
        $version = StringUtil::removeFromStart($version, 'dev-');
205
        $version = StringUtil::removeFromEnd($version, ['.x-dev', '-dev']);
206
        return $version;
207
    }
208
209
    /**
210
     * Check if the package version is for a dev package.
211
     *
212
     * @param string $version
213
     * @return boolean
214
     */
215
    protected function isDev(string $version): bool
216
    {
217
        $version = preg_replace('/"#.+$/', '', $version);
218
        if (StringUtil::startsWith($version, 'dev-') || StringUtil::endsWith($version, '-dev')) {
219
            return true;
220
        }
221
222
        return false;
223
    }
224
225
    /**
226
     * Fetch advisories from FriendsOfPHP.
227
     *
228
     * @return void
229
     */
230
    protected function fetchAdvisories(): void
231
    {
232
        $advisoriesDir = $this->getOption('advisories-dir');
233
        $timestampFile = $advisoriesDir . '/timestamp.txt';
234
        // Don't fetch if we still have advisories and they aren't stale.
235
        if (is_file($timestampFile) && !$this->isStale(file_get_contents($timestampFile))) {
236
            return;
237
        }
238
239
        // Fetch advisories zip from github.
240
        $client = new GuzzleClient();
241
        $response = $client->request('GET', self::ADVISORIES_URL, $this->getOption('guzzle-options'));
0 ignored issues
show
Bug introduced by
It seems like $this->getOption('guzzle-options') can also be of type null; however, parameter $options of GuzzleHttp\Client::request() does only seem to accept array, maybe add an additional type check? ( Ignorable by Annotation )

If this is a false-positive, you can also ignore this issue in your code via the ignore-type  annotation

241
        $response = $client->request('GET', self::ADVISORIES_URL, /** @scrutinizer ignore-type */ $this->getOption('guzzle-options'));
Loading history...
242
        if ($response->getStatusCode() >= 300) {
243
            throw new LogicException('Got status code ' . $response->getStatusCode() . ' when requesting advisories.');
244
        }
245
246
        // Store zip temporarily so it can be unzipped.
247
        $file = tempnam(sys_get_temp_dir(), 'zip');
248
        file_put_contents($file, $response->getBody());
249
250
        // Unzip advisories repository.
251
        $zip = new ZipArchive();
252
        $zip->open($file);
253
        $zip->extractTo($advisoriesDir);
254
        $zip->close();
255
256
        // Remove temporary zip file
257
        unlink($file);
258
259
        // Add timestamp to the directory so we don't refetch unnecessarily.
260
        file_put_contents($timestampFile, time());
261
262
        // Ensure all files have correct permissions.
263
        $this->setFilePermissionsRecursive($advisoriesDir);
0 ignored issues
show
Bug introduced by
It seems like $advisoriesDir can also be of type null; however, parameter $dir of Signify\SecurityChecker\...ePermissionsRecursive() does only seem to accept string, maybe add an additional type check? ( Ignorable by Annotation )

If this is a false-positive, you can also ignore this issue in your code via the ignore-type  annotation

263
        $this->setFilePermissionsRecursive(/** @scrutinizer ignore-type */ $advisoriesDir);
Loading history...
264
    }
265
266
    /**
267
     * Recursively set permissions for all files nested inside some directory.
268
     *
269
     * @param string $dir
270
     * @return void
271
     * @throws InvalidArgumentException if $dir is not a directory.
272
     */
273
    private function setFilePermissionsRecursive(string $dir): void
274
    {
275
        if (!is_dir($dir)) {
276
            throw new InvalidArgumentException("$dir must be a directory.");
277
        }
278
        $recursion = new RecursiveDirectoryIterator($dir, FilesystemIterator::SKIP_DOTS);
279
        foreach (new RecursiveIteratorIterator($recursion) as $item) {
280
            chmod($item->getPathname(), self::FILE_PERMISSIONS);
281
        }
282
    }
283
284
    /**
285
     * Check if a timestamp is outside the permitted timeframe.
286
     *
287
     * @param string|int $timestamp The unix timestamp to check for staleness.
288
     * @return boolean
289
     */
290
    protected function isStale($timestamp): bool
291
    {
292
        return ((int)$timestamp) < (time() - $this->getOption('advisories-stale-after'));
293
    }
294
295
    /**
296
     * Read advisory yaml files from the FriendsOfPHP repo into memory.
297
     *
298
     * @return void
299
     */
300
    protected function instantiateAdvisories(): void
301
    {
302
        if (!empty($this->advisories)) {
303
            return;
304
        }
305
        $this->advisories = [];
306
307
        // Parse all yaml files.
308
        $dir = $this->getOption('advisories-dir') . '/security-advisories-master/';
309
        $recursiveIterator = new RecursiveIteratorIterator(
310
            new RecursiveDirectoryIterator($dir, FilesystemIterator::SKIP_DOTS)
311
        );
312
        // Match all files with a .yml or .yaml extension within our directory which are not in hidden directories.
313
        $regex = '/^' . preg_quote($dir, '/') . '[^.]+\.(yaml|yml)$/i';
314
        foreach (new RegexIterator($recursiveIterator, $regex, RecursiveRegexIterator::GET_MATCH) as $match) {
315
            $filename = $match[0];
316
            // Parse yaml and store advisory against package name.
317
            $advisory = Yaml::parseFile($filename);
318
            $packageName = preg_replace('/^composer:\/\//', '', $advisory['reference']);
319
            $this->advisories[$packageName][] = $advisory;
320
        }
321
    }
322
}
323