'.well-known/security.txt' ] ); Router::add_file( 'security_root', 'security\.txt', 'text/plain', [ $this, 'render' ], [ 'path' => 'security.txt' ] ); add_action( 'acid_aeo_settings_updated', [ $this, 'on_settings_updated' ] ); add_action( 'acid_aeo_daily_tasks', [ $this, 'renew_expires' ] ); } /** * Renders the security contact file. * * A site that has not named a contact yet gets a file that says so, rather * than a fatal error on a public URL: the generator refuses to write an * invalid security.txt, and refusing is not the same as crashing. * * @return string File body. */ public function render(): string { try { return ( new SecurityTxtGenerator() )->generate( $this->data() ); } catch ( InvalidArgumentException ) { return SecurityTxtGenerator::MARKER . "\n" . '# Not configured: add a security contact in Acid AEO settings.' . "\n"; } } /** * Audit checks contributed by this module. * * @return array Checks. */ public function checks(): array { return [ Checks::reachable( 'security_reachable', $this->slug(), __( 'security.txt is reachable', 'acid-aeo' ), Router::url( 'security' ), [ 'type' => 'text/plain', 'weight' => Check::WEIGHT_IMPORTANT, 'fix' => 'flush_rewrite', ] ), Checks::physical_conflict( 'security_physical_conflict', $this->slug(), '.well-known/security.txt', 'security.txt' ), $this->expires_check(), $this->contact_check(), ]; } /** * Whether the `Expires` field still lies in the future. * * RFC 9116 says a security.txt whose expiry date has passed is invalid, and * a date that cannot be parsed is no date at all. * * @return Check The finding. */ private function expires_check(): Check { $id = 'security_expires'; $label = __( 'security.txt expiry date', 'acid-aeo' ); $expires = trim( (string) Options::get( 'security.expires', '' ) ); $stamp = '' !== $expires ? strtotime( $expires ) : false; if ( false === $stamp ) { return Check::fail( $id, $this->slug(), $label, __( 'security.txt has no readable Expires date, which makes the file invalid under RFC 9116.', 'acid-aeo' ), Check::WEIGHT_IMPORTANT, 'renew_security_expires' ); } if ( $stamp < time() + ( self::RENEW_DAYS * DAY_IN_SECONDS ) ) { return Check::fail( $id, $this->slug(), $label, sprintf( /* translators: %s: expiry date. */ __( 'The security.txt Expires date (%s) is in the past or less than thirty days away, so researchers will treat the file as stale.', 'acid-aeo' ), $expires ), Check::WEIGHT_IMPORTANT, 'renew_security_expires' ); } return Check::pass( $id, $this->slug(), $label, sprintf( /* translators: %s: expiry date. */ __( 'security.txt is valid until %s.', 'acid-aeo' ), $expires ), Check::WEIGHT_IMPORTANT ); } /** * Whether anybody is named to receive a security report. * * @return Check The finding. */ private function contact_check(): Check { $id = 'security_contact'; $label = __( 'security.txt contact', 'acid-aeo' ); if ( [] === $this->option_list( 'security.contact' ) ) { return Check::fail( $id, $this->slug(), $label, __( 'No security contact is set, so the published file tells a researcher who found a problem nothing about where to send it.', 'acid-aeo' ), Check::WEIGHT_IMPORTANT ); } return Check::pass( $id, $this->slug(), $label, __( 'A security contact is published, so a researcher knows where to report.', 'acid-aeo' ), Check::WEIGHT_IMPORTANT ); } /** * Drops every cached file after a change that could alter them. * * @return void */ public function invalidate(): void { Cache::invalidate_all(); } /** * Keeps the `Expires` field in the future. * * Runs on the daily maintenance hook. A date that is missing, unreadable or * less than thirty days away is replaced with one a year out. * * @return void */ public function renew_expires(): void { $expires = trim( (string) Options::get( 'security.expires', '' ) ); $stamp = '' !== $expires ? strtotime( $expires ) : false; if ( false !== $stamp && $stamp >= time() + ( self::RENEW_DAYS * DAY_IN_SECONDS ) ) { return; } Options::set( 'security.expires', gmdate( 'Y-m-d\TH:i:s\Z', time() + YEAR_IN_SECONDS ) ); $this->invalidate(); } /** * The contact details the file is rendered from. * * @return array Input for `Generators\SecurityTxtGenerator`. */ public function data(): array { // RFC 9116 wants an RFC 3339 timestamp; the field takes whatever was // typed. A value `strtotime()` cannot read is left out, and the // generator then refuses the file rather than publish a bad date. $expires = trim( (string) Options::get( 'security.expires', '' ) ); $stamp = '' !== $expires ? strtotime( $expires ) : false; $expires = false !== $stamp ? gmdate( 'Y-m-d\TH:i:s\Z', $stamp ) : ''; $data = [ 'contact' => $this->option_list( 'security.contact' ), 'expires' => $expires, 'canonical' => (string) ( SiteData::file_urls()['security'] ?? '' ), 'languages' => $this->option_list( 'security.languages' ), 'policy' => (string) Options::get( 'security.policy_url', '' ), 'acknowledgments' => (string) Options::get( 'security.acknowledgments_url', '' ), 'encryption' => (string) Options::get( 'security.encryption_url', '' ), ]; /** * Filters the contact details `security.txt` is built from. * * @param array $data Contact details, as described by `Modules\SecurityTxt::data()`. */ $data = apply_filters( 'acid_aeo_security_data', $data ); return is_array( $data ) ? $data : []; } /** * One settings list, as non-empty strings. * * @param string $path Dot path of the setting. * @return array Items. */ private function option_list( string $path ): array { $value = Options::get( $path, [] ); $items = []; foreach ( is_array( $value ) ? $value : [] as $item ) { $item = is_scalar( $item ) ? trim( (string) $item ) : ''; if ( '' !== $item ) { $items[] = $item; } } return $items; } }