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
- 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.
- 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-256header to ensure the request genuinely comes from GitHub and not a malicious actor. - 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.detachedto 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. - Interacting with the Shell: We need to invoke
gitandswiftcommands. Swift’sFoundation.Processis 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. - 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
AsyncHTTPClientto make these API requests because it integrates well with Vapor’s event loop and Swift’s concurrency model. - Configuration Management: Sensitive information, like the GitHub Personal Access Token and Webhook Secret, must never be hardcoded. Vapor’s
EnvironmentAPI allows us to securely load these configurations from environment variables or a.envfile.
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:
- Task Queue and Concurrency Control: The current approach using
Task.detachedis 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 likeVapor Queues. This would serialize jobs to be handled by a finite number of workers, enabling load leveling and failure retries. - 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.
- 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. - 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
.envfile should be managed by a dedicated secrets management system like Vault. - Extensibility: The service currently only handles
pushevents. It could easily be extended to supportpull_requestevents, 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.