From 2981dd20cf82d3a60256ee5cef873de4f50ffb2c Mon Sep 17 00:00:00 2001 From: Martin Adler Date: Thu, 8 Oct 2026 14:00:52 +0200 Subject: [PATCH] [!!!][FEATURE] Type vulnerability analysis as enums --- docs/cyclonedx-parser.md | 27 +++ .../ImpactAnalysisJustification.php | 44 +++++ .../Vulnerability/ImpactAnalysisResponse.php | 40 ++++ .../Vulnerability/ImpactAnalysisState.php | 41 ++++ .../Vulnerability/VulnerabilityAnalysis.php | 6 +- tests/Fixtures/sbom/README.md | 2 + .../sbom/vex-1.6-without-components.json | 13 ++ tests/Fixtures/sbom/vex-1.6.json | 187 ++++++++++++++++++ tests/Integration/AnalysisVocabularyTest.php | 92 +++++++++ tests/Integration/SchemaComplianceTest.php | 2 + tests/Unit/Parser/CycloneDxParserTest.php | 166 ++++++++++++++++ 11 files changed, 617 insertions(+), 3 deletions(-) create mode 100644 src/Entity/Vulnerability/ImpactAnalysisJustification.php create mode 100644 src/Entity/Vulnerability/ImpactAnalysisResponse.php create mode 100644 src/Entity/Vulnerability/ImpactAnalysisState.php create mode 100644 tests/Fixtures/sbom/vex-1.6-without-components.json create mode 100644 tests/Fixtures/sbom/vex-1.6.json create mode 100644 tests/Integration/AnalysisVocabularyTest.php diff --git a/docs/cyclonedx-parser.md b/docs/cyclonedx-parser.md index 8c93661..2af1f4c 100644 --- a/docs/cyclonedx-parser.md +++ b/docs/cyclonedx-parser.md @@ -97,6 +97,33 @@ foreach ($component->licenses ?? [] as $licenseChoice) { The same shape applies to `Service::$licenses` and `ComponentEvidence::$licenses`. +### Vulnerability Analysis: [`VulnerabilityAnalysis`](../src/Entity/Vulnerability/VulnerabilityAnalysis.php) + +A VEX document records its verdict in `vulnerabilities[].analysis`. The +`state`, `justification` and `response` fields are enums. + +```php +use mteu\SbomParser\Entity\Vulnerability\ImpactAnalysisState; + +foreach ($bom->vulnerabilities ?? [] as $vulnerability) { + $analysis = $vulnerability->analysis; + + if ($analysis?->state === ImpactAnalysisState::NOT_AFFECTED) { + $analysis->justification; // ImpactAnalysisJustification, e.g. CODE_NOT_REACHABLE + } + + $analysis?->response; // list, e.g. [UPDATE] + $analysis?->detail; // Free text written by a person: escape it before display +} +``` + +The vocabulary is the same in every supported spec version. A value outside +it fails the parse with an `SbomParseException` whose message names the path, +such as `vulnerabilities.2.analysis.state`. + +A VEX document may carry no `components` at all. It parses like any other +document, and `Bom::hasComponents()` returns `false`. + ## File Validation The parser includes validation: diff --git a/src/Entity/Vulnerability/ImpactAnalysisJustification.php b/src/Entity/Vulnerability/ImpactAnalysisJustification.php new file mode 100644 index 0000000..0cbb6eb --- /dev/null +++ b/src/Entity/Vulnerability/ImpactAnalysisJustification.php @@ -0,0 +1,44 @@ + + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +namespace mteu\SbomParser\Entity\Vulnerability; + +/** + * Impact analysis justification based on CycloneDX 1.4+ specification. + * The vocabulary is identical in every supported spec version. + * + * @author Martin Adler + * @license GPL-3.0-or-later + */ +enum ImpactAnalysisJustification: string +{ + case CODE_NOT_PRESENT = 'code_not_present'; + case CODE_NOT_REACHABLE = 'code_not_reachable'; + case REQUIRES_CONFIGURATION = 'requires_configuration'; + case REQUIRES_DEPENDENCY = 'requires_dependency'; + case REQUIRES_ENVIRONMENT = 'requires_environment'; + case PROTECTED_BY_COMPILER = 'protected_by_compiler'; + case PROTECTED_AT_RUNTIME = 'protected_at_runtime'; + case PROTECTED_AT_PERIMETER = 'protected_at_perimeter'; + case PROTECTED_BY_MITIGATING_CONTROL = 'protected_by_mitigating_control'; +} diff --git a/src/Entity/Vulnerability/ImpactAnalysisResponse.php b/src/Entity/Vulnerability/ImpactAnalysisResponse.php new file mode 100644 index 0000000..4a6661c --- /dev/null +++ b/src/Entity/Vulnerability/ImpactAnalysisResponse.php @@ -0,0 +1,40 @@ + + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +namespace mteu\SbomParser\Entity\Vulnerability; + +/** + * Impact analysis response based on CycloneDX 1.4+ specification. + * The vocabulary is identical in every supported spec version. + * + * @author Martin Adler + * @license GPL-3.0-or-later + */ +enum ImpactAnalysisResponse: string +{ + case CAN_NOT_FIX = 'can_not_fix'; + case WILL_NOT_FIX = 'will_not_fix'; + case UPDATE = 'update'; + case ROLLBACK = 'rollback'; + case WORKAROUND_AVAILABLE = 'workaround_available'; +} diff --git a/src/Entity/Vulnerability/ImpactAnalysisState.php b/src/Entity/Vulnerability/ImpactAnalysisState.php new file mode 100644 index 0000000..d574696 --- /dev/null +++ b/src/Entity/Vulnerability/ImpactAnalysisState.php @@ -0,0 +1,41 @@ + + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +namespace mteu\SbomParser\Entity\Vulnerability; + +/** + * Impact analysis state based on CycloneDX 1.4+ specification. + * The vocabulary is identical in every supported spec version. + * + * @author Martin Adler + * @license GPL-3.0-or-later + */ +enum ImpactAnalysisState: string +{ + case RESOLVED = 'resolved'; + case RESOLVED_WITH_PEDIGREE = 'resolved_with_pedigree'; + case EXPLOITABLE = 'exploitable'; + case IN_TRIAGE = 'in_triage'; + case FALSE_POSITIVE = 'false_positive'; + case NOT_AFFECTED = 'not_affected'; +} diff --git a/src/Entity/Vulnerability/VulnerabilityAnalysis.php b/src/Entity/Vulnerability/VulnerabilityAnalysis.php index 014ebe3..346caa0 100644 --- a/src/Entity/Vulnerability/VulnerabilityAnalysis.php +++ b/src/Entity/Vulnerability/VulnerabilityAnalysis.php @@ -33,9 +33,9 @@ final readonly class VulnerabilityAnalysis { public function __construct( - public ?string $state = null, - public ?string $justification = null, - /** @var string[]|null */ + public ?ImpactAnalysisState $state = null, + public ?ImpactAnalysisJustification $justification = null, + /** @var list|null */ public ?array $response = null, public ?string $detail = null, public ?\DateTimeImmutable $firstIssued = null, diff --git a/tests/Fixtures/sbom/README.md b/tests/Fixtures/sbom/README.md index a310b7c..32bda42 100644 --- a/tests/Fixtures/sbom/README.md +++ b/tests/Fixtures/sbom/README.md @@ -4,6 +4,8 @@ |----------------------------------------------------------------|-----------------------------------------------------------------------------------------------------------------------------------------------------------------------| | `bom-1.4.json`, `bom-1.5.json`, `bom-1.6.json`, `bom-1.7.json` | Generated by `composer generate-sboms` from this package's own dependencies. Do not edit by hand; changes are overwritten on the next run. | | `bom-1.6-custom.json` | Manually created fields the generator never emits, so parser tests can assert on schema-valid input. | +| `vex-1.6.json` | Written by `mteu/vex`'s `CycloneDxVexWriter`, re-indented with tabs. No `serialNumber`, one id on two components, a `mteu:vex:justification` property. | +| `vex-1.6-without-components.json` | Hand-written. A VEX document may carry vulnerabilities only. | Every fixture must be registered in `tests/Integration/SchemaComplianceTest::schemaFixtureProvider()`, so it is diff --git a/tests/Fixtures/sbom/vex-1.6-without-components.json b/tests/Fixtures/sbom/vex-1.6-without-components.json new file mode 100644 index 0000000..560199a --- /dev/null +++ b/tests/Fixtures/sbom/vex-1.6-without-components.json @@ -0,0 +1,13 @@ +{ + "bomFormat": "CycloneDX", + "specVersion": "1.6", + "version": 1, + "vulnerabilities": [ + { + "id": "CVE-2026-1234", + "analysis": { + "state": "in_triage" + } + } + ] +} diff --git a/tests/Fixtures/sbom/vex-1.6.json b/tests/Fixtures/sbom/vex-1.6.json new file mode 100644 index 0000000..244309f --- /dev/null +++ b/tests/Fixtures/sbom/vex-1.6.json @@ -0,0 +1,187 @@ +{ + "bomFormat": "CycloneDX", + "specVersion": "1.6", + "version": 1, + "metadata": { + "timestamp": "2026-10-08T12:00:00Z" + }, + "components": [ + { + "type": "library", + "bom-ref": "pkg:composer/guzzlehttp/psr7@2.4.1", + "group": "guzzlehttp", + "name": "psr7", + "version": "2.4.1", + "purl": "pkg:composer/guzzlehttp/psr7@2.4.1" + }, + { + "type": "library", + "bom-ref": "pkg:composer/symfony/http-kernel@5.4.19", + "group": "symfony", + "name": "http-kernel", + "version": "5.4.19", + "purl": "pkg:composer/symfony/http-kernel@5.4.19" + }, + { + "type": "library", + "bom-ref": "pkg:composer/symfony/http-kernel@6.4.2", + "group": "symfony", + "name": "http-kernel", + "version": "6.4.2", + "purl": "pkg:composer/symfony/http-kernel@6.4.2" + }, + { + "type": "library", + "bom-ref": "pkg:composer/twig/twig@3.8.0", + "group": "twig", + "name": "twig", + "version": "3.8.0", + "purl": "pkg:composer/twig/twig@3.8.0" + }, + { + "type": "library", + "bom-ref": "pkg:npm/lodash@4.17.20", + "name": "lodash", + "version": "4.17.20", + "purl": "pkg:npm/lodash@4.17.20" + } + ], + "vulnerabilities": [ + { + "id": "CVE-2026-1234", + "source": { + "name": "NVD", + "url": "https://nvd.nist.gov/vuln/detail/CVE-2026-1234" + }, + "references": [ + { + "id": "GHSA-jfh8-c2jp-5v3q", + "source": { + "name": "GitHub", + "url": "https://github.com/advisories/GHSA-jfh8-c2jp-5v3q" + } + } + ], + "analysis": { + "state": "not_affected", + "justification": "code_not_reachable", + "detail": "Fragments are never rendered, so the vulnerable path is never called.", + "firstIssued": "2026-10-01T09:15:00Z", + "lastUpdated": "2026-10-01T09:15:00Z" + }, + "affects": [ + { + "ref": "pkg:composer/symfony/http-kernel@5.4.19" + } + ] + }, + { + "id": "CVE-2026-1234", + "source": { + "name": "NVD", + "url": "https://nvd.nist.gov/vuln/detail/CVE-2026-1234" + }, + "references": [ + { + "id": "GHSA-jfh8-c2jp-5v3q", + "source": { + "name": "GitHub", + "url": "https://github.com/advisories/GHSA-jfh8-c2jp-5v3q" + } + } + ], + "analysis": { + "state": "exploitable", + "response": [ + "update" + ], + "detail": "Fixed by the upgrade planned for the next release.", + "firstIssued": "2026-10-02T10:00:00Z", + "lastUpdated": "2026-10-02T10:00:00Z" + }, + "affects": [ + { + "ref": "pkg:composer/symfony/http-kernel@6.4.2" + } + ] + }, + { + "id": "CVE-2026-2345", + "source": { + "name": "NVD", + "url": "https://nvd.nist.gov/vuln/detail/CVE-2026-2345" + }, + "analysis": { + "state": "resolved", + "detail": "A Composer patch backports the upstream fix.", + "firstIssued": "2026-10-03T11:30:00Z", + "lastUpdated": "2026-10-03T11:30:00Z" + }, + "affects": [ + { + "ref": "pkg:composer/guzzlehttp/psr7@2.4.1" + } + ] + }, + { + "id": "CVE-2026-3456", + "source": { + "name": "NVD", + "url": "https://nvd.nist.gov/vuln/detail/CVE-2026-3456" + }, + "analysis": { + "state": "false_positive", + "detail": "The advisory range is wrong for this release.", + "firstIssued": "2026-10-05T14:45:00Z", + "lastUpdated": "2026-10-05T14:45:00Z" + }, + "affects": [ + { + "ref": "pkg:composer/twig/twig@3.8.0" + } + ] + }, + { + "id": "CVE-2026-4567", + "source": { + "name": "NVD", + "url": "https://nvd.nist.gov/vuln/detail/CVE-2026-4567" + }, + "analysis": { + "state": "in_triage", + "firstIssued": "2026-10-06T16:20:00Z", + "lastUpdated": "2026-10-06T16:20:00Z" + }, + "affects": [ + { + "ref": "pkg:composer/twig/twig@3.8.0" + } + ] + }, + { + "id": "GHSA-29mw-wpgm-hmr9", + "source": { + "name": "GitHub", + "url": "https://github.com/advisories/GHSA-29mw-wpgm-hmr9" + }, + "analysis": { + "state": "not_affected", + "justification": "code_not_present", + "detail": "A build input that never reaches the bundle.", + "firstIssued": "2026-10-04T08:00:00Z", + "lastUpdated": "2026-10-04T08:00:00Z" + }, + "affects": [ + { + "ref": "pkg:npm/lodash@4.17.20" + } + ], + "properties": [ + { + "name": "mteu:vex:justification", + "value": "component_not_present" + } + ] + } + ] +} diff --git a/tests/Integration/AnalysisVocabularyTest.php b/tests/Integration/AnalysisVocabularyTest.php new file mode 100644 index 0000000..7a25ebe --- /dev/null +++ b/tests/Integration/AnalysisVocabularyTest.php @@ -0,0 +1,92 @@ + + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +namespace mteu\SbomParser\Tests\Integration; + +namespace mteu\SbomParser\Tests\Integration; + +use mteu\SbomParser\Entity\Vulnerability\ImpactAnalysisJustification; +use mteu\SbomParser\Entity\Vulnerability\ImpactAnalysisResponse; +use mteu\SbomParser\Entity\Vulnerability\ImpactAnalysisState; +use mteu\SbomParser\Parser\CycloneDxParser; +use PHPUnit\Framework\Attributes\DataProvider; +use PHPUnit\Framework\Attributes\Test; +use PHPUnit\Framework\TestCase; + +/** + * Pins the analysis enums against the vocabulary of every supported spec + * version. One enum serves all of them, so a value added or removed upstream + * has to fail here before it fails a consumer's VEX upload. + * + * @author Martin Adler + * @license GPL-3.0-or-later + */ +final class AnalysisVocabularyTest extends TestCase +{ + /** + * @return iterable}> spec version, slash-separated path into the schema, enum values + */ + public static function vocabularyProvider(): iterable + { + // The response vocabulary has no named definition; it is inlined. + $enums = [ + 'definitions/impactAnalysisState' => ImpactAnalysisState::cases(), + 'definitions/impactAnalysisJustification' => ImpactAnalysisJustification::cases(), + 'definitions/vulnerability/properties/analysis/properties/response/items' => ImpactAnalysisResponse::cases(), + ]; + + foreach (CycloneDxParser::SUPPORTED_VERSIONS as $version) { + foreach ($enums as $pointer => $cases) { + yield "$pointer in $version" => [ + $version, + $pointer, + array_map(static fn (\BackedEnum $case): string => (string)$case->value, $cases), + ]; + } + } + } + + /** + * @param list $enumValues + */ + #[Test] + #[DataProvider('vocabularyProvider')] + public function enumMatchesTheSchemaVocabulary(string $version, string $pointer, array $enumValues): void + { + $path = dirname(__DIR__) . "/Fixtures/schemas/bom-$version.schema.json"; + $node = json_decode((string)file_get_contents($path), true, flags: JSON_THROW_ON_ERROR); + + foreach (explode('/', $pointer) as $key) { + self::assertIsArray($node); + $node = $node[$key] ?? null; + } + + self::assertIsArray($node); + $schemaValues = $node['enum'] ?? null; + self::assertIsArray($schemaValues, "Schema $version has no enum at $pointer."); + + sort($schemaValues); + sort($enumValues); + self::assertSame($schemaValues, $enumValues); + } +} diff --git a/tests/Integration/SchemaComplianceTest.php b/tests/Integration/SchemaComplianceTest.php index 81f6248..79c2496 100644 --- a/tests/Integration/SchemaComplianceTest.php +++ b/tests/Integration/SchemaComplianceTest.php @@ -46,6 +46,8 @@ public static function schemaFixtureProvider(): iterable yield '1.6 fixture validates against 1.6 schema' => ['bom-1.6.schema.json', 'bom-1.6.json']; yield '1.7 fixture validates against 1.7 schema' => ['bom-1.7.schema.json', 'bom-1.7.json']; yield 'hand-authored 1.6 fixture validates against 1.6 schema' => ['bom-1.6.schema.json', 'bom-1.6-custom.json']; + yield 'VEX 1.6 fixture validates against 1.6 schema' => ['bom-1.6.schema.json', 'vex-1.6.json']; + yield 'VEX 1.6 fixture without components validates against 1.6 schema' => ['bom-1.6.schema.json', 'vex-1.6-without-components.json']; } #[Test] diff --git a/tests/Unit/Parser/CycloneDxParserTest.php b/tests/Unit/Parser/CycloneDxParserTest.php index 4c9b2c2..840f038 100644 --- a/tests/Unit/Parser/CycloneDxParserTest.php +++ b/tests/Unit/Parser/CycloneDxParserTest.php @@ -33,7 +33,13 @@ use mteu\SbomParser\Entity\LicenseAcknowledgement; use mteu\SbomParser\Entity\LicenseType; use mteu\SbomParser\Entity\OrganizationalContact; +use mteu\SbomParser\Entity\Property; use mteu\SbomParser\Entity\Tool; +use mteu\SbomParser\Entity\Vulnerability\ImpactAnalysisJustification; +use mteu\SbomParser\Entity\Vulnerability\ImpactAnalysisResponse; +use mteu\SbomParser\Entity\Vulnerability\ImpactAnalysisState; +use mteu\SbomParser\Entity\Vulnerability\Vulnerability; +use mteu\SbomParser\Entity\Vulnerability\VulnerabilityAffects; use mteu\SbomParser\Exception\SbomParseException; use mteu\SbomParser\Parser\Configuration\CycloneDxParserOptions; use mteu\SbomParser\Parser\CycloneDxParser; @@ -1605,6 +1611,166 @@ private function parseHandAuthoredFixture(): Bom return $this->subject->parseFromFile(self::fixtureDir() . '/bom-1.6-custom.json'); } + #[Test] + public function parseFromFileTypesTheAnalysisOfAVexDocument(): void + { + $vulnerabilities = $this->parseVexFixture()->vulnerabilities ?? []; + self::assertCount(6, $vulnerabilities); + + self::assertSame( + [ + ImpactAnalysisState::NOT_AFFECTED, + ImpactAnalysisState::EXPLOITABLE, + ImpactAnalysisState::RESOLVED, + ImpactAnalysisState::FALSE_POSITIVE, + ImpactAnalysisState::IN_TRIAGE, + ImpactAnalysisState::NOT_AFFECTED, + ], + array_map(static fn (Vulnerability $vulnerability): ?ImpactAnalysisState => $vulnerability->analysis?->state, $vulnerabilities), + ); + + $notAffected = $vulnerabilities[0]->analysis; + self::assertNotNull($notAffected); + self::assertSame(ImpactAnalysisJustification::CODE_NOT_REACHABLE, $notAffected->justification); + self::assertNull($notAffected->response); + self::assertSame('Fragments are never rendered, so the vulnerable path is never called.', $notAffected->detail); + self::assertSame('2026-10-01T09:15:00+00:00', $notAffected->firstIssued?->format(\DateTimeInterface::ATOM)); + self::assertSame('2026-10-01T09:15:00+00:00', $notAffected->lastUpdated?->format(\DateTimeInterface::ATOM)); + + self::assertSame([ImpactAnalysisResponse::UPDATE], $vulnerabilities[1]->analysis?->response); + } + + #[Test] + public function parseFromFileKeepsEveryEntryOfAVulnerabilityIdThatHitsTwoComponents(): void + { + $vulnerabilities = array_values(array_filter( + $this->parseVexFixture()->vulnerabilities ?? [], + static fn (Vulnerability $vulnerability): bool => $vulnerability->id === 'CVE-2026-1234', + )); + + self::assertCount(2, $vulnerabilities); + self::assertSame( + [ + ['pkg:composer/symfony/http-kernel@5.4.19'], + ['pkg:composer/symfony/http-kernel@6.4.2'], + ], + array_map( + static fn (Vulnerability $vulnerability): array => array_map( + static fn (VulnerabilityAffects $affects): string => $affects->ref, + $vulnerability->affects ?? [], + ), + $vulnerabilities, + ), + ); + self::assertNotSame($vulnerabilities[0]->analysis?->state, $vulnerabilities[1]->analysis?->state); + } + + #[Test] + public function parseFromFileKeepsTheReferencesAndPropertiesOfAVexDocument(): void + { + $bom = $this->parseVexFixture(); + $vulnerabilities = $bom->vulnerabilities ?? []; + + self::assertNull($bom->serialNumber); + self::assertSame('NVD', $vulnerabilities[0]->source?->name); + self::assertSame('GHSA-jfh8-c2jp-5v3q', ($vulnerabilities[0]->references ?? [])[0]->id ?? null); + + self::assertSame( + [['mteu:vex:justification', 'component_not_present']], + array_map( + static fn (Property $property): array => [$property->name, $property->value], + $vulnerabilities[5]->properties ?? [], + ), + ); + self::assertSame(ImpactAnalysisJustification::CODE_NOT_PRESENT, $vulnerabilities[5]->analysis?->justification); + } + + #[Test] + public function parseFromFileFindsTheComponentsAVexDocumentRepeatsByPurl(): void + { + $bom = $this->parseVexFixture(); + + self::assertCount(5, $bom->components ?? []); + self::assertSame('http-kernel', $bom->findComponentByPurl('pkg:composer/symfony/http-kernel@6.4.2')?->name); + } + + #[Test] + public function parseFromFileReadsAVexDocumentWithoutComponents(): void + { + $bom = $this->subject->parseFromFile(self::fixtureDir() . '/vex-1.6-without-components.json'); + + self::assertFalse($bom->hasComponents()); + self::assertSame([], $bom->getAllComponents()); + self::assertSame(ImpactAnalysisState::IN_TRIAGE, ($bom->vulnerabilities ?? [])[0]->analysis?->state); + } + + /** + * @return \Generator, string}> + */ + public static function unknownAnalysisValueProvider(): \Generator + { + yield 'state' => [['state' => 'resolved_with_hope'], 'vulnerabilities.1.analysis.state']; + yield 'justification' => [['state' => 'not_affected', 'justification' => 'nobody_uses_it'], 'vulnerabilities.1.analysis.justification']; + yield 'one of several responses' => [['state' => 'exploitable', 'response' => ['update', 'teleport']], 'vulnerabilities.1.analysis.response.1']; + } + + /** + * @param array $analysis + */ + #[Test] + #[DataProvider('unknownAnalysisValueProvider')] + public function parseFromArrayRejectsAnAnalysisValueOutsideTheVocabularyAndNamesItsPath(array $analysis, string $expectedPath): void + { + $this->expectException(SbomParseException::class); + $this->expectExceptionMessage('Error at path: ' . $expectedPath); + + $this->subject->parseFromArray([ + 'bomFormat' => 'CycloneDX', + 'specVersion' => '1.6', + 'vulnerabilities' => [ + ['id' => 'CVE-2026-0001', 'analysis' => ['state' => 'in_triage']], + ['id' => 'CVE-2026-0002', 'analysis' => $analysis], + ], + ]); + } + + /** + * @return \Generator}> + */ + public static function everyAnalysisValueProvider(): \Generator + { + foreach (ImpactAnalysisState::cases() as $state) { + yield 'state ' . $state->value => [['state' => $state->value]]; + } + foreach (ImpactAnalysisJustification::cases() as $justification) { + yield 'justification ' . $justification->value => [['state' => 'not_affected', 'justification' => $justification->value]]; + } + foreach (ImpactAnalysisResponse::cases() as $response) { + yield 'response ' . $response->value => [['state' => 'exploitable', 'response' => [$response->value]]]; + } + } + + /** + * @param array $analysis + */ + #[Test] + #[DataProvider('everyAnalysisValueProvider')] + public function parseFromArrayAcceptsEveryAnalysisValueOfTheVocabulary(array $analysis): void + { + $bom = $this->subject->parseFromArray([ + 'bomFormat' => 'CycloneDX', + 'specVersion' => '1.6', + 'vulnerabilities' => [['id' => 'CVE-2026-0001', 'analysis' => $analysis]], + ]); + + self::assertNotNull(($bom->vulnerabilities ?? [])[0]->analysis?->state); + } + + private function parseVexFixture(): Bom + { + return $this->subject->parseFromFile(self::fixtureDir() . '/vex-1.6.json'); + } + #[Test] #[DataProvider('isValidSbomFileProvider')] public function isValidSbomFile(string $filePath, bool $expected): void