Passed
Push — main ( 8faa83...6cdb7a )
by Sebastian
03:38
created

BlockSecrets::lookForSecretsWithSupplier()   A

Complexity

Conditions 4
Paths 4

Size

Total Lines 15
Code Lines 9

Duplication

Lines 0
Ratio 0 %

Code Coverage

Tests 10
CRAP Score 4

Importance

Changes 0
Metric Value
cc 4
eloc 9
c 0
b 0
f 0
nc 4
nop 3
dl 0
loc 15
rs 9.9666
ccs 10
cts 10
cp 1
crap 4
1
<?php
2
3
/**
4
 * This file is part of CaptainHook.
5
 *
6
 * (c) Sebastian Feldmann <[email protected]>
7
 *
8
 * For the full copyright and license information, please view the LICENSE
9
 * file that was distributed with this source code.
10
 */
11
12
namespace CaptainHook\App\Hook\Diff\Action;
13
14
use CaptainHook\App\Config;
15
use CaptainHook\App\Console\IO;
16
use CaptainHook\App\Console\IOUtil;
17
use CaptainHook\App\Exception\ActionFailed;
18
use CaptainHook\App\Hook\Action;
19
use CaptainHook\App\Hook\Constrained;
20
use CaptainHook\App\Hook\Restriction;
21
use CaptainHook\App\Hook\Util;
22
use CaptainHook\App\Hooks;
23
use CaptainHook\Secrets\Detector;
24
use CaptainHook\Secrets\Entropy\Shannon;
25
use CaptainHook\Secrets\Regex\Supplier\Ini;
26
use CaptainHook\Secrets\Regex\Supplier\Json;
27
use CaptainHook\Secrets\Regex\Supplier\PHP;
28
use CaptainHook\Secrets\Regex\Supplier\Yaml;
29
use CaptainHook\Secrets\Regexer;
30
use Exception;
31
use SebastianFeldmann\Git\Diff\File;
32
use SebastianFeldmann\Git\Repository;
33
34
class BlockSecrets implements Action, Constrained
35
{
36
    /**
37
     * @var \CaptainHook\App\Console\IO
38
     */
39
    private IO $io;
40
41
    /**
42
     * @var \CaptainHook\Secrets\Detector
43
     */
44
    private Detector $detector;
45
46
    /**
47
     * List of allowed patterns
48
     *
49
     * @var array<string>
50
     */
51
    private array $allowed;
52
53
    /**
54
     * Additional information for a file
55
     *
56
     * @var array<string, string>
57
     */
58
    private array $info = [];
59
60
    /**
61
     * Max allowed entropy for words
62
     *
63
     * @var float
64
     */
65
    private float $entropyThreshold;
66
67
    /**
68
     * Map filetype regex supplier
69
     *
70
     * @var array<string>
71
     */
72
    private array $fileTypeSupplier = [
73
        'json' => Json::class,
74
        'php'  => PHP::class,
75
        'yml'  => Yaml::class,
76
        'ini'  => Ini::class,
77
    ];
78
79
    /**
80
     * Make sure this action is only used pro pre-commit hooks
81
     *
82
     * @return \CaptainHook\App\Hook\Restriction
83
     */
84 1
    public static function getRestriction(): Restriction
85
    {
86 1
        return new Restriction('pre-commit', 'pre-push');
87
    }
88
89
    /**
90
     * Execute the action
91
     *
92
     * @param \CaptainHook\App\Config           $config
93
     * @param \CaptainHook\App\Console\IO       $io
94
     * @param \SebastianFeldmann\Git\Repository $repository
95
     * @param \CaptainHook\App\Config\Action    $action
96
     * @return void
97
     * @throws \CaptainHook\App\Exception\ActionFailed
98
     */
99 10
    public function execute(Config $config, IO $io, Repository $repository, Config\Action $action): void
100
    {
101 10
        $this->io = $io;
102 10
        $this->setUp($action->getOptions());
103
104 8
        $filesFailed  = 0;
105 8
        $filesToCheck = $this->getChanges($repository);
106
107 8
        foreach ($filesToCheck as $file) {
108 7
            if ($this->isSecretInFile($file->getName(), $this->getLines($file))) {
109 3
                $filesFailed++;
110 3
                $io->write('  ' . IOUtil::PREFIX_FAIL . ' ' . $file->getName() . $this->errorDetails($file->getName()));
111 3
                continue;
112
            }
113 4
            $io->write('  ' . IOUtil::PREFIX_OK . ' ' . $file->getName(), true, IO::VERBOSE);
114
        }
115 8
        if ($filesFailed > 0) {
116 3
            $s = $filesFailed > 1 ? 's' : '';
117 3
            throw new ActionFailed('Found secrets in ' . $filesFailed . ' file' . $s);
118
        }
119
    }
120
121
    /**
122
     * Checks if some added lines contain secrets that are not allowed
123
     *
124
     * @param string        $file
125
     * @param array<string> $lines
126
     * @return bool
127
     */
128 7
    private function isSecretInFile(string $file, array $lines): bool
129
    {
130 7
        $result = $this->detector->detectIn(implode(PHP_EOL, $lines));
131 7
        if ($result->wasSecretDetected()) {
132 2
            foreach ($result->matches() as $match) {
133 2
                if (!$this->isAllowed($match)) {
134 1
                    $this->info[$file] = $match;
135 1
                    return true;
136
                }
137
            }
138
        }
139 6
        if ($this->containsSuspiciousText($file, $lines)) {
140 2
            return true;
141
        }
142 4
        return false;
143
    }
144
145
    /**
146
     * Tries to find passwords by entropy
147
     *
148
     * @param string        $file
149
     * @param array<string> $lines
150
     * @return bool
151
     */
152 6
    private function containsSuspiciousText(string $file, array $lines): bool
153
    {
154 6
        if ($this->entropyThreshold < 0.1) {
155 1
            return false;
156
        }
157 5
        $ext = $this->getFileExtension($file);
158
        // if we don't have a supplier for this filetype just exit
159 5
        if (!isset($this->fileTypeSupplier[$ext])) {
160 2
            return $this->lookForSecretsBruteForce($file, $lines);
161
        }
162 3
        return $this->lookForSecretsWithSupplier($this->fileTypeSupplier[$ext], $lines, $file);
163
    }
164
165
    /**
166
     * @param \SebastianFeldmann\Git\Diff\File $file
167
     * @return array<string>
168
     */
169 7
    private function getLines(File $file): array
170
    {
171 7
        $lines = [];
172 7
        foreach ($file->getChanges() as $change) {
173 7
            array_push($lines, ...$change->getAddedContent());
174
        }
175 7
        return $lines;
176
    }
177
178
    /**
179
     * Checks if a found blocked pattern should be allowed anyway
180
     *
181
     * @param  string $blocked
182
     * @return bool
183
     */
184 5
    private function isAllowed(string $blocked): bool
185
    {
186 5
        foreach ($this->allowed as $regex) {
187 2
            $matchCount = preg_match($regex, $blocked, $matches);
188 2
            if ($matchCount) {
189 2
                return true;
190
            }
191
        }
192 3
        return false;
193
    }
194
195
    /**
196
     * Read all options and set up the action properly
197
     *
198
     * @param \CaptainHook\App\Config\Options $options
199
     * @throws \CaptainHook\App\Exception\ActionFailed
200
     */
201 10
    private function setUp(Config\Options $options): void
202
    {
203 10
        $this->detector = Detector::create();
204
205 10
        $this->setUpSuppliers($options);
206 8
        $this->setUpBlocked($options);
207 8
        $this->entropyThreshold = (float) $options->get('entropyThreshold', 0.0);
208 8
        $this->allowed          = $options->get('allowed', []);
0 ignored issues
show
Documentation Bug introduced by
It seems like $options->get('allowed', array()) can also be of type CaptainHook\App\Config\ProvidedDefault. However, the property $allowed is declared as type string[]. Maybe add an additional type check?

Our type inference engine has found a suspicous assignment of a value to a property. This check raises an issue when a value that can be of a mixed type is assigned to a property that is type hinted more strictly.

For example, imagine you have a variable $accountId that can either hold an Id object or false (if there is no account id yet). Your code now assigns that value to the id property of an instance of the Account class. This class holds a proper account, so the id value must no longer be false.

Either this assignment is in error or a type check should be added for that assignment.

class Id
{
    public $id;

    public function __construct($id)
    {
        $this->id = $id;
    }

}

class Account
{
    /** @var  Id $id */
    public $id;
}

$account_id = false;

if (starsAreRight()) {
    $account_id = new Id(42);
}

$account = new Account();
if ($account instanceof Id)
{
    $account->id = $account_id;
}
Loading history...
209
    }
210
211
    /**
212
     * Set up the blocked regex
213
     *
214
     * @param \CaptainHook\App\Config\Options $options
215
     * @throws \CaptainHook\App\Exception\ActionFailed
216
     */
217 10
    private function setUpSuppliers(Config\Options $options): void
218
    {
219
        try {
220 10
            $this->detector->useSupplierConfig($options->get('suppliers', []));
0 ignored issues
show
Bug introduced by
It seems like $options->get('suppliers', array()) can also be of type CaptainHook\App\Config\ProvidedDefault; however, parameter $config of CaptainHook\Secrets\Detector::useSupplierConfig() 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

220
            $this->detector->useSupplierConfig(/** @scrutinizer ignore-type */ $options->get('suppliers', []));
Loading history...
221 2
        } catch (Exception $e) {
222 2
            throw new ActionFailed($e->getMessage(), 0, $e);
223
        }
224
    }
225
226
    /**
227
     * @param \CaptainHook\App\Config\Options $options
228
     * @return void
229
     */
230 8
    private function setUpBlocked(Config\Options $options): void
231
    {
232 8
        $this->detector->useRegex(...$options->get('blocked', []));
233
    }
234
235
    /**
236
     * Return an error message appendix
237
     *
238
     * @param  string $file
239
     * @return string
240
     */
241 3
    protected function errorDetails(string $file): string
242
    {
243 3
        return ' found <comment>' . $this->info[$file] . '</comment>';
244
    }
245
246
    /**
247
     * @param \SebastianFeldmann\Git\Repository $repository
248
     * @return array<\SebastianFeldmann\Git\Diff\File>
249
     */
250 8
    private function getChanges(Repository $repository): array
251
    {
252 8
        if (Util::isRunningHook($this->io, Hooks::PRE_PUSH)) {
253 1
            $ranges  = \CaptainHook\App\Git\Range\Detector::getRanges($this->io);
254 1
            $newHash = 'HEAD';
255 1
            $oldHash = 'HEAD@{1}';
256 1
            if (!empty($ranges)) {
257 1
                $oldHash = $ranges[0]->from()->id();
258 1
                $newHash = $ranges[0]->to()->id();
259
            }
260 1
            return $repository->getDiffOperator()->compare($oldHash, $newHash);
261
        }
262 7
        return $repository->getDiffOperator()->compareIndexTo('HEAD');
263
    }
264
265
    /**
266
     * Return the file suffix for a given file name
267
     *
268
     * @param string $file
269
     * @return string
270
     */
271 5
    private function getFileExtension(string $file): string
272
    {
273 5
        $fileInfo = pathinfo($file);
274 5
        return $fileInfo['extension'] ?? '';
275
    }
276
277
    /**
278
     * Should match be blocked because of entropy value
279
     *
280
     * @param string $file
281
     * @param string $match
282
     * @return bool
283
     */
284 3
    private function isEntropyTooHigh(string $file, string $match): bool
285
    {
286 3
        $entropy = Shannon::entropy($match);
287 3
        $this->io->write('Entropy of ' . $match . ' is ' . $entropy, true, IO::DEBUG);
288 3
        if ($entropy > $this->entropyThreshold) {
289 3
            if (!$this->isAllowed($match)) {
290 2
                $this->info[$file] = $match;
291 2
                return true;
292
            }
293
        }
294 2
        return false;
295
    }
296
297
    /**
298
     * Uses supplier and regexer to find possible risky parts of a string
299
     *
300
     * @param string        $supplierClass
301
     * @param array<string> $lines
302
     * @param string $file
303
     * @return bool
304
     */
305 3
    private function lookForSecretsWithSupplier(string $supplierClass, array $lines, string $file): bool
306
    {
307
        /** @var \CaptainHook\Secrets\Regex\Grouped $supplier */
308 3
        $supplier = new $supplierClass();
309 3
        $regexer  = Regexer::create()->useGroupedSupplier($supplier);
310 3
        foreach ($lines as $line) {
311 3
            $result = $regexer->detectIn($line);
312 3
            if (!$result->wasSecretDetected()) {
313 3
                continue;
314
            }
315 2
            if ($this->isEntropyTooHigh($file, $result->matches()[0])) {
316 1
                return true;
317
            }
318
        }
319 2
        return false;
320
    }
321
322
    /**
323
     * Check every word in a file if the entropy is too high
324
     *
325
     * @param string        $file
326
     * @param array<string> $lines
327
     * @return bool
328
     */
329 2
    private function lookForSecretsBruteForce(string $file, array $lines): bool
330
    {
331 2
        $matches = [];
332 2
        if (preg_match_all('#\b\S{8,}\b#', implode(' ', $lines), $matches)) {
333 1
            foreach ($matches[0] as $word) {
334 1
                if ($this->isEntropyTooHigh($file, $word)) {
335 1
                    return true;
336
                }
337
            }
338
        }
339 1
        return false;
340
    }
341
}
342