-
Notifications
You must be signed in to change notification settings - Fork 213
Plugin Directory: Email committers the outcome of a security scan #795
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Closed
obenland
wants to merge
15
commits into
WordPress:trunk
from
obenland:feature/gandalf-scan-committer-notifications
Closed
Changes from all commits
Commits
Show all changes
15 commits
Select commit
Hold shift + click to select a range
76d0923
Plugin Directory: Email committers the outcome of a security scan.
obenland 503b31a
Plugin Directory: A block is the outcome of the security review, not …
obenland 8b81d0d
Plugin Directory: Drop the no-action claim from the advisory scan email.
obenland a2a67dc
Plugin Directory: Enrich the scan email with snippets, explanations, …
obenland e6b0422
Plugin Directory: Separate the scan email outro from the findings.
obenland aee1ec6
Plugin Directory: Refine the scan email copy.
obenland 6ff36f5
Plugin Directory: Outdent code snippets in the scan email.
obenland 47bf5ea
Plugin Directory: Drop the redundant findings parameter from the comm…
obenland 1c8d5a9
Plugin Directory: Escape a lone carriage return in scan email snippets.
obenland 6f9ff24
Plugin Directory: Neutralize forged Markdown headings in scan email p…
obenland 2ec9fdf
Plugin Directory: Neutralize markup in the scan email intro name and …
obenland 4b5d678
Plugin Directory: Substitute invalid UTF-8 when escaping scan email p…
obenland 4966035
Plugin Directory: Neutralize the remaining forged Markdown blocks in …
obenland 6f0d9f3
Plugin Directory: Expand and harden the committer-email test coverage.
obenland 0e74372
Plugin Directory: Restore the account-exclusion globals after each sc…
obenland File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
298 changes: 298 additions & 0 deletions
298
...rg/public_html/wp-content/plugins/plugin-directory/email/class-security-scan-findings.php
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,298 @@ | ||
| <?php | ||
| /** | ||
| * Email to plugin committers about the outcome of a security scan. | ||
| * | ||
| * @package WordPressdotorg\Plugin_Directory\Email | ||
| */ | ||
|
|
||
| declare( strict_types = 1 ); | ||
|
|
||
| namespace WordPressdotorg\Plugin_Directory\Email; | ||
|
|
||
| /** | ||
| * Notifies plugin committers of a completed security scan's findings, and | ||
| * whether the scanned release was blocked from being served pending review. | ||
| * | ||
| * Expects a `record` arg: the completed scan record built by | ||
| * `Plugin_Scan_Gandalf`, with findings ordered highest risk first. | ||
| */ | ||
| class Security_Scan_Findings extends Markdown_Base { | ||
|
|
||
| /** | ||
| * The completed scan record is required. | ||
| * | ||
| * @var array | ||
| */ | ||
| protected $required_args = [ 'record' ]; | ||
|
|
||
| /** | ||
| * The email subject. | ||
| * | ||
| * @return string The subject. | ||
| */ | ||
| public function subject(): string { | ||
| $record = $this->args['record']; | ||
|
|
||
| if ( 'blocked' === $record['action'] ) { | ||
| /* translators: 1: Plugin name. 2: Plugin version. */ | ||
| $subject = __( '%1$s %2$s has been blocked due to security findings', 'wporg-plugins' ); | ||
| } else { | ||
| /* translators: 1: Plugin name. 2: Plugin version. */ | ||
| $subject = __( 'Security scan findings in %1$s %2$s', 'wporg-plugins' ); | ||
| } | ||
|
|
||
| return sprintf( $subject, $this->plugin_title(), $record['version'] ); | ||
| } | ||
|
|
||
| /** | ||
| * The Markdown content of the email. | ||
| * | ||
| * @return string The email content. | ||
| */ | ||
| public function markdown(): string { | ||
| $record = $this->args['record']; | ||
|
|
||
| $greeting = sprintf( | ||
| /* translators: %s: Committer's display name. */ | ||
| __( 'Howdy %s,', 'wporg-plugins' ), | ||
| $this->user_text( $this->user ) | ||
| ); | ||
|
|
||
| $intro = sprintf( | ||
| /* translators: 1: Plugin name. 2: Plugin version. 3: URL to the automated security review documentation. */ | ||
| __( 'An automated security review of %1$s %2$s reported the following findings. Learn more about these reviews in the [plugin developer handbook](%3$s).', 'wporg-plugins' ), | ||
| $this->excerpt( $this->plugin_title(), 200 ), | ||
| $this->excerpt( (string) $record['version'], 32 ), | ||
| 'https://developer.wordpress.org/plugins/wordpress-org/automated-security-review/' | ||
| ); | ||
|
|
||
| if ( 'blocked' === $record['action'] ) { | ||
| $action = __( 'The issues found were severe enough to block this version from being offered as an update. Sites running a previous version keep receiving that version. Please address the findings and release a new version.', 'wporg-plugins' ); | ||
| } else { | ||
| $action = __( 'Please review the findings and address them in an upcoming release.', 'wporg-plugins' ); | ||
| } | ||
|
|
||
| $parts = [ $greeting, $intro, $action ]; | ||
|
|
||
| $findings = $this->findings_text( $record ); | ||
| if ( '' !== $findings ) { | ||
| array_push( $parts, $findings, '---' ); | ||
| } | ||
|
|
||
| $parts[] = __( 'If you have questions or believe a finding does not apply, please reply to this email with the details.', 'wporg-plugins' ); | ||
|
|
||
| return implode( "\n\n", $parts ); | ||
| } | ||
|
|
||
| /** | ||
| * The plain-text content for the email template. | ||
| * | ||
| * Decodes the entities prose() encodes into the Markdown source. | ||
| * | ||
| * @return string The plain-text content. | ||
| */ | ||
| public function body(): string { | ||
| return html_entity_decode( $this->markdown(), ENT_QUOTES | ENT_HTML5, 'UTF-8' ); | ||
| } | ||
|
|
||
| /** | ||
| * Format the findings, highest risk first. | ||
| * | ||
| * Finding strings are untrusted scanner output; only the risk score is | ||
| * contractually guaranteed. The title line ends in two spaces to | ||
| * hard-break in Markdown. | ||
| * | ||
| * @param array $record The completed scan record. | ||
| * @return string The findings, or an empty string without findings. | ||
| */ | ||
| private function findings_text( array $record ): string { | ||
| $items = []; | ||
|
|
||
| foreach ( $record['findings'] as $finding ) { | ||
| $title = $this->excerpt( (string) ( $finding['title'] ?? '' ), 300 ); | ||
|
|
||
| $item = sprintf( | ||
| '**%1$s** — %2$s', | ||
| number_format_i18n( (float) ( $finding['risk_score'] ?? 0 ), 1 ), | ||
| $title ?: __( '(no summary provided)', 'wporg-plugins' ) | ||
| ); | ||
|
|
||
| if ( ! empty( $finding['file_path'] ) ) { | ||
| $file_path = $this->excerpt( (string) $finding['file_path'], 200 ); | ||
| $line = (int) ( $finding['line'] ?? 0 ); | ||
|
|
||
| // The excerpted label can't contain a `]`, the URL is percent-encoded; the link syntax stays intact. | ||
| $item .= sprintf( | ||
| " \n[%1\$s](%2\$s)", | ||
| $file_path . ( $line ? ':' . $line : '' ), | ||
| $this->file_url( (string) $record['release_ref'], (string) $finding['file_path'], $line ) | ||
| ); | ||
| } | ||
|
|
||
| $snippet = $this->snippet_text( (string) ( $finding['code_snippet'] ?? '' ) ); | ||
| if ( '' !== $snippet ) { | ||
| $item .= "\n\n" . $snippet; | ||
| } | ||
|
|
||
| $explanation = $this->prose( (string) ( $finding['explanation'] ?? '' ), 2000 ); | ||
| if ( '' !== $explanation ) { | ||
| $item .= "\n\n" . $explanation; | ||
| } | ||
|
|
||
| $items[] = $item; | ||
| } | ||
|
|
||
| if ( ! $items ) { | ||
| return ''; | ||
| } | ||
|
|
||
| return '### ' . __( 'Findings', 'wporg-plugins' ) . "\n\n" . implode( "\n\n---\n\n", $items ); | ||
| } | ||
|
|
||
| /** | ||
| * Return a link to the finding's file in the plugins Trac browser. | ||
| * | ||
| * @param string $release_ref The scanned release ref. | ||
| * @param string $file_path The file path, relative to the plugin root. | ||
| * @param int $line The line number, or 0 for none. | ||
| * @return string The Trac browser URL. | ||
| */ | ||
| private function file_url( string $release_ref, string $file_path, int $line ): string { | ||
| $url = sprintf( | ||
| 'https://plugins.trac.wordpress.org/browser/%s/%s/%s', | ||
| $this->plugin->post_name, | ||
| 'trunk' === $release_ref ? 'trunk' : 'tags/' . rawurlencode( $release_ref ), | ||
| implode( '/', array_map( 'rawurlencode', explode( '/', ltrim( $file_path, '/' ) ) ) ) | ||
| ); | ||
|
|
||
| if ( $line ) { | ||
| $url .= '#L' . $line; | ||
| } | ||
|
|
||
| return $url; | ||
| } | ||
|
|
||
| /** | ||
| * Format an untrusted code snippet as an indented Markdown code block. | ||
| * | ||
| * Unlike a fence, an indented code block cannot be broken out of, and | ||
| * Markdown escapes its content in the HTML variant. | ||
| * | ||
| * @param string $snippet The code snippet. | ||
| * @return string The code block, or an empty string for an empty snippet. | ||
| */ | ||
| private function snippet_text( string $snippet ): string { | ||
| // Normalize every newline the Markdown processor recognizes (it maps \r\n and lone \r to \n): an unindented line it splits out later escapes the code block. | ||
| $snippet = str_replace( [ "\r\n", "\r" ], "\n", trim( $snippet, "\n\r" ) ); | ||
| $lines = array_slice( explode( "\n", $snippet ), 0, 10 ); | ||
| $snippet = mb_strimwidth( implode( "\n", $this->outdent( $lines ) ), 0, 1000, '…' ); | ||
|
|
||
| if ( '' === trim( $snippet ) ) { | ||
| return ''; | ||
| } | ||
|
|
||
| return ' ' . str_replace( "\n", "\n ", $snippet ); | ||
| } | ||
|
|
||
| /** | ||
| * Strip the whitespace prefix shared by all non-blank lines, keeping | ||
| * the block's relative indentation. | ||
| * | ||
| * @param array $lines The lines to outdent. | ||
| * @return array The outdented lines. | ||
| */ | ||
| private function outdent( array $lines ): array { | ||
| $prefix = null; | ||
|
|
||
| foreach ( $lines as $line ) { | ||
| if ( '' === trim( $line ) ) { | ||
| continue; | ||
| } | ||
|
|
||
| $indent = substr( $line, 0, strspn( $line, " \t" ) ); | ||
|
|
||
| if ( null === $prefix ) { | ||
| $prefix = $indent; | ||
| continue; | ||
| } | ||
|
|
||
| $length = 0; | ||
| $max_length = min( strlen( $prefix ), strlen( $indent ) ); | ||
| while ( $length < $max_length && $prefix[ $length ] === $indent[ $length ] ) { | ||
| ++$length; | ||
| } | ||
| $prefix = substr( $prefix, 0, $length ); | ||
|
|
||
| if ( '' === $prefix ) { | ||
| break; | ||
| } | ||
| } | ||
|
|
||
| if ( ! $prefix ) { | ||
| return $lines; | ||
| } | ||
|
|
||
| return array_map( | ||
| static function ( string $line ) use ( $prefix ): string { | ||
| return str_starts_with( $line, $prefix ) ? substr( $line, strlen( $prefix ) ) : $line; | ||
| }, | ||
| $lines | ||
| ); | ||
| } | ||
|
|
||
| /** | ||
| * Bound untrusted prose, preserving paragraphs, without live markup. | ||
| * | ||
| * Angle brackets are encoded rather than stripped, so text like `<slug>` | ||
| * or `<?php` survives as text; body() decodes the plain-text variant. | ||
| * Backticks become apostrophes: a line-leading backtick pair would turn | ||
| * into a code block (Markdown::code_trick()). Line indents go for the | ||
| * same reason, and brackets can't form Markdown links or images. A | ||
| * line-leading block marker — heading, rule, list, table, definition | ||
| * list, or fence — is emitted as a numeric entity, so it displays as | ||
| * typed but can't forge a block element; a lone carriage return is folded | ||
| * first, since the Markdown processor treats it as a newline and would | ||
| * otherwise expose a marker mid-text. | ||
| * | ||
| * @param string $text The text to bound. | ||
| * @param int $length Maximum length in characters. | ||
| * @return string The bounded text. | ||
| */ | ||
| private function prose( string $text, int $length ): string { | ||
| $text = str_replace( [ "\r\n", "\r" ], "\n", trim( $text ) ); | ||
| $text = preg_replace( '/^[ \t]+/m', '', $text ); | ||
| $text = preg_replace( "/\n{3,}/", "\n\n", $text ); | ||
| $text = mb_strimwidth( $text, 0, $length, '…' ); | ||
| $text = str_replace( [ '[', ']', '`' ], [ '(', ')', "'" ], $text ); | ||
| $text = htmlspecialchars( $text, ENT_NOQUOTES | ENT_SUBSTITUTE, 'UTF-8' ); | ||
|
|
||
| // A line-leading heading, rule, list, table, definition-list, or fence marker. | ||
| $text = preg_replace_callback( | ||
| '/^([#=*_+~|:-])/m', | ||
| static function ( array $matches ): string { | ||
| return '&#' . ord( $matches[1] ) . ';'; | ||
| }, | ||
| $text | ||
| ); | ||
|
|
||
| // An ordered-list marker, whose opener is the digits' trailing punctuation followed by a space; a version like 9.9 is left alone. | ||
| return preg_replace_callback( | ||
| '/^(\d+)([.)])(?=\s)/m', | ||
| static function ( array $matches ): string { | ||
| return $matches[1] . '&#' . ord( $matches[2] ) . ';'; | ||
| }, | ||
| $text | ||
| ); | ||
| } | ||
|
|
||
| /** | ||
| * Collapse untrusted text onto a single bounded line, without markup. | ||
| * | ||
| * @param string $text The text to excerpt. | ||
| * @param int $length Maximum length in characters. | ||
| * @return string The excerpted text. | ||
| */ | ||
| private function excerpt( string $text, int $length ): string { | ||
| return preg_replace( '/\s+/u', ' ', $this->prose( $text, $length ) ); | ||
| } | ||
| } | ||
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
Uh oh!
There was an error while loading. Please reload this page.