Skip to content

Commit e4942e1

Browse files
committed
Unknown field now generates a warning
If the file is signed with GPG, then the `Hash:` header is not flagged as an unknown field. Close #41
1 parent 46e57c5 commit e4942e1

File tree

3 files changed

+122
-25
lines changed

3 files changed

+122
-25
lines changed

src/Parser/SecurityTxtParser.php

Lines changed: 23 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,7 @@
2828
use Spaze\SecurityTxt\Violations\SecurityTxtLineNoEol;
2929
use Spaze\SecurityTxt\Violations\SecurityTxtPossibelFieldTypo;
3030
use Spaze\SecurityTxt\Violations\SecurityTxtSpecViolation;
31+
use Spaze\SecurityTxt\Violations\SecurityTxtUnknownField;
3132

3233
final class SecurityTxtParser
3334
{
@@ -132,7 +133,17 @@ public function parseString(string $contents, ?string $fileLocation = null, ?int
132133
if (str_starts_with($line, '#')) {
133134
continue;
134135
}
135-
$securityTxt = $this->checkSignature($lineNumber, $line, $contents, $securityTxt);
136+
if (isset($skipSignatureArmorHeaders) && $skipSignatureArmorHeaders) {
137+
if ($line === '') {
138+
$skipSignatureArmorHeaders = false;
139+
}
140+
continue;
141+
}
142+
if ($this->signature->isClearsignHeader($line)) {
143+
$securityTxt = $this->checkSignature($lineNumber, $contents, $securityTxt);
144+
$skipSignatureArmorHeaders = true;
145+
continue;
146+
}
136147
$field = explode(':', $line, 2);
137148
if (count($field) !== 2) {
138149
continue;
@@ -145,6 +156,8 @@ public function parseString(string $contents, ?string $fileLocation = null, ?int
145156
$suggestion = $this->getSuggestion($securityTxtFields, $fieldName);
146157
if ($suggestion !== null) {
147158
$this->lineWarnings[$lineNumber][] = new SecurityTxtPossibelFieldTypo($field[0], $suggestion->value, $line);
159+
} else {
160+
$this->lineWarnings[$lineNumber][] = new SecurityTxtUnknownField($field[0], $line);
148161
}
149162
}
150163
}
@@ -176,19 +189,17 @@ public function parseFetchResult(SecurityTxtFetchResult $fetchResult, ?int $expi
176189

177190

178191
/**
179-
* @param int<1, max> $lineNumber
192+
* @param positive-int $lineNumber
180193
*/
181-
private function checkSignature(int $lineNumber, string $line, string $contents, SecurityTxt $securityTxt): SecurityTxt
194+
private function checkSignature(int $lineNumber, string $contents, SecurityTxt $securityTxt): SecurityTxt
182195
{
183-
if ($this->signature->isClearsignHeader($line)) {
184-
try {
185-
$result = $this->signature->verify($contents);
186-
return $securityTxt->withSignatureVerifyResult($result);
187-
} catch (SecurityTxtError $e) {
188-
$this->lineErrors[$lineNumber][] = $e->getViolation();
189-
} catch (SecurityTxtWarning $e) {
190-
$this->lineWarnings[$lineNumber][] = $e->getViolation();
191-
}
196+
try {
197+
$result = $this->signature->verify($contents);
198+
return $securityTxt->withSignatureVerifyResult($result);
199+
} catch (SecurityTxtError $e) {
200+
$this->lineErrors[$lineNumber][] = $e->getViolation();
201+
} catch (SecurityTxtWarning $e) {
202+
$this->lineWarnings[$lineNumber][] = $e->getViolation();
192203
}
193204
return $securityTxt;
194205
}
Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,23 @@
1+
<?php
2+
declare(strict_types = 1);
3+
4+
namespace Spaze\SecurityTxt\Violations;
5+
6+
final class SecurityTxtUnknownField extends SecurityTxtSpecViolation
7+
{
8+
9+
public function __construct(string $fieldName, string $line)
10+
{
11+
parent::__construct(
12+
func_get_args(),
13+
'Field %s is unknown',
14+
[$fieldName],
15+
null,
16+
"# {$line}",
17+
"Remove the line or comment it out",
18+
[],
19+
null,
20+
);
21+
}
22+
23+
}

tests/Parser/SecurityTxtParserTest.phpt

Lines changed: 76 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -26,8 +26,9 @@ use Spaze\SecurityTxt\Violations\SecurityTxtNoExpires;
2626
use Spaze\SecurityTxt\Violations\SecurityTxtPossibelFieldTypo;
2727
use Spaze\SecurityTxt\Violations\SecurityTxtPreferredLanguagesCommonMistake;
2828
use Spaze\SecurityTxt\Violations\SecurityTxtPreferredLanguagesSeparatorNotComma;
29-
use Spaze\SecurityTxt\Violations\SecurityTxtSpecViolation;
29+
use Spaze\SecurityTxt\Violations\SecurityTxtSignatureCannotVerify;
3030
use Spaze\SecurityTxt\Violations\SecurityTxtTopLevelPathOnly;
31+
use Spaze\SecurityTxt\Violations\SecurityTxtUnknownField;
3132
use Tester\Assert;
3233
use Tester\TestCase;
3334

@@ -69,10 +70,10 @@ final class SecurityTxtParserTest extends TestCase
6970
*/
7071
public function testParseStringExpiresField(string $fieldValue, bool $isExpired, array $errors): void
7172
{
72-
$contents = "Foo: bar\nExpires: " . (new DateTime($fieldValue))->format(SecurityTxtExpires::FORMAT) . "\nBar: foo\n";
73+
$contents = "Contact: https://example.com/\nExpires: " . (new DateTime($fieldValue))->format(SecurityTxtExpires::FORMAT) . "\nHiring: https://com.example\n";
7374
$parseResult = $this->securityTxtParser->parseString($contents);
7475
Assert::same($isExpired, $parseResult->getSecurityTxt()->getExpires()?->isExpired());
75-
Assert::true($parseResult->hasErrors());
76+
Assert::same($isExpired, $parseResult->hasErrors());
7677
Assert::false($parseResult->hasWarnings());
7778
foreach ($parseResult->getLineErrors() as $lineNumber => $lineErrors) {
7879
foreach ($lineErrors as $key => $lineError) {
@@ -145,19 +146,19 @@ final class SecurityTxtParserTest extends TestCase
145146

146147
public function testParseStringMissingExpires(): void
147148
{
148-
$contents = "Foo: bar\nBar: foo\n#Expires: 2020-10-05T10:20:30+00:00\nExpires\n";
149+
$contents = "Contact: https://example.com/\nHiring: https://com.example\n#Expires: 2020-10-05T10:20:30+00:00\nExpires\n";
149150
$parseResult = $this->securityTxtParser->parseString($contents);
150-
Assert::contains(SecurityTxtNoExpires::class, array_map(function (SecurityTxtSpecViolation $throwable): string {
151-
return $throwable::class;
152-
}, $parseResult->getFileErrors()));
151+
Assert::count(0, $parseResult->getLineErrors());
152+
Assert::count(1, $parseResult->getFileErrors());
153+
Assert::type(SecurityTxtNoExpires::class, $parseResult->getFileErrors()[0]);
153154
Assert::true($parseResult->hasErrors());
154155
Assert::false($parseResult->hasWarnings());
155156
}
156157

157158

158159
public function testParseStringMultipleExpires(): void
159160
{
160-
$contents = "Foo: bar\nExpires: " . (new DateTime('+2 months'))->format(SecurityTxtExpires::FORMAT) . "\nExpires: " . (new DateTime('+3 months'))->format(SecurityTxtExpires::FORMAT) . "\nBar: foo\n";
161+
$contents = "Contact: https://example.com/\nExpires: " . (new DateTime('+2 months'))->format(SecurityTxtExpires::FORMAT) . "\nExpires: " . (new DateTime('+3 months'))->format(SecurityTxtExpires::FORMAT) . "\nHiring: https://com.example/\n";
161162
$parseResult = $this->securityTxtParser->parseString($contents);
162163
$expiresError = $parseResult->getLineErrors()[3][0];
163164
Assert::type(SecurityTxtMultipleExpires::class, $expiresError);
@@ -168,7 +169,7 @@ final class SecurityTxtParserTest extends TestCase
168169

169170
public function testParseStringMultipleExpiresAllWrong(): void
170171
{
171-
$contents = "Foo: bar\nExpires: Mon, 15 Aug 2005 15:52:01 +0000\nExpires: Mon, 15 Aug 2015 15:52:01 +0000\nBar: foo\n";
172+
$contents = "Contact: https://example.com/\nExpires: Mon, 15 Aug 2005 15:52:01 +0000\nExpires: Mon, 15 Aug 2015 15:52:01 +0000\nHiring: https://com.example/\n";
172173
$parseResult = $this->securityTxtParser->parseString($contents);
173174
Assert::type(SecurityTxtExpiresOldFormat::class, $parseResult->getLineErrors()[2][0]);
174175
Assert::type(SecurityTxtMultipleExpires::class, $parseResult->getLineErrors()[3][0]);
@@ -180,7 +181,7 @@ final class SecurityTxtParserTest extends TestCase
180181

181182
public function testParseStringMultipleExpiresFirstWrong(): void
182183
{
183-
$contents = "Foo: bar\nExpires: Mon, 15 Aug 2005 15:52:01 +0000\nExpires: " . (new DateTime('+2 months'))->format(SecurityTxtExpires::FORMAT) . "\nBar: foo\n";
184+
$contents = "Contact: https://example.com/\nExpires: Mon, 15 Aug 2005 15:52:01 +0000\nExpires: " . (new DateTime('+2 months'))->format(SecurityTxtExpires::FORMAT) . "\nHiring: https://com.example/\n";
184185
$parseResult = $this->securityTxtParser->parseString($contents);
185186
Assert::type(SecurityTxtExpiresOldFormat::class, $parseResult->getLineErrors()[2][0]);
186187
Assert::true($parseResult->hasErrors());
@@ -190,7 +191,7 @@ final class SecurityTxtParserTest extends TestCase
190191

191192
public function testParseStringMultipleExpiresFirstCorrect(): void
192193
{
193-
$contents = "Foo: bar\nExpires: " . (new DateTime('+2 months'))->format(SecurityTxtExpires::FORMAT) . "\nExpires: Mon, 15 Aug 2005 15:52:01 +0000\nBar: foo\n";
194+
$contents = "Contact: https://example.com/\nExpires: " . (new DateTime('+2 months'))->format(SecurityTxtExpires::FORMAT) . "\nExpires: Mon, 15 Aug 2005 15:52:01 +0000\nHiring: https://com.example/\n";
194195
$parseResult = $this->securityTxtParser->parseString($contents);
195196
Assert::count(3, $parseResult->getLineErrors()[3]);
196197
Assert::type(SecurityTxtMultipleExpires::class, $parseResult->getLineErrors()[3][0]);
@@ -220,7 +221,7 @@ final class SecurityTxtParserTest extends TestCase
220221

221222
public function testParseMultipleBadFiles(): void
222223
{
223-
$contents = "Foo: bar\nExpires: " . (new DateTime('+2 months'))->format(SecurityTxtExpires::FORMAT) . "\nExpires: " . (new DateTime('+3 months'))->format(SecurityTxtExpires::FORMAT) . "\nBar: foo\n";
224+
$contents = "Contact: https://example.com/\nExpires: " . (new DateTime('+2 months'))->format(SecurityTxtExpires::FORMAT) . "\nExpires: " . (new DateTime('+3 months'))->format(SecurityTxtExpires::FORMAT) . "\nHiring: https://com.example/\n";
224225
$parseResult = $this->securityTxtParser->parseString($contents);
225226
Assert::type(SecurityTxtMultipleExpires::class, $parseResult->getLineErrors()[3][0]);
226227
Assert::true($parseResult->hasErrors());
@@ -249,7 +250,7 @@ final class SecurityTxtParserTest extends TestCase
249250

250251
public function testParseStringMissingContact(): void
251252
{
252-
$contents = "Foo: bar\nBar: foo\n";
253+
$contents = "Acknowledgments: https://example.com/\nHiring: https://com.example/\n";
253254
$parseResult = $this->securityTxtParser->parseString($contents);
254255
$contactError = $parseResult->getFileErrors()[0];
255256
Assert::type(SecurityTxtNoContact::class, $contactError);
@@ -393,6 +394,68 @@ final class SecurityTxtParserTest extends TestCase
393394
}
394395

395396

397+
public function testParseStringUnknownField(): void
398+
{
399+
$contents = "Foo: bar\nHash: file-not-signed-0123\nContact: https://example.com/\nExpires: " . (new DateTime('+3 weeks'))->format(SecurityTxtExpires::FORMAT) . "\n";
400+
$parseResult = $this->securityTxtParser->parseString($contents);
401+
Assert::count(0, $parseResult->getLineErrors());
402+
Assert::false($parseResult->hasErrors());
403+
Assert::true($parseResult->hasWarnings());
404+
Assert::count(2, $parseResult->getLineWarnings());
405+
Assert::count(0, $parseResult->getFileWarnings());
406+
Assert::type(SecurityTxtUnknownField::class, $parseResult->getLineWarnings()[1][0]);
407+
Assert::type(SecurityTxtUnknownField::class, $parseResult->getLineWarnings()[2][0]);
408+
}
409+
410+
411+
public function testParseStringSignedFile(): void
412+
{
413+
$expires = (new DateTime('+2 weeks'))->format(SecurityTxtExpires::FORMAT);
414+
$contents = <<< EOT
415+
-----BEGIN PGP SIGNED MESSAGE-----
416+
Hash: SHA512
417+
418+
Contact: https://example.com/
419+
Expires: {$expires}
420+
Canonical: https://foo.bar.example/
421+
-----BEGIN PGP SIGNATURE-----
422+
423+
iJIEARYKADoWIQSvbhd14xH/eOkR59x/h5ABqcj1CgUCaH7y/xwcc3RpbGwudGVz
424+
dHNAbGlicmFyeS5leGFtcGxlAAoJEH+HkAGpyPUKRvEA/2cVGZs54ieQ7s1nSTla
425+
6O+JHJNaLOf3llvGRi55gW+BAQCDVLTj2q7cbHPS78lD/uvsgFI3NVWwZx8m72sx
426+
SmjCCQ==
427+
=bZYA
428+
-----END PGP SIGNATURE-----
429+
EOT . "\n";
430+
$parseResult = $this->securityTxtParser->parseString($contents);
431+
Assert::false($parseResult->hasErrors());
432+
Assert::false($parseResult->hasWarnings());
433+
Assert::same('AF6E1775E311FF78E911E7DC7F879001A9C8F50A', $parseResult->getSecurityTxt()->getSignatureVerifyResult()?->getKeyFingerprint());
434+
}
435+
436+
437+
public function testParseStringSignedFileDamaged(): void
438+
{
439+
$expires = (new DateTime('+2 weeks'))->format(SecurityTxtExpires::FORMAT);
440+
$contents = <<< EOT
441+
-----BEGIN PGP SIGNED MESSAGE-----
442+
Hash: SHA512
443+
444+
Contact: https://example.com/
445+
Expires: {$expires}
446+
Canonical: https://foo.bar.example/
447+
-----BEGIN PGP SIGNATURE-----
448+
yes, but
449+
-----END PGP SIGNATURE-----
450+
EOT . "\n";
451+
$parseResult = $this->securityTxtParser->parseString($contents);
452+
Assert::false($parseResult->hasErrors());
453+
Assert::true($parseResult->hasWarnings());
454+
Assert::count(1, $parseResult->getLineWarnings());
455+
Assert::type(SecurityTxtSignatureCannotVerify::class, $parseResult->getLineWarnings()[1][0]);
456+
}
457+
458+
396459
public function testParseFetchResult(): void
397460
{
398461
$lines = ["Contact: mailto:example@example.com\r\n", "Expires: 2020-12-31T23:59:59.000Z"];

0 commit comments

Comments
 (0)