Passed
Push — main ( 7e4279...8faa83 )
by Sebastian
03:15
created

BlockSecrets   A

Complexity

Total Complexity 34

Size/Duplication

Total Lines 274
Duplicated Lines 0 %

Test Coverage

Coverage 100%

Importance

Changes 2
Bugs 0 Features 0
Metric Value
eloc 86
c 2
b 0
f 0
dl 0
loc 274
rs 9.68
ccs 87
cts 87
cp 1
wmc 34

13 Methods

Rating   Name   Duplication   Size   Complexity  
A getRestriction() 0 3 1
A isSecretInFile() 0 15 5
A execute() 0 19 5
A setUp() 0 8 1
A getFileExtension() 0 4 1
A errorDetails() 0 3 1
A containsSuspiciousText() 0 24 6
A getChanges() 0 13 3
A isAllowed() 0 9 3
A setUpBlocked() 0 3 1
A getLines() 0 7 2
A setUpSuppliers() 0 6 2
A isBlockedByEntropyCheck() 0 11 3
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 9
    public function execute(Config $config, IO $io, Repository $repository, Config\Action $action): void
100
    {
101 9
        $this->io = $io;
102 9
        $this->setUp($action->getOptions());
103
104 7
        $filesFailed  = 0;
105 7
        $filesToCheck = $this->getChanges($repository);
106
107 7
        foreach ($filesToCheck as $file) {
108 6
            if ($this->isSecretInFile($file->getName(), $this->getLines($file))) {
109 2
                $filesFailed++;
110 2
                $io->write('  ' . IOUtil::PREFIX_FAIL . ' ' . $file->getName() . $this->errorDetails($file->getName()));
111 2
                continue;
112
            }
113 4
            $io->write('  ' . IOUtil::PREFIX_OK . ' ' . $file->getName(), true, IO::VERBOSE);
114
        }
115 7
        if ($filesFailed > 0) {
116 2
            $s = $filesFailed > 1 ? 's' : '';
117 2
            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 6
    private function isSecretInFile(string $file, array $lines): bool
129
    {
130 6
        $result = $this->detector->detectIn(implode(PHP_EOL, $lines));
131 6
        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 5
        if ($this->containsSuspiciousText($file, $lines)) {
140 1
            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 5
    private function containsSuspiciousText(string $file, array $lines): bool
153
    {
154 5
        if ($this->entropyThreshold < 0.1) {
155 1
            return false;
156
        }
157 4
        $ext = $this->getFileExtension($file);
158
        // if we don't have a supplier for this filetype just exit
159 4
        if (!isset($this->fileTypeSupplier[$ext])) {
160 1
            return false;
161
        }
162 3
        $supplierToUse = $this->fileTypeSupplier[$ext];
163
        /** @var \CaptainHook\Secrets\Regex\Grouped $supplier */
164 3
        $supplier = new $supplierToUse();
165
166 3
        foreach ($lines as $line) {
167 3
            $result = Regexer::create()->useGroupedSupplier($supplier)->detectIn($line);
168 3
            if (!$result->wasSecretDetected()) {
169 3
                continue;
170
            }
171 2
            if ($this->isBlockedByEntropyCheck($file, $result->matches()[0])) {
172 1
                return true;
173
            }
174
        }
175 2
        return false;
176
    }
177
178
    /**
179
     * @param \SebastianFeldmann\Git\Diff\File $file
180
     * @return array<string>
181
     */
182 6
    private function getLines(File $file): array
183
    {
184 6
        $lines = [];
185 6
        foreach ($file->getChanges() as $change) {
186 6
            array_push($lines, ...$change->getAddedContent());
187
        }
188 6
        return $lines;
189
    }
190
191
    /**
192
     * Checks if a found blocked pattern should be allowed anyway
193
     *
194
     * @param  string $blocked
195
     * @return bool
196
     */
197 4
    private function isAllowed(string $blocked): bool
198
    {
199 4
        foreach ($this->allowed as $regex) {
200 2
            $matchCount = preg_match($regex, $blocked, $matches);
201 2
            if ($matchCount) {
202 2
                return true;
203
            }
204
        }
205 2
        return false;
206
    }
207
208
    /**
209
     * Read all options and set up the action properly
210
     *
211
     * @param \CaptainHook\App\Config\Options $options
212
     * @throws \CaptainHook\App\Exception\ActionFailed
213
     */
214 9
    private function setUp(Config\Options $options): void
215
    {
216 9
        $this->detector = Detector::create();
217
218 9
        $this->setUpSuppliers($options);
219 7
        $this->setUpBlocked($options);
220 7
        $this->entropyThreshold = (float) $options->get('entropyThreshold', 0.0);
221 7
        $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...
222
    }
223
224
    /**
225
     * Set up the blocked regex
226
     *
227
     * @param \CaptainHook\App\Config\Options $options
228
     * @throws \CaptainHook\App\Exception\ActionFailed
229
     */
230 9
    private function setUpSuppliers(Config\Options $options): void
231
    {
232
        try {
233 9
            $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

233
            $this->detector->useSupplierConfig(/** @scrutinizer ignore-type */ $options->get('suppliers', []));
Loading history...
234 2
        } catch (Exception $e) {
235 2
            throw new ActionFailed($e->getMessage(), 0, $e);
236
        }
237
    }
238
239
    /**
240
     * @param \CaptainHook\App\Config\Options $options
241
     * @return void
242
     */
243 7
    private function setUpBlocked(Config\Options $options): void
244
    {
245 7
        $this->detector->useRegex(...$options->get('blocked', []));
246
    }
247
248
    /**
249
     * Return an error message appendix
250
     *
251
     * @param  string $file
252
     * @return string
253
     */
254 2
    protected function errorDetails(string $file): string
255
    {
256 2
        return ' found <comment>' . $this->info[$file] . '</comment>';
257
    }
258
259
    /**
260
     * @param \SebastianFeldmann\Git\Repository $repository
261
     * @return array<\SebastianFeldmann\Git\Diff\File>
262
     */
263 7
    private function getChanges(Repository $repository): array
264
    {
265 7
        if (Util::isRunningHook($this->io, Hooks::PRE_PUSH)) {
266 1
            $ranges  = \CaptainHook\App\Git\Range\Detector::getRanges($this->io);
267 1
            $newHash = 'HEAD';
268 1
            $oldHash = 'HEAD@{1}';
269 1
            if (!empty($ranges)) {
270 1
                $oldHash = $ranges[0]->from()->id();
271 1
                $newHash = $ranges[0]->to()->id();
272
            }
273 1
            return $repository->getDiffOperator()->compare($oldHash, $newHash);
274
        }
275 6
        return $repository->getDiffOperator()->compareIndexTo('HEAD');
276
    }
277
278
    /**
279
     * Return the file suffix for a given file name
280
     *
281
     * @param string $file
282
     * @return string
283
     */
284 4
    private function getFileExtension(string $file): string
285
    {
286 4
        $fileInfo = pathinfo($file);
287 4
        return $fileInfo['extension'] ?? '';
288
    }
289
290
    /**
291
     * Should match be blocked because of entropy value
292
     *
293
     * @param string $file
294
     * @param string $match
295
     * @return bool
296
     */
297 2
    private function isBlockedByEntropyCheck(string $file, string $match): bool
298
    {
299 2
        $entropy = Shannon::entropy($match);
300 2
        $this->io->write('Entropy of ' . $match . ' is ' . $entropy, true, IO::DEBUG);
301 2
        if ($entropy > $this->entropyThreshold) {
302 2
            if (!$this->isAllowed($match)) {
303 1
                $this->info[$file] = $match;
304 1
                return true;
305
            }
306
        }
307 1
        return false;
308
    }
309
}
310