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', []); |
|
|
|
|
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', [])); |
|
|
|
|
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
|
|
|
|
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 theid
property of an instance of theAccount
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.