From d24381bd340462ed49b3533505f7c33e4da45347 Mon Sep 17 00:00:00 2001 From: milux Date: Mon, 18 Nov 2024 10:49:44 +0100 Subject: [PATCH] RetrievedAggregator Implementation (#110) * Added aggregator impl * Refactor dependencies and improve document retrieval Updated the build scripts to use `api` instead of `implementation` for key dependencies to ensure better visibility and reusability. Enhanced `AggregatorDemo.kt` to display more detailed document retrieval information and handle errors more gracefully. * Added logging and enhanced test coverage Added logging to the provider resolution process to facilitate debugging. Updated test cases to include additional scenarios, ensuring comprehensive coverage. --- csaf-cvss/build.gradle.kts | 2 +- csaf-retrieval/build.gradle.kts | 9 ++- .../github/csaf/sbom/retrieval/CsafLoader.kt | 2 +- .../csaf/sbom/retrieval/RetrievalContext.kt | 19 +----- .../sbom/retrieval/RetrievedAggregator.kt | 51 +++++++++++++-- .../csaf/sbom/retrieval/RetrievedProvider.kt | 22 +++++-- .../io/github/csaf/sbom/retrieval/Utils.kt | 2 + .../sbom/retrieval/demo/AggregatorDemo.kt | 62 +++++++++++++++++++ .../{Main.kt => demo/ProviderDemo.kt} | 3 +- .../retrieval/requirements/Requirements.kt | 56 +---------------- .../github/csaf/sbom/retrieval/roles/Roles.kt | 8 ++- .../csaf/sbom/retrieval/CsafLoaderTest.kt | 5 -- .../sbom/retrieval/RetrievedAggregatorTest.kt | 22 ++++++- .../sbom/retrieval/RetrievedProviderTest.kt | 2 +- .../csaf/sbom/retrieval/ValidatableTest.kt | 54 ++++++++++++++++ .../retrieval/requirements/RequirementTest.kt | 12 ++-- .../requirements/RequirementsTest.kt | 57 +++-------------- .../example.com/example-01-aggregator.json | 37 ++++++++++- .../example.com/example-01-lister.json | 2 +- csaf-schema/build.gradle.kts | 1 - gradle/libs.versions.toml | 22 ++++--- settings.gradle.kts | 2 +- 22 files changed, 288 insertions(+), 164 deletions(-) create mode 100644 csaf-retrieval/src/main/kotlin/io/github/csaf/sbom/retrieval/demo/AggregatorDemo.kt rename csaf-retrieval/src/main/kotlin/io/github/csaf/sbom/retrieval/{Main.kt => demo/ProviderDemo.kt} (95%) create mode 100644 csaf-retrieval/src/test/kotlin/io/github/csaf/sbom/retrieval/ValidatableTest.kt diff --git a/csaf-cvss/build.gradle.kts b/csaf-cvss/build.gradle.kts index 90668a29..7aafb9a3 100644 --- a/csaf-cvss/build.gradle.kts +++ b/csaf-cvss/build.gradle.kts @@ -10,5 +10,5 @@ mavenPublishing { } dependencies { - implementation(project(":csaf-schema")) + api(project(":csaf-schema")) } \ No newline at end of file diff --git a/csaf-retrieval/build.gradle.kts b/csaf-retrieval/build.gradle.kts index 15702b54..606ef6c6 100644 --- a/csaf-retrieval/build.gradle.kts +++ b/csaf-retrieval/build.gradle.kts @@ -11,12 +11,15 @@ mavenPublishing { dependencies { api(project(":csaf-schema")) - implementation(project(":csaf-validation")) - implementation(libs.kotlinx.coroutines) - implementation(libs.kotlinx.json) + api(project(":csaf-validation")) + api(libs.kotlinx.coroutines) + api(libs.kotlinx.json) implementation(libs.bundles.ktor.client) implementation(libs.ktor.kotlinx.json) + implementation(libs.kotlin.logging) + implementation(libs.bundles.slf4j) testImplementation(libs.kotlinx.coroutines.test) testImplementation(libs.ktor.client.mock) + testImplementation(libs.mockk) testImplementation(testFixtures(project(":csaf-validation"))) } diff --git a/csaf-retrieval/src/main/kotlin/io/github/csaf/sbom/retrieval/CsafLoader.kt b/csaf-retrieval/src/main/kotlin/io/github/csaf/sbom/retrieval/CsafLoader.kt index 412f94ba..a2c47d6b 100644 --- a/csaf-retrieval/src/main/kotlin/io/github/csaf/sbom/retrieval/CsafLoader.kt +++ b/csaf-retrieval/src/main/kotlin/io/github/csaf/sbom/retrieval/CsafLoader.kt @@ -149,7 +149,7 @@ class CsafLoader(engine: HttpClientEngine = Java.create()) { securityTxt .lineSequence() .mapNotNull { line -> - SecurityTxt.csafEntry.matchEntire(line)?.let { it.groupValues[1] } + CSAF_ENTRY_REGEX.matchEntire(line)?.let { it.groupValues[1] } } .toList() } diff --git a/csaf-retrieval/src/main/kotlin/io/github/csaf/sbom/retrieval/RetrievalContext.kt b/csaf-retrieval/src/main/kotlin/io/github/csaf/sbom/retrieval/RetrievalContext.kt index ce1328f7..c477c3a9 100644 --- a/csaf-retrieval/src/main/kotlin/io/github/csaf/sbom/retrieval/RetrievalContext.kt +++ b/csaf-retrieval/src/main/kotlin/io/github/csaf/sbom/retrieval/RetrievalContext.kt @@ -18,20 +18,6 @@ package io.github.csaf.sbom.retrieval import io.ktor.client.statement.HttpResponse -/** Specifies the data source of the "provider-metadata.json". */ -sealed class ProviderMetaDataSource - -/** provider-metadata.json was fetched from a well-known location. */ -object WellKnownPath : ProviderMetaDataSource() - -/** provider-metadata.json was fetched from a location specified in a security.txt. */ -data object SecurityTxt : ProviderMetaDataSource() { - val csafEntry = Regex("CSAF: (https://.*)") -} - -/** provider-metadata.json was fetched from a special DNS path. */ -object DNSPath : ProviderMetaDataSource() - /** * This [RetrievalContext] holds all the necessary information that is needed to validate a * validatable object. According to the requirements in the specification we probably need access to @@ -43,14 +29,11 @@ object DNSPath : ProviderMetaDataSource() * - The HTTP headers used in the HTTP communication to check for redirects; or the complete HTTP * request; see [RetrievalContext.httpResponse]) */ -open class RetrievalContext() { +open class RetrievalContext { /** The document to validate. */ var json: Any? = null - /** If this validates a provider, this will be the data source of the provider-metadata.json. */ - var dataSource: ProviderMetaDataSource? = null - /** The HTTP response used to retrieve the [json]. */ var httpResponse: HttpResponse? = null } diff --git a/csaf-retrieval/src/main/kotlin/io/github/csaf/sbom/retrieval/RetrievedAggregator.kt b/csaf-retrieval/src/main/kotlin/io/github/csaf/sbom/retrieval/RetrievedAggregator.kt index 6f1c0769..6afdd51d 100644 --- a/csaf-retrieval/src/main/kotlin/io/github/csaf/sbom/retrieval/RetrievedAggregator.kt +++ b/csaf-retrieval/src/main/kotlin/io/github/csaf/sbom/retrieval/RetrievedAggregator.kt @@ -16,6 +16,7 @@ */ package io.github.csaf.sbom.retrieval +import io.github.csaf.sbom.retrieval.CsafLoader.Companion.lazyLoader import io.github.csaf.sbom.retrieval.roles.CSAFAggregatorRole import io.github.csaf.sbom.retrieval.roles.CSAFListerRole import io.github.csaf.sbom.schema.generated.Aggregator @@ -40,6 +41,49 @@ class RetrievedAggregator(val json: Aggregator) : Validatable { Aggregator.Category.aggregator -> CSAFAggregatorRole } + /** + * Fetches a list of CSAF providers using the specified loader. + * + * @param loader An optional [CsafLoader] instance to use for fetching data. Defaults to + * [lazyLoader]. + * @return A list of [Result] objects containing [RetrievedProvider] instances. + */ + suspend fun fetchProviders(loader: CsafLoader = lazyLoader): List> { + return json.csaf_providers.map { providerMeta -> + val ctx = RetrievalContext() + loader.fetchProvider(providerMeta.metadata.url.toString(), ctx).mapCatching { p -> + RetrievedProvider(p).also { it.validate(ctx) } + } + } + } + + /** + * Fetches a list of CSAF publishers using the specified loader. + * + * @param loader An optional [CsafLoader] instance to use for fetching data. Defaults to + * [lazyLoader]. + * @return A list of [Result] objects containing [RetrievedProvider] instances. + */ + suspend fun fetchPublishers(loader: CsafLoader = lazyLoader): List> { + return (json.csaf_publishers ?: emptyList()).map { publisherMeta -> + val ctx = RetrievalContext() + loader.fetchProvider(publisherMeta.metadata.url.toString(), ctx).mapCatching { p -> + RetrievedProvider(p).also { it.validate(ctx) } + } + } + } + + /** + * Fetches all providers and publishers, optionally using the specified loader. + * + * @param loader An optional [CsafLoader] instance to use for fetching data. Defaults to + * [lazyLoader]. + * @return A list of [Result] objects containing [RetrievedProvider] instances. + */ + suspend fun fetchAll(loader: CsafLoader = lazyLoader): List> { + return fetchProviders(loader) + fetchPublishers(loader) + } + companion object { /** * Retrieves an [Aggregator] from a given [url]. @@ -51,15 +95,12 @@ class RetrievedAggregator(val json: Aggregator) : Validatable { */ suspend fun from( url: String, - loader: CsafLoader = CsafLoader.lazyLoader + loader: CsafLoader = lazyLoader ): Result { val ctx = RetrievalContext() - val mapAndValidateAggregator = { a: Aggregator -> - RetrievedAggregator(a).also { it.validate(ctx) } - } return loader .fetchAggregator(url, ctx) - .mapCatching(mapAndValidateAggregator) + .mapCatching { a -> RetrievedAggregator(a).also { it.validate(ctx) } } .recoverCatching { e -> throw Exception("Failed to load CSAF Aggregator from $url", e) } diff --git a/csaf-retrieval/src/main/kotlin/io/github/csaf/sbom/retrieval/RetrievedProvider.kt b/csaf-retrieval/src/main/kotlin/io/github/csaf/sbom/retrieval/RetrievedProvider.kt index 4918f778..b6f76653 100644 --- a/csaf-retrieval/src/main/kotlin/io/github/csaf/sbom/retrieval/RetrievedProvider.kt +++ b/csaf-retrieval/src/main/kotlin/io/github/csaf/sbom/retrieval/RetrievedProvider.kt @@ -23,6 +23,7 @@ import io.github.csaf.sbom.retrieval.roles.CSAFTrustedProviderRole import io.github.csaf.sbom.schema.generated.Provider import io.github.csaf.sbom.schema.generated.Provider.Feed import io.github.csaf.sbom.schema.generated.ROLIEFeed +import io.github.oshai.kotlinlogging.KotlinLogging import java.util.* import java.util.concurrent.CompletableFuture import java.util.stream.Stream @@ -300,6 +301,7 @@ class RetrievedProvider(val json: Provider) : Validatable { companion object { const val DEFAULT_CHANNEL_CAPACITY = 256 private val ioScope = CoroutineScope(Dispatchers.IO + SupervisorJob()) + private val log = KotlinLogging.logger {} @JvmStatic @JvmOverloads @@ -333,36 +335,48 @@ class RetrievedProvider(val json: Provider) : Validatable { ): Result { val ctx = RetrievalContext() val mapAndValidateProvider = { p: Provider -> + // TODO: Add some more logging when any implemented tests can fail RetrievedProvider(p).also { it.validate(ctx) } } - // TODO: Only the last error will be available in result. We should do some logging. // First, we need to check if a .well-known URL exists. val wellKnownPath = "https://$domain/.well-known/csaf/provider-metadata.json" return loader .fetchProvider(wellKnownPath, ctx) - .onSuccess { ctx.dataSource = WellKnownPath } .mapCatching(mapAndValidateProvider) .recoverCatching { + log.info(it) { + "Failed to fetch and validate provider via .well-known, trying security.txt..." + } // If failure, we fetch CSAF fields from security.txt and try observed URLs // one-by-one. loader.fetchSecurityTxtCsafUrls(domain).getOrThrow().firstNotNullOf { entry -> loader .fetchProvider(entry, ctx) - .onSuccess { ctx.dataSource = SecurityTxt } .mapCatching(mapAndValidateProvider) .getOrNull() } } .recoverCatching { + log.info(it) { + "Failed to fetch and validate provider via security.txt, trying DNS..." + } // If still failure, we try to fetch the provider directly via HTTPS request to // "csaf.data.security.domain.tld", see // https://docs.oasis-open.org/csaf/csaf/v2.0/os/csaf-v2.0-os.html#7110-requirement-10-dns-path. loader .fetchProvider("https://csaf.data.security.$domain", ctx) - .onSuccess { ctx.dataSource = DNSPath } .mapCatching(mapAndValidateProvider) .getOrThrow() } + .recoverCatching { + log.info(it) { + "Failed to fetch and validate provider via DNS, resolution finally failed." + } + throw Exception( + "Failed to resolve provider for $domain via .well-known, security.txt or DNS.", + it + ) + } } } } diff --git a/csaf-retrieval/src/main/kotlin/io/github/csaf/sbom/retrieval/Utils.kt b/csaf-retrieval/src/main/kotlin/io/github/csaf/sbom/retrieval/Utils.kt index 761ee464..b44fa0d7 100644 --- a/csaf-retrieval/src/main/kotlin/io/github/csaf/sbom/retrieval/Utils.kt +++ b/csaf-retrieval/src/main/kotlin/io/github/csaf/sbom/retrieval/Utils.kt @@ -20,6 +20,8 @@ import kotlinx.coroutines.async import kotlinx.coroutines.awaitAll import kotlinx.coroutines.coroutineScope +val CSAF_ENTRY_REGEX = Regex("CSAF: (https://.*)") + /** * An async replacement for `Iterable.map()`, which processes all elements in parallel using * coroutines. The function preserves the order of the `Iterable` it is applied on. diff --git a/csaf-retrieval/src/main/kotlin/io/github/csaf/sbom/retrieval/demo/AggregatorDemo.kt b/csaf-retrieval/src/main/kotlin/io/github/csaf/sbom/retrieval/demo/AggregatorDemo.kt new file mode 100644 index 00000000..63b34a4a --- /dev/null +++ b/csaf-retrieval/src/main/kotlin/io/github/csaf/sbom/retrieval/demo/AggregatorDemo.kt @@ -0,0 +1,62 @@ +/* + * Copyright (c) 2024, The Authors. All rights reserved. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * + */ +package io.github.csaf.sbom.retrieval.demo + +import io.github.csaf.sbom.retrieval.RetrievedAggregator +import kotlinx.coroutines.channels.toList +import kotlinx.coroutines.runBlocking + +fun main(args: Array) { + runBlocking { + // Create a new "RetrievedAggregator" from wid.cert-bund.de. This will automatically + // discover a + // suitable provider-metadata.json + RetrievedAggregator.from( + "https://wid.cert-bund.de/.well-known/csaf-aggregator/aggregator.json" + ) + .onSuccess { aggregator -> + println("Loaded aggregator.json @ ${aggregator.json.canonical_url}") + val providers = aggregator.fetchProviders() + val publishers = aggregator.fetchPublishers() + println( + "Found ${providers.filter { it.isSuccess }.size} providers and " + + "${publishers.filter { it.isSuccess }.size} publishers." + ) + // Retrieve all documents from all feeds. Note: we currently only support index.txt + for (result in providers + publishers) { + result.onSuccess { + println("Provider @ ${it.json.canonical_url}") + println("Estimated number of documents: ${it.countExpectedDocuments()}") + for (error in it.fetchAllDocumentUrls().toList().filter { it.isFailure }) { + error.onFailure { + println("Could not fetch index/feed: ${it.message}, ${it.cause}") + } + } + println("---") + } + result.onFailure { + println("Could not fetch document: ${it.message}, ${it.cause}") + println("---") + } + } + } + .onFailure { + println("Could not fetch provider meta from ${args[0]}") + it.printStackTrace() + } + } +} diff --git a/csaf-retrieval/src/main/kotlin/io/github/csaf/sbom/retrieval/Main.kt b/csaf-retrieval/src/main/kotlin/io/github/csaf/sbom/retrieval/demo/ProviderDemo.kt similarity index 95% rename from csaf-retrieval/src/main/kotlin/io/github/csaf/sbom/retrieval/Main.kt rename to csaf-retrieval/src/main/kotlin/io/github/csaf/sbom/retrieval/demo/ProviderDemo.kt index 5246597b..34cb61eb 100644 --- a/csaf-retrieval/src/main/kotlin/io/github/csaf/sbom/retrieval/Main.kt +++ b/csaf-retrieval/src/main/kotlin/io/github/csaf/sbom/retrieval/demo/ProviderDemo.kt @@ -14,8 +14,9 @@ * limitations under the License. * */ -package io.github.csaf.sbom.retrieval +package io.github.csaf.sbom.retrieval.demo +import io.github.csaf.sbom.retrieval.RetrievedProvider import kotlinx.coroutines.runBlocking fun main(args: Array) { diff --git a/csaf-retrieval/src/main/kotlin/io/github/csaf/sbom/retrieval/requirements/Requirements.kt b/csaf-retrieval/src/main/kotlin/io/github/csaf/sbom/retrieval/requirements/Requirements.kt index 1b9fe257..995a3b5d 100644 --- a/csaf-retrieval/src/main/kotlin/io/github/csaf/sbom/retrieval/requirements/Requirements.kt +++ b/csaf-retrieval/src/main/kotlin/io/github/csaf/sbom/retrieval/requirements/Requirements.kt @@ -16,10 +16,7 @@ */ package io.github.csaf.sbom.retrieval.requirements -import io.github.csaf.sbom.retrieval.DNSPath import io.github.csaf.sbom.retrieval.RetrievalContext -import io.github.csaf.sbom.retrieval.SecurityTxt -import io.github.csaf.sbom.retrieval.WellKnownPath import io.github.csaf.sbom.schema.generated.Csaf import io.github.csaf.sbom.schema.generated.Csaf.Label import io.github.csaf.sbom.validation.* @@ -79,10 +76,7 @@ object Requirement2ValidFilename : Requirement { */ object Requirement3UsageOfTls : Requirement { override fun check(ctx: RetrievalContext): ValidationResult { - var response = ctx.httpResponse - if (response == null) { - return ValidationNotApplicable - } + val response = ctx.httpResponse ?: return ValidationNotApplicable return if (response.request.url.protocol == URLProtocol.HTTPS) { ValidationSuccessful @@ -145,54 +139,6 @@ object Requirement7 : Requirement { } } -/** - * Represents - * [Requirement 8: security.txt](https://docs.oasis-open.org/csaf/csaf/v2.0/os/csaf-v2.0-os.html#718-requirement-8-securitytxt). - * - * The check itself is already performed in the retrieval API, we can just check for the existence - * of the data source here. - */ -object Requirement8SecurityTxt : Requirement { - override fun check(ctx: RetrievalContext) = - if (ctx.dataSource == SecurityTxt) { - ValidationSuccessful - } else { - ValidationFailed(listOf("Not resolved via security.txt")) - } -} - -/** - * Represents - * [Requirement 9: Well-known URL](https://docs.oasis-open.org/csaf/csaf/v2.0/os/csaf-v2.0-os.html#719-requirement-9-well-known-url-for-provider-metadatajson). - * - * The check itself is already performed in the retrieval API, we can just check for the existence - * of the data source here. - */ -object Requirement9WellKnownURL : Requirement { - override fun check(ctx: RetrievalContext) = - if (ctx.dataSource == WellKnownPath) { - ValidationSuccessful - } else { - ValidationFailed(listOf("Not resolved via .well-known")) - } -} - -/** - * Represents - * [Requirement 10: DNS path](https://docs.oasis-open.org/csaf/csaf/v2.0/os/csaf-v2.0-os.html#7110-requirement-10-dns-path). - * - * The check itself is already performed in the retrieval API, we can just check for the existence - * of the data source here. - */ -object Requirement10DNSPath : Requirement { - override fun check(ctx: RetrievalContext) = - if (ctx.dataSource == DNSPath) { - ValidationSuccessful - } else { - ValidationFailed(listOf("Not resolved via CSAF domain (csaf.data.security.domain.tld)")) - } -} - // TODO(oxisto): This is actually a document requirement, but it is part of an OR clause in the role // requirement :( object Requirement11YearInFolder : Requirement { diff --git a/csaf-retrieval/src/main/kotlin/io/github/csaf/sbom/retrieval/roles/Roles.kt b/csaf-retrieval/src/main/kotlin/io/github/csaf/sbom/retrieval/roles/Roles.kt index 419f74c4..258f2f48 100644 --- a/csaf-retrieval/src/main/kotlin/io/github/csaf/sbom/retrieval/roles/Roles.kt +++ b/csaf-retrieval/src/main/kotlin/io/github/csaf/sbom/retrieval/roles/Roles.kt @@ -38,12 +38,18 @@ object CSAFPublisherRole : Role { /** * The "CSAF provider" role. See * https://docs.oasis-open.org/csaf/csaf/v2.0/os/csaf-v2.0-os.html#722-role-csaf-provider. + * + * Requirements 8, 9 and 10 need to be implicitly fulfilled by the domain-based fetching algorithm. + * They are therefore not explicitly checked. For reference, see these links: + * [Requirement 8: security.txt](https://docs.oasis-open.org/csaf/csaf/v2.0/os/csaf-v2.0-os.html#718-requirement-8-securitytxt) + * [Requirement 9: Well-known + * URL](https://docs.oasis-open.org/csaf/csaf/v2.0/os/csaf-v2.0-os.html#719-requirement-9-well-known-url-for-provider-metadatajson) + * [Requirement 10: DNS path](https://docs.oasis-open.org/csaf/csaf/v2.0/os/csaf-v2.0-os.html#7110-requirement-10-dns-path) */ object CSAFProviderRole : Role { override val roleRequirements = CSAFPublisherRole.roleRequirements + allOf(Requirement6, Requirement7) + - oneOf(Requirement8SecurityTxt, Requirement9WellKnownURL, Requirement10DNSPath) + (allOf(Requirement11YearInFolder, Requirement12, Requirement13, Requirement14) or allOf(Requirement15, Requirement16, Requirement17)) diff --git a/csaf-retrieval/src/test/kotlin/io/github/csaf/sbom/retrieval/CsafLoaderTest.kt b/csaf-retrieval/src/test/kotlin/io/github/csaf/sbom/retrieval/CsafLoaderTest.kt index b8ed85bf..0c3169a9 100644 --- a/csaf-retrieval/src/test/kotlin/io/github/csaf/sbom/retrieval/CsafLoaderTest.kt +++ b/csaf-retrieval/src/test/kotlin/io/github/csaf/sbom/retrieval/CsafLoaderTest.kt @@ -16,7 +16,6 @@ */ package io.github.csaf.sbom.retrieval -import io.github.csaf.sbom.validation.ValidationException import io.ktor.http.* import kotlin.test.* import kotlinx.coroutines.test.runTest @@ -54,10 +53,6 @@ class CsafLoaderTest { result.isSuccess, "Failed to \"download\" example-01-aggregator.json from resources." ) - // Fresh [ValidationContext] should always throw. - assertFailsWith { - RetrievedProvider(result.getOrThrow()).validate(RetrievalContext()) - } val provider = result.getOrNull() assertNotNull(provider) diff --git a/csaf-retrieval/src/test/kotlin/io/github/csaf/sbom/retrieval/RetrievedAggregatorTest.kt b/csaf-retrieval/src/test/kotlin/io/github/csaf/sbom/retrieval/RetrievedAggregatorTest.kt index 5b149d8f..e094249e 100644 --- a/csaf-retrieval/src/test/kotlin/io/github/csaf/sbom/retrieval/RetrievedAggregatorTest.kt +++ b/csaf-retrieval/src/test/kotlin/io/github/csaf/sbom/retrieval/RetrievedAggregatorTest.kt @@ -22,9 +22,9 @@ import io.github.csaf.sbom.retrieval.requirements.mockResponse import io.github.csaf.sbom.retrieval.roles.CSAFAggregatorRole import io.github.csaf.sbom.validation.assertValidationSuccessful import io.github.csaf.sbom.validation.goodCsaf -import io.ktor.http.HttpStatusCode -import io.ktor.http.Url +import io.ktor.http.* import kotlin.test.Test +import kotlin.test.assertEquals import kotlin.test.assertTrue import kotlinx.coroutines.test.runTest @@ -61,4 +61,22 @@ class RetrievedAggregatorTest { ) ) } + + @Test + fun testFetchAll() = runTest { + RetrievedAggregator.from("https://example.com/example-01-aggregator.json") + .getOrThrow() + .let { aggregator -> + val (successes, failures) = aggregator.fetchAll().partition { it.isSuccess } + assertEquals(2, successes.size) + assertEquals(2, failures.size) + } + RetrievedAggregator.from("https://example.com/example-01-lister.json").getOrThrow().let { + lister -> + val (successes, failures) = lister.fetchProviders().partition { it.isSuccess } + assertEquals(1, successes.size) + assertEquals(0, failures.size) + assertEquals(0, lister.fetchPublishers().size) + } + } } diff --git a/csaf-retrieval/src/test/kotlin/io/github/csaf/sbom/retrieval/RetrievedProviderTest.kt b/csaf-retrieval/src/test/kotlin/io/github/csaf/sbom/retrieval/RetrievedProviderTest.kt index 5d4b002f..b4c34c1e 100644 --- a/csaf-retrieval/src/test/kotlin/io/github/csaf/sbom/retrieval/RetrievedProviderTest.kt +++ b/csaf-retrieval/src/test/kotlin/io/github/csaf/sbom/retrieval/RetrievedProviderTest.kt @@ -44,7 +44,7 @@ class RetrievedProviderTest { runTest { RetrievedProvider.from("broken-domain.com").getOrThrow() } } assertEquals( - "Could not retrieve https://csaf.data.security.broken-domain.com: Not Found", + "Failed to resolve provider for broken-domain.com via .well-known, security.txt or DNS.", exception.message ) } diff --git a/csaf-retrieval/src/test/kotlin/io/github/csaf/sbom/retrieval/ValidatableTest.kt b/csaf-retrieval/src/test/kotlin/io/github/csaf/sbom/retrieval/ValidatableTest.kt new file mode 100644 index 00000000..8c9c974b --- /dev/null +++ b/csaf-retrieval/src/test/kotlin/io/github/csaf/sbom/retrieval/ValidatableTest.kt @@ -0,0 +1,54 @@ +/* + * Copyright (c) 2024, The Authors. All rights reserved. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * + */ +package io.github.csaf.sbom.retrieval + +import io.github.csaf.sbom.retrieval.roles.Role +import io.github.csaf.sbom.validation.ValidationException +import io.github.csaf.sbom.validation.ValidationFailed +import io.github.csaf.sbom.validation.ValidationSuccessful +import io.mockk.every +import io.mockk.mockk +import kotlin.test.Test +import kotlin.test.assertFailsWith + +class ValidatableTest { + + class TestValidatable(override val role: Role) : Validatable + + @Test + fun `validate should not throw an exception if role validation passes`() { + val mockRole = mockk() + val retrievalContext = RetrievalContext() + + every { mockRole.checkRole(retrievalContext) } returns ValidationSuccessful + + val validatable = TestValidatable(mockRole) + validatable.validate(retrievalContext) + } + + @Test + fun `validate should throw ValidationFailed exception if role validation fails`() { + val mockRole = mockk() + val retrievalContext = RetrievalContext() + val validationFailed = ValidationFailed(listOf("Validation Error")) + + every { mockRole.checkRole(retrievalContext) } returns validationFailed + + val validatable = TestValidatable(mockRole) + assertFailsWith { validatable.validate(retrievalContext) } + } +} diff --git a/csaf-retrieval/src/test/kotlin/io/github/csaf/sbom/retrieval/requirements/RequirementTest.kt b/csaf-retrieval/src/test/kotlin/io/github/csaf/sbom/retrieval/requirements/RequirementTest.kt index 4c68b59e..cc596d4e 100644 --- a/csaf-retrieval/src/test/kotlin/io/github/csaf/sbom/retrieval/requirements/RequirementTest.kt +++ b/csaf-retrieval/src/test/kotlin/io/github/csaf/sbom/retrieval/requirements/RequirementTest.kt @@ -17,19 +17,13 @@ package io.github.csaf.sbom.retrieval.requirements import io.github.csaf.sbom.retrieval.RetrievalContext -import io.github.csaf.sbom.retrieval.WellKnownPath import io.github.csaf.sbom.validation.ValidationFailed import io.github.csaf.sbom.validation.ValidationResult import io.github.csaf.sbom.validation.ValidationSuccessful import kotlin.test.Test import kotlin.test.assertIs -class TestRetrievalContext() : RetrievalContext() { - init { - this.dataSource = WellKnownPath - this.json = json - } -} +class TestRetrievalContext : RetrievalContext() val alwaysFail = object : Requirement { @@ -79,5 +73,9 @@ class RequirementTest { val requirement = oneOf(alwaysFail, alwaysGood, alwaysGood, alwaysFail) val result = requirement.check(TestRetrievalContext()) assertIs(result) + + val failingRequirement = oneOf(alwaysFail, alwaysFail, alwaysFail) + val failingResult = failingRequirement.check(TestRetrievalContext()) + assertIs(failingResult) } } diff --git a/csaf-retrieval/src/test/kotlin/io/github/csaf/sbom/retrieval/requirements/RequirementsTest.kt b/csaf-retrieval/src/test/kotlin/io/github/csaf/sbom/retrieval/requirements/RequirementsTest.kt index d5615457..ad8da425 100644 --- a/csaf-retrieval/src/test/kotlin/io/github/csaf/sbom/retrieval/requirements/RequirementsTest.kt +++ b/csaf-retrieval/src/test/kotlin/io/github/csaf/sbom/retrieval/requirements/RequirementsTest.kt @@ -16,30 +16,18 @@ */ package io.github.csaf.sbom.retrieval.requirements -import io.github.csaf.sbom.retrieval.DNSPath import io.github.csaf.sbom.retrieval.RetrievalContext -import io.github.csaf.sbom.retrieval.SecurityTxt -import io.github.csaf.sbom.retrieval.WellKnownPath import io.github.csaf.sbom.schema.generated.Csaf -import io.github.csaf.sbom.validation.ValidationFailed -import io.github.csaf.sbom.validation.ValidationNotApplicable -import io.github.csaf.sbom.validation.ValidationSuccessful -import io.github.csaf.sbom.validation.goodCsaf -import io.github.csaf.sbom.validation.goodDistribution -import io.ktor.client.HttpClient -import io.ktor.client.call.HttpClientCall -import io.ktor.client.request.HttpRequestData -import io.ktor.client.request.HttpResponseData -import io.ktor.client.statement.HttpResponse -import io.ktor.client.utils.EmptyContent -import io.ktor.http.Headers -import io.ktor.http.HttpMethod -import io.ktor.http.HttpProtocolVersion -import io.ktor.http.HttpStatusCode -import io.ktor.http.Url +import io.github.csaf.sbom.validation.* +import io.ktor.client.* +import io.ktor.client.call.* +import io.ktor.client.request.* +import io.ktor.client.statement.* +import io.ktor.client.utils.* +import io.ktor.http.* import io.ktor.http.headers -import io.ktor.util.Attributes -import io.ktor.util.date.GMTDate +import io.ktor.util.* +import io.ktor.util.date.* import io.ktor.utils.io.* import kotlin.coroutines.EmptyCoroutineContext import kotlin.test.Test @@ -197,33 +185,6 @@ class RequirementsTest { ) ) } - - @Test - fun testRequirement8() { - val (rule, ctx) = testRule(Requirement8SecurityTxt) - - // Data source is not security.txt -> fail - assertIs(rule.check(ctx.also { ctx.dataSource = WellKnownPath })) - } - - @Test - fun testRequirement9() { - val (rule, ctx) = testRule(Requirement9WellKnownURL) - - // Data source is not well_known -> fail - assertIs(rule.check(ctx.also { ctx.dataSource = DNSPath })) - } - - @Test - fun testRequirement10() { - val (rule, ctx) = testRule(Requirement10DNSPath) - - // Data source is not DNS -> fail - assertIs(rule.check(ctx.also { ctx.dataSource = SecurityTxt })) - - // Data source is DNS -> success - assertIs(rule.check(ctx.also { ctx.dataSource = DNSPath })) - } } fun testRule(rule: T): Pair { diff --git a/csaf-retrieval/src/test/resources/example.com/example-01-aggregator.json b/csaf-retrieval/src/test/resources/example.com/example-01-aggregator.json index 3d3ed8e7..c3e6f1cd 100644 --- a/csaf-retrieval/src/test/resources/example.com/example-01-aggregator.json +++ b/csaf-retrieval/src/test/resources/example.com/example-01-aggregator.json @@ -1,6 +1,6 @@ { "aggregator": { - "category": "lister", + "category": "aggregator", "contact_details": "Example CSAF Lister can be reached at contact_us@lister.example, or via our website at https://lister.example/security/csaf/aggregator/contact.", "issuing_authority": "This service is provided as it is. It is free for everybody.", "name": "Example CSAF Lister", @@ -19,6 +19,41 @@ }, "url": "https://example.com/.well-known/csaf/provider-metadata.json" } + }, { + "metadata": { + "last_updated": "2021-07-12T20:20:56.169Z", + "publisher": { + "category": "vendor", + "name": "Non-Existing Company ProductCERT", + "namespace": "https://psirt.example.com" + }, + "url": "https://does-not-exist.com/.well-known/csaf/provider-metadata.json" + } + } + ], + "csaf_publishers": [ + { + "metadata": { + "last_updated": "2021-07-12T20:20:56.169Z", + "publisher": { + "category": "vendor", + "name": "Example Company ProductCERT", + "namespace": "https://psirt.example.com" + }, + "url": "https://example.com/.well-known/csaf/provider-metadata.json" + }, + "update_interval": "best effort" + }, { + "metadata": { + "last_updated": "2021-07-12T20:20:56.169Z", + "publisher": { + "category": "vendor", + "name": "Non-Existing Company ProductCERT", + "namespace": "https://psirt.example.com" + }, + "url": "https://does-not-exist.com/.well-known/csaf/provider-metadata.json" + }, + "update_interval": "best effort" } ], "last_updated":"2021-07-12T22:35:38.978Z" diff --git a/csaf-retrieval/src/test/resources/example.com/example-01-lister.json b/csaf-retrieval/src/test/resources/example.com/example-01-lister.json index 9261cddb..3d3ed8e7 100644 --- a/csaf-retrieval/src/test/resources/example.com/example-01-lister.json +++ b/csaf-retrieval/src/test/resources/example.com/example-01-lister.json @@ -1,6 +1,6 @@ { "aggregator": { - "category": "aggregator", + "category": "lister", "contact_details": "Example CSAF Lister can be reached at contact_us@lister.example, or via our website at https://lister.example/security/csaf/aggregator/contact.", "issuing_authority": "This service is provided as it is. It is free for everybody.", "name": "Example CSAF Lister", diff --git a/csaf-schema/build.gradle.kts b/csaf-schema/build.gradle.kts index 3dd8635f..5105e5dd 100644 --- a/csaf-schema/build.gradle.kts +++ b/csaf-schema/build.gradle.kts @@ -17,7 +17,6 @@ mavenPublishing { dependencies { api(libs.kotlinx.json) - testImplementation(libs.mockito.kotlin) } configure { diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 941fae96..bb6b0dda 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -1,20 +1,23 @@ [versions] codegen = "0.108.2" -ktor = "3.0.1" -kotlin = "2.0.21" dokka = "1.9.20" # for dependency CVE fix woodstox = "6.7.0" +ktor = "3.0.1" +kotlin = "2.0.21" +kotlin-logging = "7.0.0" kotlinx-coroutines = "1.9.0" kotlinx-json = "1.7.3" kover = "0.8.3" -mockito-kotlin = "5.4.0" -spotless = "6.25.0" +mockk = "1.13.13" publish = "0.30.0" -semver = "2.0.0" purl = "1.5.0" +semver = "2.0.0" +slf4j = "2.0.16" +spotless = "6.25.0" [libraries] +kotlin-logging = { group = "io.github.oshai", name = "kotlin-logging", version.ref = "kotlin-logging" } kotlinx-coroutines = { group = "org.jetbrains.kotlinx", name = "kotlinx-coroutines-core", version.ref = "kotlinx-coroutines" } kotlinx-coroutines-test = { group = "org.jetbrains.kotlinx", name = "kotlinx-coroutines-test", version.ref = "kotlinx-coroutines" } kotlinx-json = { group = "org.jetbrains.kotlinx", name = "kotlinx-serialization-json", version.ref = "kotlinx-json" } @@ -23,23 +26,26 @@ ktor-client-content-negotiation = { group = "io.ktor", name = "ktor-client-conte ktor-client-java = { group = "io.ktor", name = "ktor-client-java", version.ref = "ktor" } ktor-client-mock = { group = "io.ktor", name = "ktor-client-mock", version.ref = "ktor" } ktor-kotlinx-json = { group = "io.ktor", name = "ktor-serialization-kotlinx-json", version.ref = "ktor" } -mockito-kotlin = { group = "org.mockito.kotlin", name = "mockito-kotlin", version.ref = "mockito-kotlin" } +mockk = { group = "io.mockk", name = "mockk", version.ref = "mockk" } purl = { group = "com.github.package-url", name = "packageurl-java", version.ref = "purl" } semver = { group = "net.swiftzer.semver", name = "semver", version.ref = "semver" } +slf4j-api = { group = "org.slf4j", name = "slf4j-api", version.ref = "slf4j" } +slf4j-jdk14 = { group = "org.slf4j", name = "slf4j-jdk14", version.ref = "slf4j" } # plugins -kotlin-json-codegen = { module = "com.github.csaf-sbom:json-kotlin-gradle", version.ref = "codegen" } -kotlin-gradle = { module = "org.jetbrains.kotlin:kotlin-gradle-plugin", version.ref = "kotlin" } dokka-gradle = { module = "org.jetbrains.dokka:dokka-gradle-plugin", version.ref = "dokka" } # dependency CVE fix fasterxml-woodstox = { module = "com.fasterxml.woodstox:woodstox-core", version.ref = "woodstox" } publish-central = { module = "com.vanniktech:gradle-maven-publish-plugin", version.ref = "publish" } +kotlin-gradle = { module = "org.jetbrains.kotlin:kotlin-gradle-plugin", version.ref = "kotlin" } +kotlin-json-codegen = { module = "com.github.csaf-sbom:json-kotlin-gradle", version.ref = "codegen" } kotlin-serialization = { module = "org.jetbrains.kotlin:kotlin-serialization", version.ref = "kotlin" } kover-gradle = { module = "org.jetbrains.kotlinx:kover-gradle-plugin", version.ref = "kover" } spotless-gradle = { module = "com.diffplug.spotless:spotless-plugin-gradle", version.ref = "spotless" } [bundles] ktor-client = ["ktor-client-core", "ktor-client-content-negotiation", "ktor-client-java"] +slf4j = ["slf4j-api", "slf4j-jdk14"] [plugins] download = { id = "de.undercouch.download", version = "5.6.0" } \ No newline at end of file diff --git a/settings.gradle.kts b/settings.gradle.kts index 450f3995..7e2fef0a 100644 --- a/settings.gradle.kts +++ b/settings.gradle.kts @@ -23,7 +23,7 @@ kover { excludedClasses.add("io.github.csaf.sbom.validation.tests.CWE*") // Ignore main classes, since they are for demo only - might be removed in the future - excludedClasses.add("io.github.csaf.sbom.retrieval.Main*") + excludedClasses.add("io.github.csaf.sbom.retrieval.demo.*") excludedClasses.add("io.github.csaf.sbom.validation.Main*") } }