Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
35 changes: 23 additions & 12 deletions src/Parser/SecurityTxtParser.php
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@
use Spaze\SecurityTxt\Violations\SecurityTxtLineNoEol;
use Spaze\SecurityTxt\Violations\SecurityTxtPossibelFieldTypo;
use Spaze\SecurityTxt\Violations\SecurityTxtSpecViolation;
use Spaze\SecurityTxt\Violations\SecurityTxtUnknownField;

final class SecurityTxtParser
{
Expand Down Expand Up @@ -132,7 +133,17 @@ public function parseString(string $contents, ?string $fileLocation = null, ?int
if (str_starts_with($line, '#')) {
continue;
}
$securityTxt = $this->checkSignature($lineNumber, $line, $contents, $securityTxt);
if (isset($skipSignatureArmorHeaders) && $skipSignatureArmorHeaders) {
if ($line === '') {
$skipSignatureArmorHeaders = false;
}
continue;
}
if ($this->signature->isClearsignHeader($line)) {
$securityTxt = $this->checkSignature($lineNumber, $contents, $securityTxt);
$skipSignatureArmorHeaders = true;
continue;
}
$field = explode(':', $line, 2);
if (count($field) !== 2) {
continue;
Expand All @@ -145,6 +156,8 @@ public function parseString(string $contents, ?string $fileLocation = null, ?int
$suggestion = $this->getSuggestion($securityTxtFields, $fieldName);
if ($suggestion !== null) {
$this->lineWarnings[$lineNumber][] = new SecurityTxtPossibelFieldTypo($field[0], $suggestion->value, $line);
} else {
$this->lineWarnings[$lineNumber][] = new SecurityTxtUnknownField($field[0], $line);
}
}
}
Expand Down Expand Up @@ -176,19 +189,17 @@ public function parseFetchResult(SecurityTxtFetchResult $fetchResult, ?int $expi


/**
* @param int<1, max> $lineNumber
* @param positive-int $lineNumber
*/
private function checkSignature(int $lineNumber, string $line, string $contents, SecurityTxt $securityTxt): SecurityTxt
private function checkSignature(int $lineNumber, string $contents, SecurityTxt $securityTxt): SecurityTxt
{
if ($this->signature->isClearsignHeader($line)) {
try {
$result = $this->signature->verify($contents);
return $securityTxt->withSignatureVerifyResult($result);
} catch (SecurityTxtError $e) {
$this->lineErrors[$lineNumber][] = $e->getViolation();
} catch (SecurityTxtWarning $e) {
$this->lineWarnings[$lineNumber][] = $e->getViolation();
}
try {
$result = $this->signature->verify($contents);
return $securityTxt->withSignatureVerifyResult($result);
} catch (SecurityTxtError $e) {
$this->lineErrors[$lineNumber][] = $e->getViolation();
} catch (SecurityTxtWarning $e) {
$this->lineWarnings[$lineNumber][] = $e->getViolation();
}
return $securityTxt;
}
Expand Down
23 changes: 23 additions & 0 deletions src/Violations/SecurityTxtUnknownField.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
<?php
declare(strict_types = 1);

namespace Spaze\SecurityTxt\Violations;

final class SecurityTxtUnknownField extends SecurityTxtSpecViolation
{

public function __construct(string $fieldName, string $line)
{
parent::__construct(
func_get_args(),
'Field %s is unknown',
[$fieldName],
null,
"# {$line}",
"Remove the line or comment it out",
[],
null,
);
}

}
89 changes: 76 additions & 13 deletions tests/Parser/SecurityTxtParserTest.phpt
Original file line number Diff line number Diff line change
Expand Up @@ -26,8 +26,9 @@ use Spaze\SecurityTxt\Violations\SecurityTxtNoExpires;
use Spaze\SecurityTxt\Violations\SecurityTxtPossibelFieldTypo;
use Spaze\SecurityTxt\Violations\SecurityTxtPreferredLanguagesCommonMistake;
use Spaze\SecurityTxt\Violations\SecurityTxtPreferredLanguagesSeparatorNotComma;
use Spaze\SecurityTxt\Violations\SecurityTxtSpecViolation;
use Spaze\SecurityTxt\Violations\SecurityTxtSignatureCannotVerify;
use Spaze\SecurityTxt\Violations\SecurityTxtTopLevelPathOnly;
use Spaze\SecurityTxt\Violations\SecurityTxtUnknownField;
use Tester\Assert;
use Tester\TestCase;

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

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


public function testParseStringMultipleExpires(): void
{
$contents = "Foo: bar\nExpires: " . (new DateTime('+2 months'))->format(SecurityTxtExpires::FORMAT) . "\nExpires: " . (new DateTime('+3 months'))->format(SecurityTxtExpires::FORMAT) . "\nBar: foo\n";
$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";
$parseResult = $this->securityTxtParser->parseString($contents);
$expiresError = $parseResult->getLineErrors()[3][0];
Assert::type(SecurityTxtMultipleExpires::class, $expiresError);
Expand All @@ -168,7 +169,7 @@ final class SecurityTxtParserTest extends TestCase

public function testParseStringMultipleExpiresAllWrong(): void
{
$contents = "Foo: bar\nExpires: Mon, 15 Aug 2005 15:52:01 +0000\nExpires: Mon, 15 Aug 2015 15:52:01 +0000\nBar: foo\n";
$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";
$parseResult = $this->securityTxtParser->parseString($contents);
Assert::type(SecurityTxtExpiresOldFormat::class, $parseResult->getLineErrors()[2][0]);
Assert::type(SecurityTxtMultipleExpires::class, $parseResult->getLineErrors()[3][0]);
Expand All @@ -180,7 +181,7 @@ final class SecurityTxtParserTest extends TestCase

public function testParseStringMultipleExpiresFirstWrong(): void
{
$contents = "Foo: bar\nExpires: Mon, 15 Aug 2005 15:52:01 +0000\nExpires: " . (new DateTime('+2 months'))->format(SecurityTxtExpires::FORMAT) . "\nBar: foo\n";
$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";
$parseResult = $this->securityTxtParser->parseString($contents);
Assert::type(SecurityTxtExpiresOldFormat::class, $parseResult->getLineErrors()[2][0]);
Assert::true($parseResult->hasErrors());
Expand All @@ -190,7 +191,7 @@ final class SecurityTxtParserTest extends TestCase

public function testParseStringMultipleExpiresFirstCorrect(): void
{
$contents = "Foo: bar\nExpires: " . (new DateTime('+2 months'))->format(SecurityTxtExpires::FORMAT) . "\nExpires: Mon, 15 Aug 2005 15:52:01 +0000\nBar: foo\n";
$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";
$parseResult = $this->securityTxtParser->parseString($contents);
Assert::count(3, $parseResult->getLineErrors()[3]);
Assert::type(SecurityTxtMultipleExpires::class, $parseResult->getLineErrors()[3][0]);
Expand Down Expand Up @@ -220,7 +221,7 @@ final class SecurityTxtParserTest extends TestCase

public function testParseMultipleBadFiles(): void
{
$contents = "Foo: bar\nExpires: " . (new DateTime('+2 months'))->format(SecurityTxtExpires::FORMAT) . "\nExpires: " . (new DateTime('+3 months'))->format(SecurityTxtExpires::FORMAT) . "\nBar: foo\n";
$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";
$parseResult = $this->securityTxtParser->parseString($contents);
Assert::type(SecurityTxtMultipleExpires::class, $parseResult->getLineErrors()[3][0]);
Assert::true($parseResult->hasErrors());
Expand Down Expand Up @@ -249,7 +250,7 @@ final class SecurityTxtParserTest extends TestCase

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


public function testParseStringUnknownField(): void
{
$contents = "Foo: bar\nHash: file-not-signed-0123\nContact: https://example.com/\nExpires: " . (new DateTime('+3 weeks'))->format(SecurityTxtExpires::FORMAT) . "\n";
$parseResult = $this->securityTxtParser->parseString($contents);
Assert::count(0, $parseResult->getLineErrors());
Assert::false($parseResult->hasErrors());
Assert::true($parseResult->hasWarnings());
Assert::count(2, $parseResult->getLineWarnings());
Assert::count(0, $parseResult->getFileWarnings());
Assert::type(SecurityTxtUnknownField::class, $parseResult->getLineWarnings()[1][0]);
Assert::type(SecurityTxtUnknownField::class, $parseResult->getLineWarnings()[2][0]);
}


public function testParseStringSignedFile(): void
{
$expires = (new DateTime('+2 weeks'))->format(SecurityTxtExpires::FORMAT);
$contents = <<< EOT
-----BEGIN PGP SIGNED MESSAGE-----
Hash: SHA512

Contact: https://example.com/
Expires: {$expires}
Canonical: https://foo.bar.example/
-----BEGIN PGP SIGNATURE-----

iJIEARYKADoWIQSvbhd14xH/eOkR59x/h5ABqcj1CgUCaH7y/xwcc3RpbGwudGVz
dHNAbGlicmFyeS5leGFtcGxlAAoJEH+HkAGpyPUKRvEA/2cVGZs54ieQ7s1nSTla
6O+JHJNaLOf3llvGRi55gW+BAQCDVLTj2q7cbHPS78lD/uvsgFI3NVWwZx8m72sx
SmjCCQ==
=bZYA
-----END PGP SIGNATURE-----
EOT . "\n";
$parseResult = $this->securityTxtParser->parseString($contents);
Assert::false($parseResult->hasErrors());
Assert::false($parseResult->hasWarnings());
Assert::same('AF6E1775E311FF78E911E7DC7F879001A9C8F50A', $parseResult->getSecurityTxt()->getSignatureVerifyResult()?->getKeyFingerprint());
}


public function testParseStringSignedFileDamaged(): void
{
$expires = (new DateTime('+2 weeks'))->format(SecurityTxtExpires::FORMAT);
$contents = <<< EOT
-----BEGIN PGP SIGNED MESSAGE-----
Hash: SHA512

Contact: https://example.com/
Expires: {$expires}
Canonical: https://foo.bar.example/
-----BEGIN PGP SIGNATURE-----
yes, but
-----END PGP SIGNATURE-----
EOT . "\n";
$parseResult = $this->securityTxtParser->parseString($contents);
Assert::false($parseResult->hasErrors());
Assert::true($parseResult->hasWarnings());
Assert::count(1, $parseResult->getLineWarnings());
Assert::type(SecurityTxtSignatureCannotVerify::class, $parseResult->getLineWarnings()[1][0]);
}


public function testParseFetchResult(): void
{
$lines = ["Contact: mailto:example@example.com\r\n", "Expires: 2020-12-31T23:59:59.000Z"];
Expand Down