Passed
Push — main ( 82ed03...16f015 )
by Sebastian
04:07
created

BlockSecrets::getChanges()   A

Complexity

Conditions 3
Paths 3

Size

Total Lines 13
Code Lines 9

Duplication

Lines 0
Ratio 0 %

Code Coverage

Tests 10
CRAP Score 3

Importance

Changes 1
Bugs 0 Features 0
Metric Value
cc 3
eloc 9
c 1
b 0
f 0
nc 3
nop 1
dl 0
loc 13
rs 9.9666
ccs 10
cts 10
cp 1
crap 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 8
    public function execute(Config $config, IO $io, Repository $repository, Config\Action $action): void
100
    {
101 8
        $this->io = $io;
102 8
        $this->setUp($action->getOptions());
103
104 6
        $filesFailed  = 0;
105 6
        $filesToCheck = $this->getChanges($repository);
106
107 6
        foreach ($filesToCheck as $file) {
108 5
            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 3
            $io->write('  ' . IOUtil::PREFIX_OK . ' ' . $file->getName(), true, IO::VERBOSE);
114
        }
115 6
        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 5
    private function isSecretInFile(string $file, array $lines): bool
129
    {
130 5
        $result = $this->detector->detectIn(implode(PHP_EOL, $lines));
131 5
        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 4
        if ($this->containsSuspiciousText($file, $lines)) {
140 1
            return true;
141
        }
142 3
        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 4
    private function containsSuspiciousText(string $file, array $lines): bool
153
    {
154 4
        if ($this->entropyThreshold < 0.1) {
155 1
            return false;
156
        }
157 3
        $fileInfo = pathinfo($file);
158 3
        $ext      = $fileInfo['extension'] ?? '';
159
        // we don't have a supplier for this filetype just exit
160 3
        if (!isset($this->fileTypeSupplier[$ext])) {
161 1
            return false;
162
        }
163 2
        $supplierToUse = $this->fileTypeSupplier[$ext];
164
        /** @var \CaptainHook\Secrets\Regex\Grouped $supplier */
165 2
        $supplier = new $supplierToUse();
166
167 2
        foreach ($lines as $line) {
168 2
            $result = Regexer::create()->useGroupedSupplier($supplier)->detectIn($line);
169 2
            if (!$result->wasSecretDetected()) {
170 2
                continue;
171
            }
172 1
            $match   = $result->matches()[0];
173 1
            $entropy = Shannon::entropy($match);
174 1
            $this->io->write('Entropy of ' . $match . ' is ' . $entropy, true, IO::DEBUG);
175 1
            if ($entropy > $this->entropyThreshold) {
176 1
                if (!$this->isAllowed($match)) {
177 1
                    $this->info[$file] = $match;
178 1
                    return true;
179
                }
180
            }
181
        }
182 1
        return false;
183
    }
184
185
    /**
186
     * @param \SebastianFeldmann\Git\Diff\File $file
187
     * @return array<string>
188
     */
189 5
    private function getLines(File $file): array
190
    {
191 5
        $lines = [];
192 5
        foreach ($file->getChanges() as $change) {
193 5
            array_push($lines, ...$change->getAddedContent());
194
        }
195 5
        return $lines;
196
    }
197
198
    /**
199
     * Checks if a found blocked pattern should be allowed anyway
200
     *
201
     * @param  string $blocked
202
     * @return bool
203
     */
204 3
    private function isAllowed(string $blocked): bool
205
    {
206 3
        foreach ($this->allowed as $regex) {
207 1
            $matchCount = preg_match($regex, $blocked, $matches);
208 1
            if ($matchCount) {
209 1
                return true;
210
            }
211
        }
212 2
        return false;
213
    }
214
215
    /**
216
     * Read all options and set up the action properly
217
     *
218
     * @param \CaptainHook\App\Config\Options $options
219
     * @throws \CaptainHook\App\Exception\ActionFailed
220
     */
221 8
    private function setUp(Config\Options $options): void
222
    {
223 8
        $this->detector = Detector::create();
224
225 8
        $this->setUpSuppliers($options);
226 6
        $this->setUpBlocked($options);
227 6
        $this->entropyThreshold = (float) $options->get('entropyThreshold', 0.0);
228 6
        $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...
229
    }
230
231
    /**
232
     * Set up the blocked regex
233
     *
234
     * @param \CaptainHook\App\Config\Options $options
235
     * @throws \CaptainHook\App\Exception\ActionFailed
236
     */
237 8
    private function setUpSuppliers(Config\Options $options): void
238
    {
239
        try {
240 8
            $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

240
            $this->detector->useSupplierConfig(/** @scrutinizer ignore-type */ $options->get('suppliers', []));
Loading history...
241 2
        } catch (Exception $e) {
242 2
            throw new ActionFailed($e->getMessage(), 0, $e);
243
        }
244
    }
245
246
    /**
247
     * @param \CaptainHook\App\Config\Options $options
248
     * @return void
249
     */
250 6
    private function setUpBlocked(Config\Options $options): void
251
    {
252 6
        $this->detector->useRegex(...$options->get('blocked', []));
253
    }
254
255
    /**
256
     * Return an error message appendix
257
     *
258
     * @param  string $file
259
     * @return string
260
     */
261 2
    protected function errorDetails(string $file): string
262
    {
263 2
        return ' found <comment>' . $this->info[$file] . '</comment>';
264
    }
265
266
    /**
267
     * @param \SebastianFeldmann\Git\Repository $repository
268
     * @return array<\SebastianFeldmann\Git\Diff\File>
269
     */
270 6
    private function getChanges(Repository $repository): array
271
    {
272 6
        if (Util::isRunningHook($this->io, Hooks::PRE_PUSH)) {
273 1
            $ranges  = \CaptainHook\App\Git\Range\Detector::getRanges($this->io);
274 1
            $newHash = 'HEAD';
275 1
            $oldHash = 'HEAD@{1}';
276 1
            if (!empty($ranges)) {
277 1
                $oldHash = $ranges[0]->from()->id();
278 1
                $newHash = $ranges[0]->to()->id();
279
            }
280 1
            return $repository->getDiffOperator()->compare($oldHash, $newHash);
281
        }
282 5
        return $repository->getDiffOperator()->compareIndexTo('HEAD');
283
    }
284
}
285