BlockSecrets::execute()   A
last analyzed

Complexity

Conditions 5
Paths 9

Size

Total Lines 19
Code Lines 13

Duplication

Lines 0
Ratio 0 %

Code Coverage

Tests 14
CRAP Score 5

Importance

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

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