Repository: getsentry/sentry-php (php)

Issue:
Add end-user function to generate CSP report-uri
### Problem Statement

It is possible to use Sentry as endpoint for collecting CSP violations, see https://docs.sentry.io/product/security-policy-reporting/. In order to do so, it requires passing a specific report-uri value to the CSP header in the response of your application. It would be great if the SDK would be able to generate such an URI.

### Solution Brainstorm

The [Raven: Sentry Integration](https://www.drupal.org/project/raven) module for Drupal has an implentation of this, see https://git.drupalcode.org/project/raven/-/blob/4.x/src/Plugin/CspReportingHandler/Raven.php. It would be great to have a public function for this URI in any application using Sentry PHP SDK.

Pull request description:
Expose a function to retrieve the URL of the CSP endpoint
Fixes #1363. This is just a backport of what the Ruby SDK does, nothing more nothing less.

Patch:
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 34a2d46..517d04c 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -2,6 +2,8 @@
 
 ## Unreleased
 
+- Expose a function to retrieve the URL of the CSP endpoint (#1378)
+
 ## 3.8.1 (2022-09-21)
 
 - fix: Use constant for the SDK version (#1374)
diff --git a/src/Client.php b/src/Client.php
index e899500..3972ef6 100644
--- a/src/Client.php
+++ b/src/Client.php
@@ -117,6 +117,30 @@ public function getOptions(): Options
         return $this->options;
     }
 
+    /**
+     * {@inheritdoc}
+     */
+    public function getCspReportUrl(): ?string
+    {
+        $dsn = $this->options->getDsn();
+
+        if (null === $dsn) {
+            return null;
+        }
+
+        $endpoint = $dsn->getCspReportEndpointUrl();
+        $query = array_filter([
+            'sentry_release' => $this->options->getRelease(),
+            'sentry_environment' => $this->options->getEnvironment(),
+        ]);
+
+        if (!empty($query)) {
+            $endpoint .= '&' . http_build_query($query, '', '&');
+        }
+
+        return $endpoint;
+    }
+
     /**
      * {@inheritdoc}
      */
diff --git a/src/ClientInterface.php b/src/ClientInterface.php
index 1cb287f..3f6d06f 100644
--- a/src/ClientInterface.php
+++ b/src/ClientInterface.php
@@ -12,6 +12,7 @@
  * This interface must be implemented by all Raven client classes.
  *
  * @method StacktraceBuilder getStacktraceBuilder() Returns the stacktrace builder of the client.
+ * @method string|null getCspReportUrl() Returns an URL for security policy reporting that's generated from the given DSN
  *
  * @author Stefano Arlandini <sarlandini@alice.it>
  */
diff --git a/src/Dsn.php b/src/Dsn.php
index bf54ea4..ee09d68 100644
--- a/src/Dsn.php
+++ b/src/Dsn.php
@@ -196,6 +196,14 @@ public function getEnvelopeApiEndpointUrl(): string
         return $this->getBaseEndpointUrl() . '/envelope/';
     }
 
+    /**
+     * Returns the URL of the API for the CSP report endpoint.
+     */
+    public function getCspReportEndpointUrl(): string
+    {
+        return $this->getBaseEndpointUrl() . '/security/?sentry_key=' . $this->publicKey;
+    }
+
     /**
      * @see https://www.php.net/manual/en/language.oop5.magic.php#object.tostring
      */


Test patch:
diff --git a/tests/ClientTest.php b/tests/ClientTest.php
index 1982fb9..9bfc982 100644
--- a/tests/ClientTest.php
+++ b/tests/ClientTest.php
@@ -811,6 +811,62 @@ public function testBuildWithCustomStacktrace(): void
         $client->captureEvent($event);
     }
 
+    /**
+     * @dataProvider getCspReportUrlDataProvider
+     */
+    public function testGetCspReportUrl(array $options, ?string $expectedUrl): void
+    {
+        $client = new Client(
+            new Options($options),
+            $this->createMock(TransportInterface::class),
+            'sentry.sdk.identifier',
+            '1.2.3',
+            $this->createMock(SerializerInterface::class),
+            $this->createMock(RepresentationSerializerInterface::class)
+        );
+
+        $this->assertSame($expectedUrl, $client->getCspReportUrl());
+    }
+
+    public function getCspReportUrlDataProvider(): \Generator
+    {
+        yield [
+            ['dsn' => null],
+            null,
+            null,
+        ];
+
+        yield [
+            ['dsn' => 'https://public:secret@example.com/1'],
+            'https://example.com/api/1/security/?sentry_key=public',
+        ];
+
+        yield [
+            [
+                'dsn' => 'https://public:secret@example.com/1',
+                'release' => 'dev-release',
+            ],
+            'https://example.com/api/1/security/?sentry_key=public&sentry_release=dev-release',
+        ];
+
+        yield [
+            [
+                'dsn' => 'https://public:secret@example.com/1',
+                'environment' => 'development',
+            ],
+            'https://example.com/api/1/security/?sentry_key=public&sentry_environment=development',
+        ];
+
+        yield [
+            [
+                'dsn' => 'https://public:secret@example.com/1',
+                'release' => 'dev-release',
+                'environment' => 'development',
+            ],
+            'https://example.com/api/1/security/?sentry_key=public&sentry_release=dev-release&sentry_environment=development',
+        ];
+    }
+
     private function createTransportFactory(TransportInterface $transport): TransportFactoryInterface
     {
         return new class($transport) implements TransportFactoryInterface {
diff --git a/tests/DsnTest.php b/tests/DsnTest.php
index 6c31e99..1b73519 100644
--- a/tests/DsnTest.php
+++ b/tests/DsnTest.php
@@ -214,6 +214,44 @@ public function getStoreApiEndpointUrlDataProvider(): \Generator
         ];
     }
 
+    /**
+     * @dataProvider getCspReportEndpointUrlDataProvider
+     */
+    public function testGetCspReportEndpointUrl(string $value, string $expectedUrl): void
+    {
+        $dsn = Dsn::createFromString($value);
+
+        $this->assertSame($expectedUrl, $dsn->getCspReportEndpointUrl());
+    }
+
+    public function getCspReportEndpointUrlDataProvider(): \Generator
+    {
+        yield [
+            'http://public@example.com/sentry/1',
+            'http://example.com/sentry/api/1/security/?sentry_key=public',
+        ];
+
+        yield [
+            'http://public@example.com/1',
+            'http://example.com/api/1/security/?sentry_key=public',
+        ];
+
+        yield [
+            'http://public@example.com:8080/sentry/1',
+            'http://example.com:8080/sentry/api/1/security/?sentry_key=public',
+        ];
+
+        yield [
+            'https://public@example.com/sentry/1',
+            'https://example.com/sentry/api/1/security/?sentry_key=public',
+        ];
+
+        yield [
+            'https://public@example.com:4343/sentry/1',
+            'https://example.com:4343/sentry/api/1/security/?sentry_key=public',
+        ];
+    }
+
     /**
      * @dataProvider toStringDataProvider
      */