diff --git a/SECURITY.md b/SECURITY.md index 4e6175d6d..b8f104074 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -41,6 +41,25 @@ Out of scope: - Denial of service from intentionally malformed inputs where the documented contract is "trusted input only." +## Handling dependency CVEs + +Dependabot flags high/critical-severity advisories against this repo's Maven/JVM and +npm dependency graphs, including transitive ones no build script declares directly. + +- **A transitive npm/Yarn dependency** (Kotlin/JS or Kotlin/Wasm): pinned via + `sk.ainet.npm-pins` — see [Pinning npm Packages](docs/modules/ROOT/pages/contributing/build-from-source.adoc#pinning-npm-packages). +- **A transitive Maven/JVM dependency of the app's own graph**: pinned via + `sk.ainet.maven-pins` — see [Pinning Maven/JVM Dependencies](docs/modules/ROOT/pages/contributing/build-from-source.adoc#pinning-mavenjvm-dependencies). +- **A transitive dependency of a *Gradle plugin's own classpath*** (AGP, Dokka, KSP, + etc.) — neither mechanism above can reach these; see the "What this cannot fix" + note in the Maven/JVM pinning doc linked above. These packages are build-time-only + and are not present in anything SKaiNET publishes, so unless the vulnerable code + path is actually reachable during a build, the alert is usually dismissed as + tolerable risk with that reasoning recorded on the alert, rather than forcing a + plugin version bump purely to silence the scanner. See + [issue #1046](https://github.com/SKaiNET-developers/SKaiNET/issues/1046) for a + worked example of this triage. + ## Hardening and best practices Broader open-source security posture (REUSE/OpenSSF Best Practices, SBOM, dependency diff --git a/build-logic/convention/build.gradle.kts b/build-logic/convention/build.gradle.kts index 47f9dae8e..7dc050afa 100644 --- a/build-logic/convention/build.gradle.kts +++ b/build-logic/convention/build.gradle.kts @@ -55,5 +55,9 @@ gradlePlugin { id = "sk.ainet.npm-pins" implementationClass = "sk.ainet.buildlogic.npm.NpmPinsPlugin" } + register("SKaiNetMavenPins") { + id = "sk.ainet.maven-pins" + implementationClass = "sk.ainet.buildlogic.maven.MavenPinsPlugin" + } } } diff --git a/build-logic/convention/src/main/kotlin/sk/ainet/buildlogic/maven/MavenPinsExtension.kt b/build-logic/convention/src/main/kotlin/sk/ainet/buildlogic/maven/MavenPinsExtension.kt new file mode 100644 index 000000000..eecd93836 --- /dev/null +++ b/build-logic/convention/src/main/kotlin/sk/ainet/buildlogic/maven/MavenPinsExtension.kt @@ -0,0 +1,106 @@ +package sk.ainet.buildlogic.maven + +import org.gradle.api.provider.MapProperty +import org.gradle.api.provider.Property +import org.gradle.api.provider.Provider + +/** + * The `mavenPins { }` block nested inside the root `skainet { }` extension. + * + * Every pin names a Maven/JVM coordinate literally and takes its version from the + * version catalog, so the number stays in one place and stays bumpable by tooling: + * + * ```kotlin + * // gradle/libs.versions.toml + * [versions] + * maven-netty = "4.1.136.Final" # CVE-2026-56819 + * + * // root build.gradle.kts + * skainet { + * mavenPins { + * pin("io.netty:netty-handler", libs.versions.maven.netty) + * } + * } + * ``` + * + * Unlike npm pins, a Maven pin applies to the single dependency graph shared by every + * subproject — there is no per-target lockfile to scope it to. + */ +abstract class MavenPinsExtension { + + /** + * `"group:artifact"` -> exact version. Consumed by [MavenPinsPlugin] and + * [VerifyMavenPinsTask]; declare pins through [pin] rather than mutating this + * directly, which skips validation and duplicate detection. + */ + abstract val pins: MapProperty + + /** + * Whether `verifyMavenPins` fails when a pinned coordinate never appears in any + * subproject's resolved dependency graph. Defaults to `false`: a pin that outlives + * the dependency that once pulled it in transitively is stale rather than broken, + * and should be reported without breaking the build. + */ + abstract val failOnMissingModule: Property + + private val declared = mutableSetOf() + + /** + * Pins [coordinate] (`"group:artifact"`, e.g. `"io.netty:netty-handler"`) to a + * version held in the version catalog. + */ + fun pin(coordinate: String, version: Provider) { + val name = validateCoordinate(coordinate) + val checked = version.map { validateVersion(name, it) } + pins.put(name, checked) + } + + /** + * Pins [coordinate] to a literal version. + * + * Prefer the [Provider] overload — a number in `libs.versions.toml` is visible to + * dependency-update tooling, a number in the build script is not. + */ + fun pin(coordinate: String, version: String) { + val name = validateCoordinate(coordinate) + val checked = validateVersion(name, version) + pins.put(name, checked) + } + + private fun validateCoordinate(coordinate: String): String { + val name = coordinate.trim() + require(name.isNotEmpty()) { "[maven-pins] Coordinate must not be blank" } + require(name.none { it.isWhitespace() }) { + "[maven-pins] Coordinate '$coordinate' must not contain whitespace" + } + require(name.count { it == ':' } == 1 && !name.startsWith(":") && !name.endsWith(":")) { + "[maven-pins] Coordinate '$coordinate' must be exactly \"group:artifact\" " + + "(no version — that goes in the version argument)" + } + require(declared.add(name)) { + "[maven-pins] '$name' is pinned twice — declare it once." + } + return name + } + + /** + * Rejects ranges. `verifyMavenPins` compares the resolved version for exact + * equality, so a range pin such as `4.1.+` could never verify — better to fail at + * configuration time with the reason than at `check` with a confusing mismatch. + */ + private fun validateVersion(coordinate: String, version: String): String { + val exact = version.trim() + require(exact.isNotEmpty()) { + "[maven-pins] Pin for '$coordinate' must declare a version (e.g. \"4.1.136.Final\")" + } + require(exact.none { it in RANGE_CHARACTERS }) { + "[maven-pins] Pin for '$coordinate' must be an exact version, but was '$exact'. " + + "Ranges cannot be verified — write \"4.1.136.Final\", not \"4.1.+\"." + } + return exact + } + + private companion object { + private val RANGE_CHARACTERS = setOf('^', '~', '>', '<', '=', '*', '+', '|', ' ') + } +} diff --git a/build-logic/convention/src/main/kotlin/sk/ainet/buildlogic/maven/MavenPinsPlugin.kt b/build-logic/convention/src/main/kotlin/sk/ainet/buildlogic/maven/MavenPinsPlugin.kt new file mode 100644 index 000000000..cb50ea2ac --- /dev/null +++ b/build-logic/convention/src/main/kotlin/sk/ainet/buildlogic/maven/MavenPinsPlugin.kt @@ -0,0 +1,113 @@ +package sk.ainet.buildlogic.maven + +import org.gradle.api.Plugin +import org.gradle.api.Project +import org.gradle.api.provider.Provider +import sk.ainet.buildlogic.root.SkainetRootExtension + +/** + * Pins selected Maven/JVM coordinates to an audited version across every subproject's + * dependency graph. + * + * ## Why this exists + * + * A high-severity CVE frequently lands in a package nobody declares directly — it is + * pulled in transitively by something else, and the direct dependency that causes it + * may itself have no newer release. Gradle's `resolutionStrategy` is the mechanism + * that actually constrains the graph in that case; this plugin gives it the same + * single-declaration, catalog-sourced shape that `sk.ainet.npm-pins` gives Yarn + * `resolutions`, instead of a bespoke `resolutionStrategy` block being reinvented in + * the root build script each time. + * + * ## Declaring a pin + * + * Put the version in `[versions]` of `gradle/libs.versions.toml` and name the + * coordinate in the root build script: + * + * ```toml + * maven-netty = "4.1.136.Final" # CVE-2026-56819 + * ``` + * + * ```kotlin + * skainet { + * mavenPins { + * pin("io.netty:netty-handler", libs.versions.maven.netty) + * } + * } + * ``` + * + * `verifyMavenPins` (wired into `check`) fails if a pinned coordinate stops resolving + * to its declared version anywhere in the build. It is registered per subproject — + * Gradle only allows a task to resolve configurations that belong to its own project, + * so a single root-level verify task cannot walk every subproject's classpath itself. + * Running `./gradlew verifyMavenPins` from the repository root still verifies the + * whole build: Gradle's CLI matches a bare task name against every project. + * + * Must be applied to the root project: pins are declared once, in the root `skainet {}` + * block, and — like `sk.ainet.npm-pins` — must be declared while the root script is + * evaluated, before any subproject reads them. + */ +class MavenPinsPlugin : Plugin { + + override fun apply(project: Project) { + require(project == project.rootProject) { + "[maven-pins] sk.ainet.maven-pins must be applied to the root project — " + + "pins are declared once in the root skainet { } block, but it was applied to ${project.path}" + } + + val extension = SkainetRootExtension.findOrCreate(project).mavenPins.apply { + failOnMissingModule.convention(false) + } + + // Resolved lazily inside eachDependency, which only fires when a configuration + // is actually resolved — by then the root script body (where pins are declared) + // has long finished evaluating. + val pins: Provider> = extension.pins + + project.allprojects { + configurations.all { + resolutionStrategy.eachDependency { + val coordinate = "${requested.group}:${requested.name}" + pins.get()[coordinate]?.let { pinned -> useVersion(pinned) } + } + } + } + + // One verify task per subproject, each resolving only its own configurations. + // The root project never has *CompileClasspath/*RuntimeClasspath configurations + // of its own, so it gets no task — an empty one would just print a spurious + // "pin never resolved" warning for pins that every subproject resolves fine. + project.subprojects { + val verify = tasks.register("verifyMavenPins", VerifyMavenPinsTask::class.java) { + group = "verification" + description = "Check that every coordinate declared in the root skainet { mavenPins { } } " + + "actually resolves to its pinned version in this subproject" + this.pins.set(pins) + failOnMissingModule.set(extension.failOnMissingModule) + } + + // `configureEach` rather than a one-shot lookup: most of a subproject's + // configurations (especially KMP per-target ones) don't exist yet when this + // plugin applies to the root project, only once the subproject's own build + // script evaluates. + // + // Scoped to *CompileClasspath/*RuntimeClasspath: every pinned coordinate here + // is a JVM-only library, so these are the only configurations that could ever + // resolve one. This also sidesteps the various non-classpath resolvable + // configurations (coverage/report aggregation buckets and the like) some + // plugins wire up in ways `isCanBeResolved` alone doesn't reliably describe + // at configuration time. + configurations.matching { configuration -> + configuration.isCanBeResolved && + (configuration.name.endsWith("CompileClasspath") || configuration.name.endsWith("RuntimeClasspath")) + }.configureEach { + val configuration = this + verify.configure { configurations.add(configuration) } + } + + pluginManager.withPlugin("base") { + tasks.named("check").configure { dependsOn(verify) } + } + } + } +} diff --git a/build-logic/convention/src/main/kotlin/sk/ainet/buildlogic/maven/VerifyMavenPinsTask.kt b/build-logic/convention/src/main/kotlin/sk/ainet/buildlogic/maven/VerifyMavenPinsTask.kt new file mode 100644 index 000000000..8fa5adce3 --- /dev/null +++ b/build-logic/convention/src/main/kotlin/sk/ainet/buildlogic/maven/VerifyMavenPinsTask.kt @@ -0,0 +1,117 @@ +package sk.ainet.buildlogic.maven + +import org.gradle.api.DefaultTask +import org.gradle.api.GradleException +import org.gradle.api.artifacts.Configuration +import org.gradle.api.artifacts.component.ModuleComponentIdentifier +import org.gradle.api.artifacts.result.ResolvedComponentResult +import org.gradle.api.artifacts.result.ResolvedDependencyResult +import org.gradle.api.provider.ListProperty +import org.gradle.api.provider.MapProperty +import org.gradle.api.provider.Property +import org.gradle.api.tasks.Input +import org.gradle.api.tasks.Internal +import org.gradle.api.tasks.TaskAction +import org.gradle.work.DisableCachingByDefault + +/** + * Fails when a pin declared in `skainet { mavenPins { } }` no longer matches what + * actually resolves in the dependency graph. + * + * Unlike Yarn, the Maven/JVM world has no committed lockfile to diff — `verifyMavenPins` + * walks the live resolved dependency graph of every `*CompileClasspath`/`*RuntimeClasspath` + * configuration across every subproject instead (metadata only, no artifact download). + * That live-resolution walk can't be expressed as configuration-cache-safe task inputs + * (a [Configuration] isn't serializable, and wrapping per-configuration resolution + * results in ordinary `Provider`s still forces resolution outside the execution lock + * configuration cache's store phase runs under), so this task opts out of it entirely. + */ +@DisableCachingByDefault(because = "Inspects live dependency-resolution results; caching costs more than it saves") +abstract class VerifyMavenPinsTask : DefaultTask() { + + init { + notCompatibleWithConfigurationCache( + "verifyMavenPins resolves dependency configurations directly against the live graph" + ) + } + + /** `"group:artifact"` -> pinned version. */ + @get:Input + abstract val pins: MapProperty + + @get:Input + abstract val failOnMissingModule: Property + + /** Every `*CompileClasspath`/`*RuntimeClasspath` configuration across every subproject. */ + @get:Internal + abstract val configurations: ListProperty + + @TaskAction + fun verify() { + val declaredPins = pins.get() + if (declaredPins.isEmpty()) { + logger.lifecycle("[maven-pins] No maven pins declared; nothing to verify.") + return + } + + val resolvedVersions = mutableMapOf() + val visited = mutableSetOf() + var checkedConfigurations = 0 + + fun walk(component: ResolvedComponentResult) { + if (!visited.add(component.id.displayName)) return + val id = component.id + if (id is ModuleComponentIdentifier) { + val coordinate = "${id.group}:${id.module}" + if (coordinate in declaredPins) { + resolvedVersions[coordinate] = id.version + } + } + component.dependencies.forEach { dependency -> + if (dependency is ResolvedDependencyResult) { + walk(dependency.selected) + } + } + } + + configurations.get().forEach { configuration -> + checkedConfigurations++ + walk(configuration.incoming.resolutionResult.root) + } + + val mismatches = declaredPins.mapNotNull { (coordinate, pinned) -> + val resolved = resolvedVersions[coordinate] + if (resolved != null && resolved != pinned) { + "$coordinate resolved to $resolved, pinned to $pinned" + } else { + null + } + } + + val missing = (declaredPins.keys - resolvedVersions.keys).sorted() + if (missing.isNotEmpty()) { + val message = buildString { + appendLine("[maven-pins] Pinned but never resolved by any subproject's dependency graph:") + missing.forEach { appendLine(" - $it") } + append( + "The pin may be stale — drop it from gradle/libs.versions.toml if the " + + "dependency that pulled it in transitively is gone for good." + ) + } + if (failOnMissingModule.get()) throw GradleException(message) else logger.warn(message) + } + + if (mismatches.isNotEmpty()) { + throw GradleException( + buildString { + appendLine("[maven-pins] Resolved dependency graph does not honour the declared maven pins:") + mismatches.sorted().forEach { appendLine(" - $it") } + } + ) + } + + logger.lifecycle( + "[maven-pins] ${declaredPins.size} pin(s) verified across $checkedConfigurations configuration(s)." + ) + } +} diff --git a/build-logic/convention/src/main/kotlin/sk/ainet/buildlogic/root/SkainetRootExtension.kt b/build-logic/convention/src/main/kotlin/sk/ainet/buildlogic/root/SkainetRootExtension.kt index ee62daec5..6e5e8bb38 100644 --- a/build-logic/convention/src/main/kotlin/sk/ainet/buildlogic/root/SkainetRootExtension.kt +++ b/build-logic/convention/src/main/kotlin/sk/ainet/buildlogic/root/SkainetRootExtension.kt @@ -2,6 +2,7 @@ package sk.ainet.buildlogic.root import org.gradle.api.Action import org.gradle.api.model.ObjectFactory +import sk.ainet.buildlogic.maven.MavenPinsExtension import sk.ainet.buildlogic.npm.NpmPinsExtension import javax.inject.Inject @@ -20,6 +21,9 @@ import javax.inject.Inject * npmPins { * pin("ws", libs.versions.npm.ws) * } + * mavenPins { + * pin("io.netty:netty-handler", libs.versions.maven.netty) + * } * } * ``` * @@ -40,6 +44,17 @@ abstract class SkainetRootExtension @Inject constructor(objects: ObjectFactory) action.execute(npmPins) } + /** + * Maven/JVM coordinates forced onto an exact version across every subproject's + * dependency graph. Populated by `sk.ainet.maven-pins`; see [MavenPinsExtension]. + */ + val mavenPins: MavenPinsExtension = objects.newInstance(MavenPinsExtension::class.java) + + /** Configures [mavenPins]. */ + fun mavenPins(action: Action) { + action.execute(mavenPins) + } + companion object { const val NAME: String = "skainet" diff --git a/build.gradle.kts b/build.gradle.kts index b7617fff1..8e4b06c39 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -13,6 +13,7 @@ plugins { alias(libs.plugins.dokka) alias(libs.plugins.skainet.docs) alias(libs.plugins.skainet.npmPins) + alias(libs.plugins.skainet.mavenPins) id("org.jetbrains.kotlinx.benchmark") version "0.4.17" apply false } diff --git a/docs/modules/ROOT/pages/contributing/build-from-source.adoc b/docs/modules/ROOT/pages/contributing/build-from-source.adoc index ebe3cd226..91371945b 100644 --- a/docs/modules/ROOT/pages/contributing/build-from-source.adoc +++ b/docs/modules/ROOT/pages/contributing/build-from-source.adoc @@ -150,7 +150,7 @@ The name appears in two places, deliberately: there is one SKaiNET namespace to | That module's own compilation — `namespace`, `androidJvmTarget`, `explicitApi`, `expectActualClasses`, `kotlinTestInCommonTest`. Created by `sk.ainet.multiplatform`. | root `build.gradle.kts` -| Conventions global to the build, which have nowhere else to live — currently `npmPins ++{++ ++}++`. Created by whichever root convention plugin is applied first; each nests its own sub-block. +| Conventions global to the build, which have nowhere else to live — currently `npmPins ++{++ ++}++` and `mavenPins ++{++ ++}++`. Created by whichever root convention plugin is applied first; each nests its own sub-block. |=== Applying `sk.ainet.multiplatform` to the *root* project is not supported and fails with a message saying so: the root project is not a library module, and its `skainet ++{++ ++}++` block means something else. @@ -228,3 +228,35 @@ grep -c '^webpack@' kotlin-js-store/yarn.lock kotlin-js-store/wasm/yarn.lock The root plugin is not optional for web modules: `sk.ainet.multiplatform` fails at configuration time if a module builds `js`/`wasmJs` while the root project does not apply `sk.ainet.npm-pins`. Without it no `resolutions` are written *and* `verifyNpmPins` does not exist to notice — a silent security regression rather than a build error. The package name is written out rather than derived from the catalog alias, so names a catalog alias cannot spell need nothing special — `pin("socket.io", …)`, `pin("++@++types/node", …)`. Versions must be exact: a range such as `++^++8.21.1` is rejected at configuration time, because `verifyNpmPins` compares the lockfile's resolved version for equality and a range could never match. + +=== Pinning Maven/JVM Dependencies + +`sk.ainet.maven-pins` is the JVM/Maven equivalent of `sk.ainet.npm-pins`: it force-pins a transitive `group:artifact` coordinate to an audited version across every subproject's dependency graph, for a high-severity CVE in a package no build script declares directly. + +Declaring a pin also takes two edits. The version goes in `++[++versions++]++` of `gradle/libs.versions.toml`: + +[source,toml] +---- +maven-netty = "4.1.136.Final" # CVE-2026-56819 +---- + +and the root build script names the coordinate: + +[source,kotlin] +---- +skainet { + mavenPins { + pin("io.netty:netty-handler", libs.versions.maven.netty) + } +} +---- + +`sk.ainet.maven-pins` forces the pin via `resolutionStrategy` across every subproject's configurations. Unlike npm pins there is one shared JVM dependency graph, not a per-target lockfile, so there is no `NpmPinTarget`-style scoping. + +`verifyMavenPins` is registered per subproject (a Gradle task may only resolve configurations belonging to its own project) and wired into that subproject's `check`. It walks each `++*++CompileClasspath`/`++*++RuntimeClasspath` configuration's live resolved dependency graph and fails if a pinned coordinate resolves to something other than its pinned version. Running `./gradlew verifyMavenPins` from the repository root still verifies the whole build — Gradle matches a bare task name against every project — and a coordinate that never resolves anywhere is reported as a warning (a pin can outlive the dependency that once pulled it in transitively), not a failure. + +==== What this cannot fix + +`resolutionStrategy` only reaches a project's own `configurations` — it cannot touch a Gradle *plugin's* own classpath (the Android Gradle Plugin, Dokka, KSP, and so on). Those classpaths are resolved before any project's `configurations` exist, through a separate mechanism entirely. A high-severity CVE in a transitive dependency of a *plugin* — not the app — cannot be closed with a `mavenPins` pin, no matter how the coordinate is spelled. + +This came up concretely in https://github.com/SKaiNET-developers/SKaiNET/issues/1046[issue #1046]: 22 high-severity Dependabot alerts (Netty, Jackson, jose4j, jdom2) turned out to be transitive to AGP's and Dokka's own plugin classpaths, not the app's dependency graph — confirmed with `./gradlew buildEnvironment`, which prints the classpath used to resolve a project's plugins. None of those packages are reachable via `mavenPins`, and none are present in anything SKaiNET publishes to Maven Central, so consumers of the library never load them. The alerts were dismissed as tolerable risk rather than pinned. For a plugin-classpath CVE that genuinely needs fixing, the options are: upgrade the plugin to a version that no longer pulls the vulnerable transitive dependency, or (confirmed to work, but not currently used anywhere in this build) add a `+buildscript { configurations.classpath { resolutionStrategy { ... } } }+` block directly to the affected script — `mavenPins` cannot express this because a build-logic-supplied plugin only runs *after* the script's own `plugins ++{++ ++}++` block has already resolved. diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index c89a8c940..5fa666ee6 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -136,5 +136,6 @@ dokka = { id = "org.jetbrains.dokka", version.ref = "dokka" } skainet-docs = { id = "sk.ainet.documentation" } skainet-multiplatform = { id = "sk.ainet.multiplatform" } skainet-npmPins = { id = "sk.ainet.npm-pins" } +skainet-mavenPins = { id = "sk.ainet.maven-pins" } kotlinx-benchmark = { id = "org.jetbrains.kotlinx.benchmark", version.ref = "kotlinxBenchmark" } shadow = { id = "com.gradleup.shadow", version = "9.6.1" }