spaze/security-txt

security.txt (RFC 9116) generator, parser, validator

Maintainers

Package info

github.com/spaze/security-txt

pkg:composer/spaze/security-txt

Transparency log

Statistics

Installs: 7 337

Dependents: 0

Suggesters: 0

Stars: 9

Open Issues: 1

v2.2.1 2026-08-23 01:27 UTC

README

This package is a PHP library that can generate, parse, and validate security.txt files. It comes with an executable script that you can use from the command line, in a CI test, or in a pipeline (for example, in GitHub Actions).

The security.txt document represents a text file that's both human-readable and machine-parsable to help organizations describe their vulnerability disclosure practices to make it easier for researchers to report vulnerabilities. The format was created by EdOverflow and Yakov Shafranovich and is specified in RFC 9116. You can find more about security.txt at securitytxt.org.

I have also written a blogpost about security.txt and how it may be helpful when reporting vulnerabilities:

Installation

Install the package with Composer:

composer require spaze/security-txt

Requirements and supported versions

Version Requirements Notes
2.x PHP 8.5
+ optional curl extension to fetch from remote hosts
+ optional gnupg extension to verify signatures
Current stable release
1.x PHP 8.3, 8.4, 8.5
+ optional curl extension to fetch from remote hosts
+ optional gnupg extension to verify signatures
End of life as of 1.0.1, no further releases

1.x reached end of life with 1.0.1, which carries every security fix this library had published by then. There will be no further 1.x releases, security or otherwise. 1.x existed so that PHP 8.3 and 8.4 could be used, so if you cannot upgrade PHP there is no supported version for you.

As a validator

How does the validation work

This package can validate security.txt file either by providing

  • the file contents as a string by calling Spaze\SecurityTxt\Parser\SecurityTxtParser::parseString()
  • a fetch result object of class Spaze\SecurityTxt\Fetcher\SecurityTxtFetchResult into Spaze\SecurityTxt\Parser\SecurityTxtParser::parseFetchResult()
    • the result object would possibly be stored or cached, or sent from a serverless service like AWS Lambda doing the fetch
  • a Uri\WhatWg\Url object to Spaze\SecurityTxt\Check\SecurityTxtCheckHost::check()
    • you can create the object with e.g. new Uri\WhatWg\Url('https://example.com/')
    • only the host part (example.com in this case) and the port, if specified, will be used, the scheme, path etc. will be ignored
    • Uri\WhatWg\Url is from the Uri extension, which is always available starting with PHP 8.5

Each of the options above will call preceding method and add more validations which are only possible in that particular case.

There's also a command line script in bin which uses Spaze\SecurityTxt\Check\SecurityTxtCheckHostCli::check() mostly just to add command line output to Spaze\SecurityTxt\Check\SecurityTxtCheckHost::check(), see "Command line usage" below.

How to use the validator

Spaze\SecurityTxt\Check\SecurityTxtCheckHost::check() is probably what you'd want to use as it provides the most comprehensive checks, can pass a URL, not just a hostname, and also supports callbacks. It accepts these parameters:

Uri\WhatWg\Url $url

A URL where the file will be looked for, you can pass just https://example.com, no need to use the full path to the security.txt file as only the hostname and port, if specified, of the URL will be used for further checks

?int $expiresWarningThreshold = null

The validator will start throwing warnings if the file expires soon, and you can say what "soon" means by specifying the number of days here

bool $strictMode = false

If you enable strict mode, then the file will be considered invalid, meaning SecurityTxtCheckHostResult::isValid() will return false even when there are only warnings, with strict mode disabled, the file with only warnings would still be valid and SecurityTxtCheckHostResult::isValid() would return true

bool $requireTopLevelLocation = false

When specified, the top-level /security.txt location must also exist (or be redirected) in addition to /.well-known/security.txt, otherwise a warning will be issued

bool $noIpv6 = false

Because some environments do not support IPv6, looking at you GitHub Actions

?int $maxAllowedRedirects = null

Maximum number of redirects to follow when fetching security.txt. Set to 0 to disable redirects, null to use the default (5).

Spaze\SecurityTxt\Check\SecurityTxtCheckHost::check() returns a Spaze\SecurityTxt\Check\SecurityTxtCheckHostResult object with some obvious and less obvious properties. The less obvious ones can be obtained with the following methods. All of them return an array of SecurityTxtSpecViolation descendants.

The violation classes are part of the public API, their constructors are not. They are called by the library, and by SecurityTxtJson when recreating violations from stored JSON, so their parameters can change in a minor version.

getFetchErrors()

Returns list<SecurityTxtSpecViolation> and contains errors encountered when fetching the file from a server. For example but not limited to:

  • When the content type or charset is wrong
  • When the URL scheme is not HTTPS

getFetchWarnings()

Also returns list<SecurityTxtSpecViolation> and has warnings when fetching the file, like for example but not limited to:

  • When the files at /security.txt and /.well-known/security.txt differ
  • When /security.txt does not redirect to /.well-known/security.txt

getLineErrors()

Returns array<int, list<SecurityTxtSpecViolation>> where the array int key is the line number. Contains errors discovered when parsing and validating the contents of the security.txt file. These errors are produced by any class that implements the FieldProcessor interface. The errors include but are not limited to:

  • When a field uses incorrect separators
  • When a field value is not URL or the URL doesn't use https:// scheme

getLineWarnings()

Also returns array<int, list<SecurityTxtSpecViolation>> where the array int key is the line number. Contains warnings generated by any class that implements the FieldProcessor interface, when parsing and validating the contents of the security.txt file. These warnings include but are not limited to:

  • When the Expires field's value is too far into the future

getFileErrors()

Returns list<SecurityTxtSpecViolation>, the list contains file-level errors which cannot be paired with any single line. Most are generated by FieldValidator child classes, the rest are about the file rather than its fields, and include:

  • When mandatory fields like Contact or Expires are missing
  • When the file was not served over https://
  • When the file contents are not valid UTF-8

getFileWarnings()

Returns list<SecurityTxtSpecViolation>, the list contains file-level warnings that cannot be paired with any single line. These warnings are generated by FieldValidator child classes, and include for example:

  • When the file is signed, but there's no Canonical field

Callbacks

SecurityTxtCheckHost::check() supports callbacks that can be set with SecurityTxtCheckHost::addOn*() methods. You can use them to get the parsing information in "real time", and are used for example by the bin/checksecuritytxt.php script via the \Spaze\SecurityTxt\Check\SecurityTxtCheckHostCli class to print information as soon as it is available. What a callback is handed is part of the API and each addOn*() method documents it: URLs arrive as Uri\WhatWg\Url, the host as Spaze\SecurityTxt\SecurityTxtHost, and an error or warning callback is handed the SecurityTxtSpecViolation itself rather than its message, how to fix and correct value as three separate strings.

User agent

When fetching the security.txt file, the library uses a default User-Agent HTTP header. The default value contains a link back to the GitHub repository, but it is recommended you use a custom User-Agent header. You can set it in SecurityTxtFetcherCurlClient constructor (the $userAgent parameter), and then pass the client object to SecurityTxtFetcher constructor as one of its arguments. The value must not be empty, the constructor throws a LogicException if it is, and it must not contain control characters, which would make it possible to inject other headers, SecurityTxtCannotOpenUrlUserAgentInvalidException is thrown when it does.

Maximum file size

The size of the file is limited when fetching the contents from remote hosts. By default, the limit is 10 000 bytes, but you can change it in SecurityTxtFetcherCurlClient constructor (the $maxResponseLength parameter). Then, when creating SecurityTxtFetcher, pass that customized client as its HTTP client argument together with the other constructor arguments required by SecurityTxtFetcher.

Fetching restrictions

The file is fetched from hosts you don't control, so the fetcher is restrictive by default:

  • Fetching always starts at https://, whatever scheme you pass in, and any username, password, query and fragment are removed from the URL first.
  • Only http:// and https:// are followed, a redirect anywhere else throws SecurityTxtUrlUnsupportedSchemeException.
  • Redirects are followed by the library, not by curl, at most 5 by default. Every target goes through the same checks as the original URL.
  • The host is resolved by the library and each address is validated before connecting: private and reserved ranges are rejected, only publicly routable addresses are used. IPv6 addresses are also checked for NAT64, because those embed an IPv4 address the range check can't see: an address with the RFC 6052 well-known prefix (64:ff9b::/96) is rejected when the IPv4 it embeds is not public, and the RFC 8215 local-use prefix (64:ff9b:1::/48) is rejected as a whole.
  • curl then connects to that validated address, and the response is rejected with SecurityTxtConnectedToWrongIpAddressException when it turns out to have talked to a different one.
  • The certificate and the hostname are verified, and the connection times out after 5 seconds, the whole transfer after 10.

DNS lookups

DNS resolution is handled by SecurityTxtPhpDnsProvider, which uses PHP's built-in dns_get_record(). This function has no timeout parameter, the system DNS timeout applies. If you need explicit DNS timeout control, or would like to use for example DNS-over-HTTPS, you can add a custom provider, which implements the SecurityTxtDnsProvider interface, and then pass it to SecurityTxtFetcher in the $dnsLookupProvider parameter.

Signature verification

This library verifies that the signature is a valid OpenPGP cleartext signature, but cannot verify whether the signing key is trustworthy, for example when the key is not in local keyring etc. As the security.txt RFC puts it: "it is always the security researcher's responsibility to make sure the key being used is indeed one they trust." Verify the key fingerprint or key id out-of-band, for example by checking it against the company's website or other trusted sources.

Verifying a signature can, depending on your GnuPG configuration, make an outbound network connection, which may be surprising. When the key that made the signature isn't in the local keyring (the usual case when you're checking someone else's security.txt) and GnuPG is configured to fetch missing keys automatically (auto-key-retrieve, off by default), it will try to retrieve the key over the network. To be certain verification never makes a network connection, make sure auto-key-retrieve stays disabled (it is unless you turned it on), or point GnuPG at an isolated home directory that holds no keys and does not enable key retrieval, either through the GNUPGHOME environment variable or the $homeDir argument of the SecurityTxtSignatureGnuPgProvider constructor (see Signing the file).

In a signed file, the signature has to cover the whole file: a field before the -----BEGIN PGP SIGNED MESSAGE----- header or after the -----END PGP SIGNATURE----- line is reported with a SecurityTxtFieldNotCoveredBySignature error, and an extra -----BEGIN PGP SIGNED MESSAGE----- header with a SecurityTxtSignatureMultipleCleartextHeaders error, both making the file invalid. Such fields are still parsed into the SecurityTxt object, the errors are what marks them as untrusted. Lines between the -----BEGIN PGP SIGNATURE----- and -----END PGP SIGNATURE----- lines are the signature itself and are not parsed as fields.

JSON

The Spaze\SecurityTxt\Check\SecurityTxtCheckHostResult object can be encoded to JSON with json_encode(), and decoded back with Spaze\SecurityTxt\Json\SecurityTxtJson::createCheckHostResultFromJsonValues().

The primary use case for JSON-encoded objects is a result cache. But JSON can also be used when you want to fetch security.txt using serverless services like AWS Lambda, and then process the fetch result yourself.

If that's the case, then you may want to encode the Spaze\SecurityTxt\Fetcher\SecurityTxtFetchResult object created by Spaze\SecurityTxt\Fetcher\SecurityTxtFetcher::fetch(). Spaze\SecurityTxt\Json\SecurityTxtJson::createFetchResultFromJsonValues() then decodes it back from JSON.

Fetch exceptions can be recreated with Spaze\SecurityTxt\Json\SecurityTxtJson::createFetcherExceptionFromJsonValues().

JSON is not versioned. Newer versions of this library will make a best effort to decode JSON created by previous versions, but compatibility cannot be guaranteed across refactors or format changes.

When the JSON can't be decoded, the create*FromJsonValues() methods throw Spaze\SecurityTxt\Check\Exceptions\SecurityTxtCannotParseJsonException. If it's a result you have stored, treat it as a cache miss and check the host again, instead of reporting the error to the user: the stored data was either written by an older version of this library or damaged some other way, and it will not become readable later.

The other methods

The Spaze\SecurityTxt\Parser\SecurityTxtParser::parseString() method returns a Spaze\SecurityTxt\Parser\SecurityTxtParseStringResult object. Spaze\SecurityTxt\Parser\SecurityTxtParser::parseFetchResult() returns a Spaze\SecurityTxt\Parser\SecurityTxtParseHostResult object, which also contains a Spaze\SecurityTxt\Fetcher\SecurityTxtFetchResult object. All the result objects have similar methods as what's described above for SecurityTxtCheckHostResult.

As a writer

You can create a security.txt file programmatically:

  1. Create a SecurityTxt object
  2. Add what's needed
  3. Pass it to SecurityTxtWriter::write() it will return the security.txt contents as a string

See below if you want to add an OpenPGP signature.

Value validation

By default, values are validated when set, and an exception is thrown when they're invalid. You can set validation level in the SecurityTxt constructor using the SecurityTxtValidationLevel enum:

  • NoInvalidValues (an exception will be thrown, and the value won't be set, this is the default setting)
  • AllowInvalidValues (an exception will be thrown but the value will still be set)
  • AllowInvalidValuesSilently (an exception will not be thrown, and the value will be set)

Content type

You can use the following SecurityTxtContentType constants to serve the file with correct HTTP content type:

  • SecurityTxtContentType::MEDIA_TYPE, the value to be sent as Content-Type header value (text/plain; charset=utf-8);
  • SecurityTxtContentType::CONTENT_TYPE, the correct content type text/plain
  • SecurityTxtContentType::CHARSET, the charset utf-8
  • SecurityTxtContentType::CHARSET_PARAMETER, the correct charset parameter name and value as charset=utf-8

Example

$securityTxt = new SecurityTxt();
$securityTxt->addContact(new SecurityTxtContact('https://contact.example'));
$securityTxt->addContact(SecurityTxtContact::phone('123456'));
$securityTxt->addContact(SecurityTxtContact::email('email@com.example'));
$securityTxt->addAcknowledgments(new SecurityTxtAcknowledgments('https://ack1.example'));
$securityTxt->setExpires(new SecurityTxtExpiresFactory()->create(new DateTimeImmutable('+3 months midnight')));
$securityTxt->addAcknowledgments(new SecurityTxtAcknowledgments('ftp://ack2.example'));
$securityTxt->setPreferredLanguages(new SecurityTxtPreferredLanguages(['en', 'cs-CZ']));
header('Content-Type: ' . SecurityTxtContentType::MEDIA_TYPE);
echo new SecurityTxtWriter()->write($securityTxt);

Signing the file

One option to sign the file using an OpenPGP cleartext signature as per the security.txt specification is to pre-sign the security.txt file using the gpg command line utility and store the result as a static file in your repository. I'd recommend creating the signatures that way as it doesn't expose your private keys to the web server and the web app. Allowing the app and the server to access your private keys brings a handful of new security problems to solve, which some of them are mentioned below.

Creating a new signing key is beyond the scope of this document, but you can refer to sources like the GitHub Docs. Related challenges like key distribution, secure storage, and expiration, while interesting to address properly, are also not covered here.

Having said that, this library also allows you to create the signature programmatically by calling Spaze\SecurityTxt\Signature\SecurityTxtSignature::sign() (requires the gnupg PHP extension):

$gnuPgProvider = new SecurityTxtSignatureGnuPgProvider();
$signature = new SecurityTxtSignature($gnuPgProvider);
$securityTxt = new SecurityTxt();
// $securityTxt->addContact(...) etc.
$writer = new SecurityTxtWriter();
$contents = $writer->write($securityTxt);
$signingKeyFingerprint = '...'; // Or anything that refers to a unique key (user id, key id, ...)
$keyPassphrase = '...'; // Don't commit the passphrase to Git, please don't
echo $signature->sign($contents, $signingKeyFingerprint, $keyPassphrase);

The SecurityTxtSignature::sign() method makes use of the keyring of the current user (which may be a web server user). This keyring is normally located in the .gnupg directory in the user's home dir. To specify a custom location, pass the path to the keyring in the Spaze\SecurityTxt\Signature\Providers\SecurityTxtSignatureGnuPgProvider constructor, for example:

$gnuPgProvider = new SecurityTxtSignatureGnuPgProvider('/home/www');

If you wish, you can instead store the path to the keyring in the environment variable GNUPGHOME. Make sure the keyring is not publicly accessible, do not store keyring in public_html or similar directories. Also don't add the keyring to your Git repository.

If you're going to use a key for this library, I'd strongly recommend you create a key only to sign the file and do not use the key for anything else. You can then sign the key with your main key, if you want.

Caching the signed file

If you're going to create the signature using this library, I don't recommend doing it on each request. Instead, you can cache the signed contents using for example the Symfony Cache component:

use Symfony\Component\Cache\Adapter\FilesystemAdapter;
use Symfony\Contracts\Cache\ItemInterface;

$cache = new FilesystemAdapter();
$cachedContents = $cache->get('securitytxt_file', function (ItemInterface $item) use ($securityTxt, $signature, $contents, $signingKeyFingerprint, $keyPassphrase): string {
    $item->expiresAt($securityTxt->getExpires()->getDateTime());
    return $signature->sign($contents, $signingKeyFingerprint, $keyPassphrase);
});

echo $cachedContents;

The following example uses the Nette Cache library, the code is very similar to the example above:

use Nette\Caching\Cache;
use Nette\Caching\Storages\FileStorage;

$storage = new FileStorage('/tmp/cache');
$cache = new Cache($storage);
$cachedContents = $cache->load('securitytxt_file', function () use ($signature, $contents, $signingKeyFingerprint, $keyPassphrase): string {
    return $signature->sign($contents, $signingKeyFingerprint, $keyPassphrase);
}, [Cache::Expire => $securityTxt->getExpires()->getDateTime()]);

echo $cachedContents;

Command line usage

The checksecuritytxt.php script, located in the bin directory, prints progress and validation errors and warnings. It can be used from the command line or in automated tests.

Usage:

checksecuritytxt.php <URL or hostname> [days] [--colors] [--strict] [--require-top-level-location] [--no-ipv6]

Parameters:

  • URL or hostname: A URL, a hostname or a domain you want to check. If you provide just a hostname or a domain (e.g. example.com) then it cannot contain a port. If you provide a full URL (e.g., https://example.com:4433/foo), the script will extract and use only the hostname part and port if specified.
  • days: If the file expires in less than days days, the script will print a warning.
  • --colors: Enables colored output using red, green, and other colors for better readability.
  • --strict: Upgrades all warnings to errors, enforcing stricter validation.
  • --require-top-level-location: When specified, the /security.txt location must also exist or be redirected, otherwise a warning will be issued.
  • --no-ipv6: Disables IPv6 usage. When this option is set, the script effectively ignores AAAA DNS records and uses only A records.

The script returns the following status codes:

  • 0: The file is valid.
  • 1: Returned if any of the following conditions are true:
    • The file has expired.
    • The file has errors.
    • The file has warnings when using --strict.
  • 2: No hostname or URL was passed.
  • 3: The file cannot be loaded.

CI Pipelines

If you'd like to check your security.txt file automatically using a CI (continuous integration) platform, such as GitHub Actions, you can use the command-line script described above. In general, you’ll need to follow these steps:

  1. Install PHP if it is not already installed.
  2. Install this package using Composer.
  3. Run the checksecuritytxt.php script.

GitHub Actions' ubuntu-24.04 runner (also as ubuntu-latest at the time of writing) has PHP 8.3 preinstalled, so you can use checksecuritytxt.php without installing anything else, the version 1.x of this lib can be used with PHP 8.3. Version 2.x requires PHP 8.5 or newer, and would require ubuntu-26.04 which comes with PHP 8.5. You can also use the setup-php GitHub action to install the required PHP version.

But unfortunately the gnupg PHP extension is not available on GitHub runners by default so you won't be able to verify the file signatures with just the GitHub-provided PHP. If you want to verify signatures you'll need to use the setup-php GitHub action which can also set up the extension.

You can use my own checks as a template or for inspiration; see the securitytxt.yml file in my repository.

Formatting exceptions and contents

The messages in the exceptions as thrown by this library do not contain any sensitive information and are safe to display to the user using the getMessage() method. The same goes for the messages of the violations in SecurityTxtCheckHostResult, whose getMessage() and getHowToFix() behave the same way. The server-supplied values quoted in them are encoded down to printable ASCII first, so a server can't move a terminal's cursor, colour its own text to read like a result, or reverse what follows it with a bidirectional override. A value the library knows to be a URL is the exception: it exists only because it parsed, and parsing refuses a host with a control character in it and percent encodes everything after the host, so it is quoted as it reads, internationalized domains included. Reading as it is written is the point of that exception, so such a URL keeps whatever script its host is written in: a right-to-left host reorders neutral text around it the way any right-to-left text does, and a host that reads like another one reads like it here too, which no certificate covers for a URL the library never fetches, such as one from a Canonical or Contact field. That covers what a checked host sends, and a result rebuilt from serialized JSON too: the message formats exist only in code, selected by the class name and, for the one violation with a variable reason, by an enum case value, so the JSON can pick a format but cannot supply one, and the values it does supply are encoded the same way. checksecuritytxt.php prints a violation's values through the very same rule, so what it shows and what a violation's getMessage() returns agree. An exception is nearly the same: it carries a host as a SecurityTxtHost, which like a URL exists only because it parsed and so is quoted as it reads, and everything else as a string that getMessage() encodes. Its constructor params stay scalar so a stored result can be rebuilt, and the script parses the ones that are URLs back and prints those as they read. But please be aware that the messages still contain server-supplied information, so please do not display the messages as HTML and do not feed them into a Markdown parser or similar. If you'd do that, a malicious server could inject content that would result in Cross-Site Scripting attack for example.

The same applies to other server-supplied values you might display, such as the fetched file contents (SecurityTxtFetchResult::getContents()) and the redirect URLs (getRedirects()): escape them before displaying and don't render them as HTML. Those are not encoded, and neither are the values from getMessageValues() below, because only the code displaying them knows what it is displaying them into.

Formatting messages

If you'd like to format some of the values contained in the messages, you can use the exception's getMessageFormat() and getMessageValues() methods. The getMessageFormat() method will return an error message with %s placeholders, while getMessageValues() will return the values, including the server-supplied ones, which you can, after a proper sanitization and/or escaping, wrap in <code> tags for example, and use them to replace the placeholders. A violation's values are string|Uri\WhatWg\Url and an exception's are string|Spaze\SecurityTxt\SecurityTxtHost, the non-string ones being the values each knows to be a URL or a host. Neither has a string form of its own, so do not pass one straight to implode(), htmlspecialchars() or anything else expecting a string: call toUnicodeString() on a Url and getUnicode() on a SecurityTxtHost, or hand either to Spaze\SecurityTxt\SecurityTxtPrintableValue::render(), which takes all three and is what this library prints with.

The same goes for formatting SecurityTxtSpecViolation object messages: you can use getMessageFormat() and getMessageValues(), and also getHowToFixFormat() and getHowToFixValues().