Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
94.87% covered (success)
94.87%
111 / 117
75.00% covered (warning)
75.00%
6 / 8
CRAP
0.00% covered (danger)
0.00%
0 / 1
SBOMGenerator
94.87% covered (success)
94.87%
111 / 117
75.00% covered (warning)
75.00%
6 / 8
22.07
0.00% covered (danger)
0.00%
0 / 1
 __construct
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 generateCdxSBOM
100.00% covered (success)
100.00%
15 / 15
100.00% covered (success)
100.00%
1 / 1
1
 getPlatformComponents
100.00% covered (success)
100.00%
19 / 19
100.00% covered (success)
100.00%
1 / 1
1
 getPHPExtensionComponents
100.00% covered (success)
100.00%
9 / 9
100.00% covered (success)
100.00%
1 / 1
1
 getMediaWikiComponent
100.00% covered (success)
100.00%
10 / 10
100.00% covered (success)
100.00%
1 / 1
1
 getExtensionComponents
87.50% covered (warning)
87.50%
35 / 40
0.00% covered (danger)
0.00%
0 / 1
12.28
 getForeignResources
100.00% covered (success)
100.00%
5 / 5
100.00% covered (success)
100.00%
1 / 1
1
 getComposerComponents
94.44% covered (success)
94.44%
17 / 18
0.00% covered (danger)
0.00%
0 / 1
4.00
1<?php
2
3namespace MediaWiki\Utils;
4
5use MediaWiki\Language\MessageLocalizer;
6use MediaWiki\Registration\ExtensionRegistry;
7use MediaWiki\ResourceLoader\ForeignResourceManager;
8use Wikimedia\Composer\ComposerInstalled;
9use Wikimedia\Rdbms\IConnectionProvider;
10use Wikimedia\Timestamp\ConvertibleTimestamp;
11use Wikimedia\Timestamp\TimestampFormat;
12use Wikimedia\UUID\GlobalIdGenerator;
13
14/**
15 * @since 1.46
16 */
17class SBOMGenerator {
18
19    public function __construct(
20        private readonly IConnectionProvider $connectionProvider,
21        private readonly ExtensionRegistry $extensionRegistry,
22        private readonly GlobalIdGenerator $globalIdGenerator,
23    ) {
24    }
25
26    /**
27     * Generate a Software Bill of Materials (SBOM) for MediaWiki and all related software,
28     * libraries and extensions.
29     * @unstable
30     * @see https://cyclonedx.org/docs/1.6/json/
31     * @return array SBOM data in the CycloneDX 1.6 format
32     */
33    public function generateCdxSBOM( MessageLocalizer $localizer ): array {
34        return [
35            '$schema' => 'http://cyclonedx.org/schema/bom-1.6.schema.json',
36            'bomFormat' => 'CycloneDX',
37            'specVersion' => '1.6',
38            'serialNumber' => 'urn:uuid:' . $this->globalIdGenerator->newUUIDv4(),
39            'version' => 1,
40            'metadata' => [
41                'timestamp' => ConvertibleTimestamp::now( TimestampFormat::ISO_8601 ),
42            ],
43            'components' => array_merge(
44                $this->getPlatformComponents(),
45                $this->getMediaWikiComponent(),
46                $this->getExtensionComponents( $this->extensionRegistry->getAllThings(), $localizer ),
47            ),
48        ];
49    }
50
51    /**
52     * @return array Components for PHP, ICU and the Database
53     */
54    private function getPlatformComponents(): array {
55        $dbr = $this->connectionProvider->getReplicaDatabase();
56        return [
57            [
58                'type' => 'platform',
59                'name' => 'PHP',
60                'version' => PHP_VERSION,
61                'components' => $this->getPHPExtensionComponents( get_loaded_extensions() ),
62            ],
63            [
64                'type' => 'library',
65                'name' => 'ICU',
66                'version' => INTL_ICU_VERSION,
67            ],
68            [
69                'type' => 'platform',
70                'name' => $dbr->getType(),
71                'version' => $dbr->getServerVersion(),
72            ],
73        ];
74    }
75
76    /**
77     * @param array $extensions string[] A list of extensions that are loaded by PHP
78     * @return array Components for all installed PHP extensions
79     */
80    private function getPHPExtensionComponents( array $extensions ): array {
81        $extensions = array_filter( $extensions, static fn ( $name ) => $name !== 'Core' );
82        return array_map(
83            static fn ( $extension ) => [
84                'type' => 'library',
85                'name' => $extension,
86                'version' => phpversion( $extension ),
87            ],
88            array_values( $extensions )
89        );
90    }
91
92    /**
93     * @return array{0: array} An array with a single component for MediaWiki core and its dependencies
94     */
95    private function getMediaWikiComponent(): array {
96        $subComponents = array_merge(
97            $this->getComposerComponents( MW_INSTALL_PATH ),
98            $this->getForeignResources( MW_INSTALL_PATH . '/resources/lib' ),
99        );
100        return [ [
101            'type' => 'application',
102            'name' => 'MediaWiki',
103            'components' => $subComponents,
104            'version' => MW_VERSION,
105        ] ];
106    }
107
108    /**
109     * @param array $extensions Information about all extensions and skins
110     * @param MessageLocalizer $localizer
111     * @return array Components for the installed extensions and skins along with their declared foreign resources
112     */
113    private function getExtensionComponents( array $extensions, MessageLocalizer $localizer ): array {
114        ksort( $extensions, SORT_STRING );
115        $foreignResourcesDirs = $this->extensionRegistry->getAttribute( 'ForeignResourcesDir' );
116
117        $components = [];
118        foreach ( $extensions as $name => $extension ) {
119            $componentData = [
120                'type' => 'application',
121                'name' => $name,
122            ];
123
124            if ( array_key_exists( 'author', $extension ) ) {
125                $authors = $extension['author'];
126                $componentData['authors'] = array_map(
127                    static fn ( $author ) => [
128                        'name' => $author
129                    ],
130                    is_array( $authors ) ? $authors : [ $authors ],
131                );
132            }
133
134            if ( array_key_exists( 'version', $extension ) ) {
135                $componentData['version'] = (string)$extension['version'];
136            }
137
138            if ( array_key_exists( 'license-name', $extension ) ) {
139                $componentData['licences'] = [ $extension['license-name'] ];
140            }
141
142            if ( array_key_exists( 'url', $extension ) ) {
143                $componentData['url'] = $extension['url'];
144            }
145
146            if ( array_key_exists( 'descriptionmsg', $extension ) ) {
147                $componentData['description'] = $localizer->msg( $extension['descriptionmsg'] )->text();
148            } elseif ( array_key_exists( 'description', $extension ) ) {
149                $componentData['description'] = $extension['description'];
150            }
151
152            $subComponents = [];
153            $path = $extension['path'] ?? null;
154            if ( $path !== null ) {
155                $subComponents = $this->getComposerComponents( dirname( $path ) );
156            }
157            $foreignResourcesDir = $foreignResourcesDirs[$name] ?? null;
158            if ( $foreignResourcesDir !== null ) {
159                $subComponents = array_merge(
160                    $subComponents,
161                    $this->getForeignResources( $foreignResourcesDir )
162                );
163            }
164            if ( $subComponents ) {
165                $componentData['components'] = $subComponents;
166            }
167            $components[] = $componentData;
168        }
169
170        return $components;
171    }
172
173    /**
174     * @return array A list of components that were parsed from a foreign-resources.yaml
175     * file in the given registry directory.
176     */
177    private function getForeignResources( string $registryDir ): array {
178        $foreignResourceManager = new ForeignResourceManager(
179            "$registryDir/foreign-resources.yaml",
180            $registryDir,
181        );
182        return $foreignResourceManager->generateCdxData()['components'] ?? [];
183    }
184
185    /**
186     * @return array Components for all installed composer libraries
187     */
188    private function getComposerComponents( string $path ): array {
189        $installedFile = $path . '/vendor/composer/installed.json';
190        if ( !file_exists( $installedFile ) ) {
191            return [];
192        }
193
194        $installed = new ComposerInstalled( $installedFile );
195
196        $components = [];
197        foreach ( $installed->getInstalledDependencies() as $name => $info ) {
198            if ( str_starts_with( $info['type'], 'mediawiki-' ) ) {
199                // Skip extensions and skins installed via Composer
200                continue;
201            }
202            $componentData = [
203                'name' => $name,
204                'version' => $info['version'],
205                'type' => 'library',
206                'licences' => $info['licences'] ?? [],
207                'authors' => $info['authors'] ?? [],
208                'description' => $info['description'],
209            ];
210
211            $components[] = $componentData;
212        }
213        return $components;
214    }
215}