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
22 changes: 21 additions & 1 deletion src/main/kotlin/net/leanix/githubagent/client/GitHubClient.kt
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
package net.leanix.githubagent.client

import feign.Response
import net.leanix.githubagent.config.FeignClientConfig
import net.leanix.githubagent.dto.ArtifactsListResponse
import net.leanix.githubagent.dto.GitHubAppResponse
import net.leanix.githubagent.dto.GitHubSearchResponse
import net.leanix.githubagent.dto.Installation
Expand All @@ -11,6 +13,7 @@ import org.springframework.cloud.openfeign.FeignClient
import org.springframework.web.bind.annotation.GetMapping
import org.springframework.web.bind.annotation.PathVariable
import org.springframework.web.bind.annotation.PostMapping
import org.springframework.web.bind.annotation.RequestBody
import org.springframework.web.bind.annotation.RequestHeader
import org.springframework.web.bind.annotation.RequestParam

Expand Down Expand Up @@ -43,7 +46,8 @@ interface GitHubClient {
@PostMapping("/api/v3/app/installations/{installationId}/access_tokens")
fun createInstallationToken(
@PathVariable("installationId") installationId: Long,
@RequestHeader("Authorization") jwt: String
@RequestHeader("Authorization") jwt: String,
@RequestBody emptyBody: String = ""
): InstallationTokenResponse

@GetMapping("/api/v3/organizations")
Expand All @@ -64,4 +68,20 @@ interface GitHubClient {
@RequestHeader("Authorization") token: String,
@RequestParam("q") query: String,
): GitHubSearchResponse

@GetMapping("/api/v3/repos/{owner}/{repo}/actions/runs/{runId}/artifacts")
fun getRunArtifacts(
@PathVariable("owner") owner: String,
@PathVariable("repo") repo: String,
@PathVariable("runId") runId: Long,
@RequestHeader("Authorization") token: String
): ArtifactsListResponse

@GetMapping("/api/v3/repos/{owner}/{repo}/actions/artifacts/{artifactId}/zip")
fun downloadArtifact(
@PathVariable("owner") owner: String,
@PathVariable("repo") repo: String,
@PathVariable("artifactId") artifactId: Long,
@RequestHeader("Authorization") token: String
): Response
}
20 changes: 20 additions & 0 deletions src/main/kotlin/net/leanix/githubagent/dto/ArtifactsDto.kt
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
package net.leanix.githubagent.dto

import com.fasterxml.jackson.annotation.JsonIgnoreProperties
import com.fasterxml.jackson.annotation.JsonProperty

@JsonIgnoreProperties(ignoreUnknown = true)
data class ArtifactsListResponse(
@JsonProperty("total_count")
val totalCount: Int,
val artifacts: List<Artifact>
)

@JsonIgnoreProperties(ignoreUnknown = true)
data class Artifact(
val id: Long,
val name: String,
val url: String,
@JsonProperty("archive_download_url")
val archiveDownloadUrl: String,
)
34 changes: 34 additions & 0 deletions src/main/kotlin/net/leanix/githubagent/dto/SbomConfig.kt
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
package net.leanix.githubagent.dto

data class SbomConfig(
val source: GitHubSbomSource = GitHubSbomSource.GITHUB_ARTIFACT,
val namingConventions: String = "leanix-serviceName-sbom",
val defaultBranch: String = "main",
) {
fun isFileNameValid(fileName: String, branchName: String): Boolean {
return fileName == generateExpectedFileName(fileName) && branchName == defaultBranch
}
fun extractFactSheetName(fileName: String): String {
val parts = fileName.split("-")
return parts.drop(1).dropLast(1).joinToString("-")
}

private fun generateExpectedFileName(fileName: String): String {
val parts = fileName.split("-")
if (parts.size < 3) return ""

val serviceName = parts.drop(1).dropLast(1).joinToString("-")

val conventionParts = namingConventions.split("-")
if (conventionParts.size < 3) return ""

val orgPrefix = conventionParts.first()
val sbomSuffix = conventionParts.last()

return "$orgPrefix-$serviceName-$sbomSuffix"
}
}

enum class GitHubSbomSource {
GITHUB_ARTIFACT,
}
8 changes: 8 additions & 0 deletions src/main/kotlin/net/leanix/githubagent/dto/SbomEventDTO.kt
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
package net.leanix.githubagent.dto

data class SbomEventDTO(
val repositoryName: String,
val factSheetName: String,
val sbomFileName: String,
val sbomFileContent: String,
)
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
package net.leanix.githubagent.dto

import com.fasterxml.jackson.annotation.JsonIgnoreProperties
import com.fasterxml.jackson.annotation.JsonProperty

@JsonIgnoreProperties(ignoreUnknown = true)
data class WorkflowRunEventDto(
val action: String,
@JsonProperty("workflow_run") val workflowRun: WorkflowRun,
val repository: WorkflowRepository,
val installation: InstallationDTO
) {
fun isCompleted() = (
action == "completed" && workflowRun.conclusion == "success"
)
}

@JsonIgnoreProperties(ignoreUnknown = true)
data class WorkflowRun(
val id: Long,
@JsonProperty("head_branch") val headBranch: String?,
@JsonProperty("url") val runUrl: String?,
@JsonProperty("run_attempt") val runAttempt: Int?,
@JsonProperty("node_id") val nodeId: String,
@JsonProperty("head_sha") val headSha: String?,
val status: String,
val conclusion: String?,
@JsonProperty("created_at") val createdAt: String?,
@JsonProperty("run_started_at") val startedAt: String?,
val name: String?
)

@JsonIgnoreProperties(ignoreUnknown = true)
data class WorkflowRepository(
val id: Long,
@JsonProperty("node_id") val nodeId: String,
val name: String,
@JsonProperty("full_name") val fullName: String,
val private: Boolean,
@JsonProperty("html_url") val htmlUrl: String?,
@JsonProperty("default_branch") val defaultBranch: String?,
val owner: RepositoryOwner
)

@JsonIgnoreProperties(ignoreUnknown = true)
data class RepositoryOwner(
val login: String
)

@JsonIgnoreProperties(ignoreUnknown = true)
data class InstallationDTO(
val id: Int
)
Original file line number Diff line number Diff line change
Expand Up @@ -121,4 +121,14 @@ class GitHubAuthenticationService(
.replace(System.lineSeparator().toRegex(), "")
.replace(pemSuffix, "")
}

fun getInstallationToken(installationId: Int): String {
var installationToken = cachingService.get("installationToken:$installationId")?.toString()
if (installationToken == null) {
refreshTokens()
installationToken = cachingService.get("installationToken:$installationId")?.toString()
require(installationToken != null) { "Installation token not found/ expired" }
}
return installationToken
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ import net.leanix.githubagent.dto.PushEventPayload
import net.leanix.githubagent.exceptions.JwtTokenNotFound
import net.leanix.githubagent.shared.INSTALLATION_LABEL
import net.leanix.githubagent.shared.MANIFEST_FILE_NAME
import net.leanix.githubagent.shared.WORKFLOW_RUN_EVENT
import net.leanix.githubagent.shared.fileNameMatchRegex
import net.leanix.githubagent.shared.generateFullPath
import org.slf4j.LoggerFactory
Expand All @@ -27,7 +28,8 @@ class WebhookEventService(
private val syncLogService: SyncLogService,
@Value("\${webhookEventService.waitingTime}") private val waitingTime: Long,
private val gitHubClient: GitHubClient,
private val gitHubEnterpriseService: GitHubEnterpriseService
private val gitHubEnterpriseService: GitHubEnterpriseService,
private val workflowRunService: WorkflowRunService
) {

private val logger = LoggerFactory.getLogger(WebhookEventService::class.java)
Expand All @@ -37,6 +39,7 @@ class WebhookEventService(
when (eventType.uppercase()) {
"PUSH" -> handlePushEvent(payload)
"INSTALLATION" -> handleInstallationEvent(payload)
WORKFLOW_RUN_EVENT -> workflowRunService.consumeWebhookPayload(payload)
else -> {
logger.info("Sending event of type: $eventType")
webSocketService.sendMessage("/events/other", payload)
Expand All @@ -52,7 +55,7 @@ class WebhookEventService(
val owner = pushEventPayload.repository.owner.name
val defaultBranch = pushEventPayload.repository.defaultBranch

val installationToken = getInstallationToken(pushEventPayload.installation.id)
val installationToken = gitHubAuthenticationService.getInstallationToken(pushEventPayload.installation.id)

if (headCommit != null && pushEventPayload.ref == "refs/heads/$defaultBranch") {
handleManifestFileChanges(
Expand Down Expand Up @@ -145,16 +148,6 @@ class WebhookEventService(

private fun isLeanixManifestFile(it: String) = it == MANIFEST_FILE_NAME || it.endsWith("/$MANIFEST_FILE_NAME")

private fun getInstallationToken(installationId: Int): String {
var installationToken = cachingService.get("installationToken:$installationId")?.toString()
if (installationToken == null) {
gitHubAuthenticationService.refreshTokens()
installationToken = cachingService.get("installationToken:$installationId")?.toString()
require(installationToken != null) { "Installation token not found/ expired" }
}
return installationToken
}

@SuppressWarnings("LongParameterList")
private fun handleAddedOrModifiedManifestFile(
repositoryFullName: String,
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,96 @@
package net.leanix.githubagent.services

import com.fasterxml.jackson.module.kotlin.jacksonObjectMapper
import com.fasterxml.jackson.module.kotlin.readValue
import net.leanix.githubagent.client.GitHubClient
import net.leanix.githubagent.dto.Artifact
import net.leanix.githubagent.dto.SbomConfig
import net.leanix.githubagent.dto.SbomEventDTO
import net.leanix.githubagent.dto.WorkflowRunEventDto
import org.slf4j.LoggerFactory
import org.springframework.stereotype.Service
import java.util.*

@Service
class WorkflowRunService(
private val gitHubAuthenticationService: GitHubAuthenticationService,
private val webSocketService: WebSocketService,
private val gitHubClient: GitHubClient,
) {

private val logger = LoggerFactory.getLogger(WorkflowRunService::class.java)
private val objectMapper = jacksonObjectMapper()
private val sbomConfig = SbomConfig()

fun consumeWebhookPayload(payload: String) {
runCatching {
val event = parseEvent(payload) ?: return
if (!event.isCompleted()) return

logger.info("Detected workflow event that successfully completed")
val installationToken = "Bearer ${gitHubAuthenticationService.getInstallationToken(event.installation.id)}"

getValidArtifacts(event, installationToken)
.takeIf { it.isNotEmpty() }
?.let { artifacts ->
logger.info("Found ${artifacts.size} artifact(s).")
fetchAndProcessArtifacts(artifacts, event, installationToken)
} ?: logger.info("No artifacts found for the event")
}.onFailure {
logger.error("Failed to consume workflow webhook", it)
}
}
private fun parseEvent(payload: String): WorkflowRunEventDto? {
return try {
objectMapper.readValue(payload)
} catch (e: Exception) {
logger.error("Failed to parse webhook payload", e)
null
}
}
private fun getValidArtifacts(event: WorkflowRunEventDto, token: String): List<Artifact> {
val owner = event.repository.owner.login
val repo = event.repository.name
val runId = event.workflowRun.id

return gitHubClient.getRunArtifacts(owner, repo, runId, token)
.artifacts
.filter { sbomConfig.isFileNameValid(it.name, event.workflowRun.headBranch ?: "") }
}

private fun fetchAndProcessArtifacts(
artifacts: List<Artifact>,
event: WorkflowRunEventDto,
installationToken: String
) {
val owner = event.repository.owner.login
val repo = event.repository.name

artifacts.forEach { artifact ->
logger.info("Processing artifact: ${artifact.name}")
downloadAndSendArtifact(owner, repo, artifact, installationToken)
}
}

private fun downloadAndSendArtifact(owner: String, repo: String, artifact: Artifact, token: String) = runCatching {
gitHubClient.downloadArtifact(owner, repo, artifact.id, token).body()?.use { body ->
val sbomContent = Base64.getEncoder().encodeToString(body.asInputStream().readAllBytes())
sendSbomEvent(repo, artifact.name, sbomContent)
} ?: logger.error("Failed to download artifact: ${artifact.name}")
}.onFailure {
logger.error("Error processing artifact: ${artifact.name}", it)
}

private fun sendSbomEvent(repo: String, artifactName: String, sbomContent: String) {
logger.info("Sending sbom file: $repo - $artifactName")
webSocketService.sendMessage(
"/events/sbom",
SbomEventDTO(
repositoryName = repo,
factSheetName = sbomConfig.extractFactSheetName(artifactName),
sbomFileName = artifactName,
sbomFileContent = sbomContent
)
)
}
}
2 changes: 2 additions & 0 deletions src/main/kotlin/net/leanix/githubagent/shared/Constants.kt
Original file line number Diff line number Diff line change
Expand Up @@ -4,12 +4,14 @@ const val TOPIC_PREFIX = "/app/ghe/"
const val APP_NAME_TOPIC = "appName"
const val LOGS_TOPIC = "logs"
const val MANIFEST_FILE_NAME = "leanix.yaml"
const val WORKFLOW_RUN_EVENT = "WORKFLOW_RUN"

val SUPPORTED_EVENT_TYPES = listOf(
"REPOSITORY",
"PUSH",
"ORGANIZATION",
"INSTALLATION",
WORKFLOW_RUN_EVENT
)

val fileNameMatchRegex = Regex("/?$MANIFEST_FILE_NAME\$", RegexOption.IGNORE_CASE)
Expand Down
Loading
Loading