Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
19 changes: 19 additions & 0 deletions SECURITY.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
4 changes: 4 additions & 0 deletions build-logic/convention/build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -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"
}
}
}
Original file line number Diff line number Diff line change
@@ -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<String, String>

/**
* 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<Boolean>

private val declared = mutableSetOf<String>()

/**
* Pins [coordinate] (`"group:artifact"`, e.g. `"io.netty:netty-handler"`) to a
* version held in the version catalog.
*/
fun pin(coordinate: String, version: Provider<String>) {
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('^', '~', '>', '<', '=', '*', '+', '|', ' ')
}
}
Original file line number Diff line number Diff line change
@@ -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<Project> {

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<Map<String, String>> = 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) }
}
}
}
}
Original file line number Diff line number Diff line change
@@ -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<String, String>

@get:Input
abstract val failOnMissingModule: Property<Boolean>

/** Every `*CompileClasspath`/`*RuntimeClasspath` configuration across every subproject. */
@get:Internal
abstract val configurations: ListProperty<Configuration>

@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<String, String>()
val visited = mutableSetOf<String>()
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)."
)
}
}
Loading
Loading