Building a GitHub Webhook-Powered SBOM Generator with Swift Vapor


When rolling out software supply chain security practices across a team, one of the first hurdles is automation. Manually generating a Software Bill of Materials (SBOM) for every Swift project is not only tedious but also easily forgotten. While existing CI/CD platforms are powerful, they often feel too heavyweight for a scenario where you just want to run a lightweight, specific task after each code commit. Our goal is to build a dedicated, low-footprint service that does one thing and does it well: listen for GitHub push events, automatically generate an SBOM for the Swift project, and report the result back as a status on the corresponding commit.

We decided to build this service with Swift and Vapor. This wasn’t just a technical preference but a strategic choice: building tools for an ecosystem with the ecosystem’s own language maximizes team familiarity and reduces maintenance overhead.

Here’s the planned workflow:

sequenceDiagram
    participant Dev as Developer
    participant GH as GitHub Repository
    participant App as Swift Vapor Service
    participant Runner as Background Task

    Dev->>+GH: git push
    GH-->>+App: Sends Webhook (Push Event)
    App->>App: 1. Verify Webhook Signature
    App->>+Runner: 2. Dispatch Job (repo_url, commit_sha)
    App-->>-GH: Responds HTTP 202 Accepted
    Runner->>GH: 3. Set Commit Status: "pending"
    Runner->>Runner: 4. git clone repository
    Runner->>Runner: 5. swift package experimental-generate-sbom
    alt SBOM Generation Succeeds
        Runner->>GH: 6. Set Commit Status: "success"
    else SBOM Generation Fails
        Runner->>GH: 7. Set Commit Status: "failure"
    end
    Runner->>Runner: 8. Cleanup workspace

Initial Design and Tech Stack

  1. Framework Choice: Vapor is the most mature choice in the Swift backend ecosystem. Its strength lies in its deep integration with Swift’s structured concurrency (Async/Await), which is crucial for handling network requests and background tasks.
  2. Webhook Handling: The service needs a public endpoint to receive POST requests from GitHub. Security is key here: we must verify the X-Hub-Signature-256 header to ensure the request genuinely comes from GitHub and not a malicious actor.
  3. Background Tasks: Webhook responses must be instantaneous. All time-consuming operations, like cloning a repository and executing commands, must be performed asynchronously in the background. A common mistake is to run these tasks directly in the HTTP request handler, which leads to GitHub webhook timeouts and retries, and can eventually get your hook disabled. We’ll use Task.detached to launch a detached task for processing, which is sufficient for a lightweight service. In a real-world project, this should be replaced with a more robust queueing system, like Vapor Queues + Redis.
  4. Interacting with the Shell: We need to invoke git and swift commands. Swift’s Foundation.Process is the standard library’s solution. The pitfall here is correctly handling the process’s standard input/output, error streams, and waiting for termination. Any oversight can lead to zombie processes or resource leaks.
  5. GitHub API Communication: After the task completes, the status (pending, success, failure) needs to be updated back on GitHub. This requires using GitHub’s REST API. We’ll use AsyncHTTPClient to make these API requests because it integrates well with Vapor’s event loop and Swift’s concurrency model.
  6. Configuration Management: Sensitive information, like the GitHub Personal Access Token and Webhook Secret, must never be hardcoded. Vapor’s Environment API allows us to securely load these configurations from environment variables or a .env file.

Step-by-Step Implementation

1. Project Setup and Configuration

First, initialize a new Vapor project.

vapor new SbomGenerator -n
cd SbomGenerator

In Package.swift, we need to add AsyncHTTPClient as a dependency.

// swift-tools-version:5.8
import PackageDescription

let package = Package(
    name: "SbomGenerator",
    platforms: [
       .macOS(.v12)
    ],
    dependencies: [
        // 💧 A server-side Swift web framework.
        .package(url: "https://github.com/vapor/vapor.git", from: "4.77.1"),
        // 🚀 An HTTP client library for Swift.
        .package(url: "https://github.com/swift-server/async-http-client.git", from: "1.19.0")
    ],
    targets: [
        .executableTarget(
            name: "App",
            dependencies: [
                .product(name: "Vapor", package: "vapor"),
                .product(name: "AsyncHTTPClient", package: "async-http-client")
            ]
        ),
        .testTarget(name: "AppTests", dependencies: [
            .target(name: "App"),
            .product(name: "XCTVapor", package: "vapor"),
        ])
    ]
)

Next, let’s define our configuration. Create a .env file to store sensitive information.

# .env
# Secret used to verify GitHub webhook payloads
GITHUB_WEBHOOK_SECRET="your_strong_secret_here"

# GitHub Personal Access Token with repo:status scope
GITHUB_ACCESS_TOKEN="ghp_your_token_here"

# The directory where repos will be cloned
WORKSPACE_DIR="/tmp/sbom_generator_ws"

To use these settings conveniently in our app, we’ll create a struct.

// Sources/App/Configuration/AppSettings.swift
import Vapor

struct AppSettings {
    let githubWebhookSecret: String
    let githubAccessToken: String
    let workspaceDirectory: String

    init(from environment: Environment) throws {
        guard let secret = environment.get("GITHUB_WEBHOOK_SECRET") else {
            throw AppError.missingConfiguration("GITHUB_WEBHOOK_SECRET")
        }
        self.githubWebhookSecret = secret

        guard let token = environment.get("GITHUB_ACCESS_TOKEN") else {
            throw AppError.missingConfiguration("GITHUB_ACCESS_TOKEN")
        }
        self.githubAccessToken = token
        
        // Workspace directory is optional, provide a default value.
        self.workspaceDirectory = environment.get("WORKSPACE_DIR") ?? "/tmp/sbom_generator_ws"
    }
}

// Custom error for better diagnostics
enum AppError: Error, DebuggableError {
    case missingConfiguration(String)
    var identifier: String { "AppError" }
    var reason: String {
        switch self {
        case .missingConfiguration(let key):
            return "Missing required environment variable: \(key)"
        }
    }
}

2. Webhook Endpoint and Signature Validation

This is the service’s first line of defense. We need a route to receive GitHub’s POST requests and rigorously validate their signatures.

// Sources/App/Controllers/GitHubWebhookController.swift
import Vapor
import Crypto

final class GitHubWebhookController: RouteCollection {
    func boot(routes: RoutesBuilder) throws {
        let githubGroup = routes.grouped("api", "github")
        githubGroup.post("webhook", use: handleWebhook)
    }

    private func handleWebhook(req: Request) async throws -> HTTPStatus {
        let settings = try AppSettings(from: req.application.environment)

        // 1. Verify the event type. We only care about 'push'.
        guard req.headers.first(name: "X-GitHub-Event") == "push" else {
            req.logger.info("Ignoring non-push event")
            return .ok
        }

        // 2. Verify the signature. This is CRITICAL for security.
        guard let signature = req.headers.first(name: "X-Hub-Signature-256"),
              let bodyData = req.body.data else {
            throw Abort(.badRequest, reason: "Missing signature or body")
        }

        try verifySignature(payload: bodyData, signature: signature, secret: settings.githubWebhookSecret, logger: req.logger)

        // 3. Decode the payload.
        let pushEvent = try req.content.decode(GitHubPushEvent.self)
        
        // 4. We only process pushes to branches, not tags. `ref` looks like "refs/heads/main".
        guard pushEvent.ref.hasPrefix("refs/heads/") else {
            req.logger.info("Ignoring push to tag or other ref: \(pushEvent.ref)")
            return .ok
        }

        // 5. Dispatch the job to a background task.
        // DO NOT block the request handler with long-running tasks.
        let job = SBOMGenerationJob(
            event: pushEvent,
            settings: settings,
            logger: req.application.logger,
            httpClient: req.client
        )
        
        Task.detached {
            await job.run()
        }

        // 6. Immediately return a 202 Accepted to GitHub.
        return .accepted
    }

    private func verifySignature(payload: Data, signature: String, secret: String, logger: Logger) throws {
        // The signature from GitHub is in the format "sha256=xxxxxxxx"
        let signatureParts = signature.split(separator: "=", maxSplits: 1)
        guard signatureParts.count == 2, signatureParts[0] == "sha256" else {
            throw Abort(.badRequest, reason: "Invalid signature format")
        }

        let expectedHex = String(signatureParts[1])
        var hmac = HMAC<SHA256>(key: SymmetricKey(data: secret.data(using: .utf8)!))
        hmac.update(data: payload)
        let calculatedHex = hmac.finalize().map { String(format: "%02x", $0) }.joined()
        
        guard calculatedHex == expectedHex else {
            logger.error("Signature mismatch. Calculated: \(calculatedHex), Expected: \(expectedHex)")
            throw Abort(.unauthorized, reason: "Webhook signature mismatch")
        }
        
        logger.info("Webhook signature verified successfully.")
    }
}

// We only need a subset of the fields from the huge push event payload.
// See: https://docs.github.com/en/webhooks/webhook-events-and-payloads#push
struct GitHubPushEvent: Content {
    let ref: String
    let after: String // The commit SHA after the push
    let repository: Repository

    struct Repository: Content {
        let name: String
        let fullName: String
        let cloneUrl: String

        enum CodingKeys: String, CodingKey {
            case name
            case fullName = "full_name"
            case cloneUrl = "clone_url"
        }
    }
}

3. Core Logic: The SBOM Generation Job

This is the component that does the actual work. It should be designed as a standalone, testable unit.

// Sources/App/Jobs/SBOMGenerationJob.swift
import Vapor
import AsyncHTTPClient

struct SBOMGenerationJob {
    let event: GitHubPushEvent
    let settings: AppSettings
    let logger: Logger
    let httpClient: Client

    func run() async {
        let repoFullName = event.repository.fullName
        let commitSHA = event.after
        let cloneURL = event.repository.cloneUrl
        
        let workspacePath = "\(settings.workspaceDirectory)/\(repoFullName)/\(commitSHA)"
        
        logger.info("Starting SBOM generation for \(repoFullName)@\(commitSHA)")

        do {
            // 1. Set commit status to "pending"
            try await updateCommitStatus(state: .pending, description: "SBOM generation in progress...")
            
            // 2. Prepare workspace and clone
            try await prepareWorkspace(path: workspacePath)
            try await cloneRepository(url: cloneURL, to: workspacePath)

            // 3. Generate SBOM
            let sbomPath = "\(workspacePath)/sbom.json"
            try await generateSBOM(in: workspacePath, outputPath: sbomPath)

            // 4. On success, update status
            logger.info("Successfully generated SBOM for \(repoFullName)@\(commitSHA)")
            try await updateCommitStatus(state: .success, description: "SBOM generated successfully.")

        } catch {
            // 5. On failure, log the error and update status
            logger.error("SBOM generation failed for \(repoFullName)@\(commitSHA): \(error)")
            try? await updateCommitStatus(state: .failure, description: "Failed to generate SBOM. Check service logs.")
        }
        
        // 6. Cleanup
        await cleanupWorkspace(path: workspacePath)
    }

    // ... helper methods below ...
}

4. Interacting with the Shell and Filesystem

This is the most error-prone part. We need robust helper functions to execute external commands and manage files. A common mistake is to overlook the asynchronous nature and error-handling details of the Process API.

// Extensions for SBOMGenerationJob

private extension SBOMGenerationJob {
    func prepareWorkspace(path: String) async throws {
        logger.info("Preparing workspace at \(path)")
        // Use FileManager to create the directory recursively.
        // This is safer than shelling out to `mkdir -p`.
        try FileManager.default.createDirectory(atPath: path, withIntermediateDirectories: true)
    }

    func cleanupWorkspace(path: String) async {
        logger.info("Cleaning up workspace at \(path)")
        do {
            try FileManager.default.removeItem(atPath: path)
        } catch {
            logger.error("Failed to clean up workspace at \(path): \(error)")
        }
    }
    
    // A robust async process execution helper
    @discardableResult
    func runShellCommand(_ command: String, in workingDirectory: String) async throws -> String {
        let process = Process()
        process.executableURL = URL(fileURLWithPath: "/bin/sh")
        process.arguments = ["-c", command]
        process.currentDirectoryURL = URL(fileURLWithPath: workingDirectory)

        let outputPipe = Pipe()
        let errorPipe = Pipe()
        process.standardOutput = outputPipe
        process.standardError = errorPipe

        try process.run()
        
        // It's crucial to read the data asynchronously to avoid deadlocks
        // if the pipes' buffers fill up.
        let outputData = try await outputPipe.fileHandleForReading.readToEnd()
        let errorData = try await errorPipe.fileHandleForReading.readToEnd()

        process.waitUntilExit()

        let output = String(data: outputData ?? Data(), encoding: .utf8) ?? ""
        let errorOutput = String(data: errorData ?? Data(), encoding: .utf8) ?? ""
        
        guard process.terminationStatus == 0 else {
            logger.error("Shell command failed: `\(command)`. Status: \(process.terminationStatus). Error: \(errorOutput)")
            throw ShellError.commandFailed(command: command, exitCode: process.terminationStatus, stderr: errorOutput)
        }
        
        logger.debug("Shell command `\(command)` succeeded. Output: \(output)")
        return output
    }

    func cloneRepository(url: String, to path: String) async throws {
        // Inject the token into the clone URL for private repositories.
        // https://<token>@github.com/user/repo.git
        let authenticatedUrl = url.replacingOccurrences(of: "https://", with: "https://\(settings.githubAccessToken)@")
        
        // Clone into the current directory (`.`) which is our prepared workspace
        let command = "git clone --depth 1 \(authenticatedUrl) ."
        try await runShellCommand(command, in: path)
    }
    
    func generateSBOM(in directory: String, outputPath: String) async throws {
        // As of Swift 5.8+, this is an experimental feature.
        // It generates SBOM in CycloneDX JSON format.
        let command = "swift package experimental-generate-sbom --output \(outputPath)"
        try await runShellCommand(command, in: directory)
    }
    
    enum ShellError: Error, LocalizedError {
        case commandFailed(command: String, exitCode: Int32, stderr: String)
        var errorDescription: String? {
            switch self {
            case .commandFailed(let command, let exitCode, let stderr):
                return "Command '\(command)' failed with exit code \(exitCode). Stderr: \(stderr)"
            }
        }
    }
}

5. Communicating with the GitHub API

The final step is to report the results back. We need to build a POST request to GitHub’s Commit Statuses API.

// More extensions for SBOMGenerationJob

private extension SBOMGenerationJob {
    enum CommitState: String {
        case error
        case failure
        case pending
        case success
    }

    func updateCommitStatus(state: CommitState, description: String) async throws {
        let repoFullName = event.repository.fullName
        let commitSHA = event.after
        
        let url = "https://api.github.com/repos/\(repoFullName)/statuses/\(commitSHA)"
        
        struct StatusPayload: Codable {
            let state: String
            let description: String
            let context: String = "ci/sbom-generator"
        }
        
        let payload = StatusPayload(state: state.rawValue, description: description)
        
        var request = ClientRequest(method: .POST, url: URI(string: url))
        request.headers.add(name: "Accept", value: "application/vnd.github.v3+json")
        request.headers.add(name: "Authorization", value: "token \(settings.githubAccessToken)")
        request.headers.add(name: "User-Agent", value: "Swift-SBOM-Generator")
        
        try request.content.encode(payload, as: .json)
        
        let response = try await httpClient.execute(request: request).get()

        guard (200...299).contains(response.status.code) else {
            let body = response.body.map { String(buffer: $0) } ?? "n/a"
            logger.error("Failed to update GitHub commit status. Status: \(response.status), Body: \(body)")
            throw GitHubAPIError.failedToUpdateStatus(reason: body)
        }
        
        logger.info("Successfully updated commit status for \(commitSHA) to \(state.rawValue)")
    }

    enum GitHubAPIError: Error, LocalizedError {
        case failedToUpdateStatus(reason: String)
        var errorDescription: String? {
            switch self {
            case .failedToUpdateStatus(let reason):
                return "Failed to update GitHub status. Reason: \(reason)"
            }
        }
    }
}

Finally, register our controller in routes.swift.

// Sources/App/routes.swift
import Vapor

func routes(_ app: Application) throws {
    try app.register(collection: GitHubWebhookController())
}

Final Result and Limitations

At this point, we have a fully functional, lightweight, self-hosted SBOM generation service. Written entirely in Swift, it can be run as a standalone binary or in a Docker container. By configuring a webhook in a GitHub repository to point to this service’s /api/github/webhook endpoint and setting up the secret, the entire process becomes fully automated.

The advantage of this solution lies in its focus and lightweight nature. It doesn’t depend on a massive CI system, has minimal resource consumption, and is relatively low-cost to maintain for Swift developers.

However, as a production-grade tool, this implementation still has its limitations and areas for iteration:

  1. Task Queue and Concurrency Control: The current approach using Task.detached is very direct, but it offers no persistence, retry mechanisms, or concurrency control. A large number of commits pushed in a short time could trigger a flood of tasks, exhausting server resources. In a real-world project, you should introduce a Redis or database-backed queueing system like Vapor Queues. This would serialize jobs to be handled by a finite number of workers, enabling load leveling and failure retries.
  2. Workspace Management: The current implementation creates a unique directory for each commit, which avoids direct conflicts. However, if the service exits unexpectedly, these temporary directories might not be cleaned up, leading to disk space leaks. A cleanup routine on startup is needed, or a more sophisticated naming and locking mechanism to manage concurrent git operations.
  3. Logging and Observability: Although we’re using Vapor’s Logger, in a production environment, logs should be structured (e.g., in JSON format) and shipped to a centralized logging platform (like the ELK Stack or Datadog). Furthermore, you should add monitoring for key metrics (like job processing time, success/failure rates) and expose them via a tool like Prometheus.
  4. Security Hardening: While we’ve verified the webhook signature, the service’s own network exposure needs to be protected. It should run behind a firewall, allowing traffic only from GitHub’s IP ranges. Secrets stored in the .env file should be managed by a dedicated secrets management system like Vault.
  5. Extensibility: The service currently only handles push events. It could easily be extended to support pull_request events, posting the SBOM as a comment in the PR or uploading it as a build artifact. It could also be designed more generically to execute arbitrary scripts via configuration, rather than just SBOM generation.

  TOC