diff --git a/src/Psalm/Internal/Analyzer/ProjectAnalyzer.php b/src/Psalm/Internal/Analyzer/ProjectAnalyzer.php index 6268966eb..1d5d6ee7c 100644 --- a/src/Psalm/Internal/Analyzer/ProjectAnalyzer.php +++ b/src/Psalm/Internal/Analyzer/ProjectAnalyzer.php @@ -368,6 +368,7 @@ class ProjectAnalyzer '.emacs' => Report::TYPE_EMACS, '.pylint' => Report::TYPE_PYLINT, '.console' => Report::TYPE_CONSOLE, + '.sarif' => Report::TYPE_SARIF, ]; foreach ($report_file_paths as $report_file_path) { diff --git a/src/Psalm/IssueBuffer.php b/src/Psalm/IssueBuffer.php index a5dccfbbc..58f77dd46 100644 --- a/src/Psalm/IssueBuffer.php +++ b/src/Psalm/IssueBuffer.php @@ -29,6 +29,7 @@ use Psalm\Report\CompactReport; use Psalm\Report\ConsoleReport; use Psalm\Report\EmacsReport; use Psalm\Report\GithubActionsReport; +use Psalm\Report\SarifReport; use Psalm\Report\JsonReport; use Psalm\Report\JsonSummaryReport; use Psalm\Report\JunitReport; @@ -757,6 +758,10 @@ class IssueBuffer case Report::TYPE_PHP_STORM: $output = new PhpStormReport($normalized_data, self::$fixable_issue_counts, $report_options); break; + + case Report::TYPE_SARIF: + $output = new SarifReport($normalized_data, self::$fixable_issue_counts, $report_options); + break; } return $output->create(); diff --git a/src/Psalm/Report.php b/src/Psalm/Report.php index 8892e31db..f16d9c3d8 100644 --- a/src/Psalm/Report.php +++ b/src/Psalm/Report.php @@ -19,6 +19,7 @@ abstract class Report public const TYPE_TEXT = 'text'; public const TYPE_GITHUB_ACTIONS = 'github'; public const TYPE_PHP_STORM = 'phpstorm'; + public const TYPE_SARIF = 'sarif'; public const SUPPORTED_OUTPUT_TYPES = [ self::TYPE_COMPACT, @@ -34,6 +35,7 @@ abstract class Report self::TYPE_TEXT, self::TYPE_GITHUB_ACTIONS, self::TYPE_PHP_STORM, + self::TYPE_SARIF, ]; /** diff --git a/src/Psalm/Report/SarifReport.php b/src/Psalm/Report/SarifReport.php new file mode 100644 index 000000000..ee6acf7ee --- /dev/null +++ b/src/Psalm/Report/SarifReport.php @@ -0,0 +1,129 @@ + '2.1.0', + 'runs' => [ + [ + 'tool' => [ + 'driver' => [ + 'name' => 'Psalm', + 'version' => PSALM_VERSION, + ], + ], + 'results' => [], + ], + ], + ]; + + $rules = []; + + foreach ($this->issues_data as $issue_data) { + $rules[$issue_data->shortcode] = [ + 'id' => (string)$issue_data->shortcode, + 'name' => $issue_data->type, + 'shortDescription' => [ + 'text' => $issue_data->type, + ], + 'properties' => [ + 'tags' => [ + (\substr($issue_data->type, 0, 7) === 'Tainted') ? 'security' : 'maintainability', + ], + ], + ]; + + $markdown_documentation_path = __DIR__ . '/../../../docs/running_psalm/issues/' . $issue_data->type . '.md'; + if (file_exists($markdown_documentation_path)) { + $markdown_documentation = file_get_contents($markdown_documentation_path); + $rules[$issue_data->shortcode]['help']['markdown'] = $markdown_documentation; + $rules[$issue_data->shortcode]['help']['text'] = $markdown_documentation; + } + + $jsonEntry = [ + 'ruleId' => (string)$issue_data->shortcode, + 'message' => [ + 'text' => $issue_data->message, + ], + 'level' => ($issue_data->severity === Config::REPORT_ERROR) ? 'error' : 'note', + 'locations' => [ + [ + 'physicalLocation' => [ + 'artifactLocation' => [ + 'uri' => $issue_data->file_name, + ], + 'region' => [ + 'startLine' => $issue_data->line_from, + 'endLine' => $issue_data->line_to, + 'startColumn' => $issue_data->column_from, + 'endColumn' => $issue_data->column_to, + ], + ], + ] + ], + ]; + + if ($issue_data->taint_trace != null) { + $jsonEntry['codeFlows'] = [ + [ + 'message' => [ + 'text' => 'Tracing the path from user input to insecure usage', + ], + 'threadFlows' => [ + [ + 'locations' => [], + ], + ], + ] + ]; + + /** @var \Psalm\Internal\Analyzer\DataFlowNodeData $trace */ + foreach ($issue_data->taint_trace as $trace) { + if (isset($trace->line_from)) { + $jsonEntry['codeFlows'][0]['threadFlows'][0]['locations'][] = [ + 'location' => [ + 'physicalLocation' => [ + 'artifactLocation' => [ + 'uri' => $trace->file_name, + ], + 'region' => [ + 'startLine' => $trace->line_from, + 'endLine' => $trace->line_to, + 'startColumn' => $trace->column_from, + 'endColumn' => $trace->column_to, + ], + ], + ], + ]; + } + } + } + + $report['runs'][0]['results'][] = $jsonEntry; + } + + foreach ($rules as $rule) { + $report['runs'][0]['tool']['driver']['rules'][] = $rule; + } + + $options = $this->pretty ? Json::PRETTY : Json::DEFAULT; + + return Json::encode($report, $options) . "\n"; + } +} diff --git a/src/command_functions.php b/src/command_functions.php index 1a1c974ef..bb86d3341 100644 --- a/src/command_functions.php +++ b/src/command_functions.php @@ -387,7 +387,7 @@ Reports: --report=PATH The path where to output report file. The output format is based on the file extension. (Currently supported formats: ".json", ".xml", ".txt", ".emacs", ".pylint", ".console", - "checkstyle.xml", "sonarqube.json", "summary.json", "junit.xml") + ".sarif", "checkstyle.xml", "sonarqube.json", "summary.json", "junit.xml") --report-show-info[=BOOLEAN] Whether the report should include non-errors in its output (defaults to true) diff --git a/tests/ReportOutputTest.php b/tests/ReportOutputTest.php index 19b9a3fee..cf6260f1e 100644 --- a/tests/ReportOutputTest.php +++ b/tests/ReportOutputTest.php @@ -65,6 +65,415 @@ class ReportOutputTest extends TestCase ProjectAnalyzer::getFileReportOptions(['/tmp/report.log']); } + public function analyzeTaintFlowFilesForReport() : void + { + $vulnerable_file_contents = 'addFile( + 'taintflow-test/vulnerable.php', + $vulnerable_file_contents + ); + + $this->analyzeFile('taintflow-test/vulnerable.php', new Context(), true, true); + } + + public function testSarifReport(): void + { + $this->analyzeTaintFlowFilesForReport(); + + $issue_data = [ + 'version' => '2.1.0', + 'runs' => [ + [ + 'tool' => [ + 'driver' => [ + 'name' => 'Psalm', + 'version' => '2.0.0', + 'rules' => [ + [ + 'id' => '246', + 'name' => 'TaintedShell', + 'shortDescription' => [ + 'text' => 'TaintedShell' + ], + 'properties' => [ + 'tags' => [ + 'security' + ], + ], + 'help' => [ + 'markdown' => file_get_contents(__DIR__ . '/../docs/running_psalm/issues/TaintedShell.md'), + 'text' => file_get_contents(__DIR__ . '/../docs/running_psalm/issues/TaintedShell.md'), + ], + ], + [ + 'id' => '245', + 'name' => 'TaintedHtml', + 'shortDescription' => [ + 'text' => 'TaintedHtml' + ], + 'properties' => [ + 'tags' => [ + 'security' + ], + ], + 'help' => [ + 'markdown' => file_get_contents(__DIR__ . '/../docs/running_psalm/issues/TaintedHtml.md'), + 'text' => file_get_contents(__DIR__ . '/../docs/running_psalm/issues/TaintedHtml.md'), + ], + ], + ] + ] + ], + 'results' => [ + [ + 'ruleId' => '246', + 'message' => [ + 'text' => 'Detected tainted shell code' + ], + 'level' => 'error', + 'locations' => [ + [ + 'physicalLocation' => [ + 'artifactLocation' => [ + 'uri' => 'taintflow-test/vulnerable.php' + ], + 'region' => [ + 'startLine' => 9, + 'endLine' => 9, + 'startColumn' => 12, + 'endColumn' => 25 + ] + ] + ] + ], + 'codeFlows' => [ + [ + 'message' => [ + 'text' => 'Tracing the path from user input to insecure usage' + ], + 'threadFlows' => [ + [ + 'locations' => [ + [ + 'location' => [ + 'physicalLocation' => [ + 'artifactLocation' => [ + 'uri' => 'taintflow-test/vulnerable.php' + ], + 'region' => [ + 'startLine' => 7, + 'endLine' => 7, + 'startColumn' => 46, + 'endColumn' => 52 + ] + ] + ] + ], + [ + 'location' => [ + 'physicalLocation' => [ + 'artifactLocation' => [ + 'uri' => 'taintflow-test/vulnerable.php' + ], + 'region' => [ + 'startLine' => 7, + 'endLine' => 7, + 'startColumn' => 46, + 'endColumn' => 59 + ] + ] + ] + ], + [ + 'location' => [ + 'physicalLocation' => [ + 'artifactLocation' => [ + 'uri' => 'taintflow-test/vulnerable.php' + ], + 'region' => [ + 'startLine' => 3, + 'endLine' => 3, + 'startColumn' => 36, + 'endColumn' => 42 + ] + ] + ] + ], + [ + 'location' => [ + 'physicalLocation' => [ + 'artifactLocation' => [ + 'uri' => 'taintflow-test/vulnerable.php' + ], + 'region' => [ + 'startLine' => 3, + 'endLine' => 3, + 'startColumn' => 36, + 'endColumn' => 42 + ] + ] + ] + ], + [ + 'location' => [ + 'physicalLocation' => [ + 'artifactLocation' => [ + 'uri' => 'taintflow-test/vulnerable.php' + ], + 'region' => [ + 'startLine' => 4, + 'endLine' => 4, + 'startColumn' => 12, + 'endColumn' => 28 + ] + ] + ] + ], + [ + 'location' => [ + 'physicalLocation' => [ + 'artifactLocation' => [ + 'uri' => 'taintflow-test/vulnerable.php' + ], + 'region' => [ + 'startLine' => 3, + 'endLine' => 3, + 'startColumn' => 45, + 'endColumn' => 51 + ] + ] + ] + ], + [ + 'location' => [ + 'physicalLocation' => [ + 'artifactLocation' => [ + 'uri' => 'taintflow-test/vulnerable.php' + ], + 'region' => [ + 'startLine' => 7, + 'endLine' => 7, + 'startColumn' => 1, + 'endColumn' => 14 + ] + ] + ] + ], + [ + 'location' => [ + 'physicalLocation' => [ + 'artifactLocation' => [ + 'uri' => 'taintflow-test/vulnerable.php' + ], + 'region' => [ + 'startLine' => 9, + 'endLine' => 9, + 'startColumn' => 12, + 'endColumn' => 25 + ] + ] + ] + ] + ] + ] + ] + ] + ] + ], + [ + 'ruleId' => '245', + 'message' => [ + 'text' => 'Detected tainted HTML' + ], + 'level' => 'error', + 'locations' => [ + [ + 'physicalLocation' => [ + 'artifactLocation' => [ + 'uri' => 'taintflow-test/vulnerable.php' + ], + 'region' => [ + 'startLine' => 11, + 'endLine' => 11, + 'startColumn' => 6, + 'endColumn' => 59 + ] + ] + ] + ], + 'codeFlows' => [ + [ + 'message' => [ + 'text' => 'Tracing the path from user input to insecure usage' + ], + 'threadFlows' => [ + [ + 'locations' => [ + [ + 'location' => [ + 'physicalLocation' => [ + 'artifactLocation' => [ + 'uri' => 'taintflow-test/vulnerable.php' + ], + 'region' => [ + 'startLine' => 7, + 'endLine' => 7, + 'startColumn' => 46, + 'endColumn' => 52 + ] + ] + ] + ], + [ + 'location' => [ + 'physicalLocation' => [ + 'artifactLocation' => [ + 'uri' => 'taintflow-test/vulnerable.php' + ], + 'region' => [ + 'startLine' => 7, + 'endLine' => 7, + 'startColumn' => 46, + 'endColumn' => 59 + ] + ] + ] + ], + [ + 'location' => [ + 'physicalLocation' => [ + 'artifactLocation' => [ + 'uri' => 'taintflow-test/vulnerable.php' + ], + 'region' => [ + 'startLine' => 3, + 'endLine' => 3, + 'startColumn' => 36, + 'endColumn' => 42 + ] + ] + ] + ], + [ + 'location' => [ + 'physicalLocation' => [ + 'artifactLocation' => [ + 'uri' => 'taintflow-test/vulnerable.php' + ], + 'region' => [ + 'startLine' => 3, + 'endLine' => 3, + 'startColumn' => 36, + 'endColumn' => 42 + ] + ] + ] + ], + [ + 'location' => [ + 'physicalLocation' => [ + 'artifactLocation' => [ + 'uri' => 'taintflow-test/vulnerable.php' + ], + 'region' => [ + 'startLine' => 4, + 'endLine' => 4, + 'startColumn' => 12, + 'endColumn' => 28 + ] + ] + ] + ], + [ + 'location' => [ + 'physicalLocation' => [ + 'artifactLocation' => [ + 'uri' => 'taintflow-test/vulnerable.php' + ], + 'region' => [ + 'startLine' => 3, + 'endLine' => 3, + 'startColumn' => 45, + 'endColumn' => 51 + ] + ] + ] + ], + [ + 'location' => [ + 'physicalLocation' => [ + 'artifactLocation' => [ + 'uri' => 'taintflow-test/vulnerable.php' + ], + 'region' => [ + 'startLine' => 7, + 'endLine' => 7, + 'startColumn' => 1, + 'endColumn' => 14 + ] + ] + ] + ], + [ + 'location' => [ + 'physicalLocation' => [ + 'artifactLocation' => [ + 'uri' => 'taintflow-test/vulnerable.php' + ], + 'region' => [ + 'startLine' => 11, + 'endLine' => 11, + 'startColumn' => 6, + 'endColumn' => 59 + ] + ] + ] + ], + [ + 'location' => [ + 'physicalLocation' => [ + 'artifactLocation' => [ + 'uri' => 'taintflow-test/vulnerable.php' + ], + 'region' => [ + 'startLine' => 11, + 'endLine' => 11, + 'startColumn' => 6, + 'endColumn' => 59 + ] + ] + ] + ] + ] + ] + ] + ] + ] + ] + ] + ] + ] + ]; + + $sarif_report_options = ProjectAnalyzer::getFileReportOptions([__DIR__ . '/test-report.sarif'])[0]; + + $this->assertSame( + $issue_data, + json_decode(IssueBuffer::getOutput(IssueBuffer::getIssuesData(), $sarif_report_options), true) + ); + } + public function analyzeFileForReport() : void { $file_contents = 'project_analyzer->getCodebase(); + if ($taint_flow_tracking) { + $this->project_analyzer->trackTaintedInputs(); + } + $codebase->addFilesToAnalyze([$file_path => $file_path]); $codebase->scanFiles();