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
18 changes: 18 additions & 0 deletions scripts/verify-loop.sh
Original file line number Diff line number Diff line change
Expand Up @@ -80,6 +80,24 @@ for format in json yaml; do
test -f "$PROJECT_FORMAT/build/Format-$format-1.0.pkg"
done

PROVENANCE="$WORK/Provenance"
run "$BIN" --create "$PROVENANCE"
mkdir -p "$PROVENANCE/payload/usr/local/bin"
printf '%s\n' '#!/bin/sh' 'exit 0' > "$PROVENANCE/payload/usr/local/bin/tool"
run "$BIN" --provenance "$PROVENANCE"
test -f "$PROVENANCE/build/Provenance-1.0.pkg.provenance.json"
python3 - "$PROVENANCE/build/Provenance-1.0.pkg.provenance.json" "$PROVENANCE/build/Provenance-1.0.pkg" <<'PY'
import hashlib, json, sys
prov = json.load(open(sys.argv[1]))
for key in ("tool", "tool_version", "built_at", "name", "version", "identifier", "pkg_path", "sha256", "input_digest"):
assert key in prov, f"provenance missing key: {key}"
assert prov["tool"] == "swiftpkg"
digest = hashlib.sha256(open(sys.argv[2], "rb").read()).hexdigest()
assert prov["sha256"] == digest, f'sha256 mismatch: {prov["sha256"]} != {digest}'
assert len(prov["input_digest"]) == 64
print("provenance OK")
PY

VERIFY="$WORK/Verify"
run "$BIN" --create "$VERIFY"
mkdir -p "$VERIFY/payload/usr/local/bin"
Expand Down
5 changes: 5 additions & 0 deletions swiftpkg/PackageBuildOptions.swift
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,9 @@ public struct PackageBuildOptions: Sendable {
public let skipsSigning: Bool
public let skipsNotarization: Bool
public let skipsStapling: Bool
/// Write a `<pkg>.provenance.json` sidecar recording tool version, build
/// time, git commit/remote, an input digest, and the package hash.
public let writesProvenance: Bool
/// After building, assert the package matches what build-info declared
/// (signature present when signing was requested, Gatekeeper-accepted when
/// notarized). Fails the build on mismatch.
Expand All @@ -24,6 +27,7 @@ public struct PackageBuildOptions: Sendable {
skipsSigning: Bool = false,
skipsNotarization: Bool = false,
skipsStapling: Bool = false,
writesProvenance: Bool = false,
verifies: Bool = false,
versionOverride: String? = nil,
outputDirectory: URL? = nil
Expand All @@ -34,6 +38,7 @@ public struct PackageBuildOptions: Sendable {
self.skipsSigning = skipsSigning
self.skipsNotarization = skipsNotarization
self.skipsStapling = skipsStapling
self.writesProvenance = writesProvenance
self.verifies = verifies
self.versionOverride = versionOverride
self.outputDirectory = outputDirectory
Expand Down
7 changes: 7 additions & 0 deletions swiftpkg/PackageBuilder.swift
Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +54,13 @@ public struct PackageBuildCoordinator: @unchecked Sendable {
try PackageVerifier(runner: runner, console: console)
.verify(package: output, expectedIdentifier: packageConfiguration.identifier, expectedVersion: packageConfiguration.version, signed: signed, notarized: notarized)
}
if configuration.writesProvenance {
let provenance = try ProvenanceBuilder(runner: runner, fileManager: fileManager)
.build(configuration: packageConfiguration, output: output, project: project)
let sidecar = URL(fileURLWithPath: output.path + ".provenance.json")
try Data(provenance.jsonString().utf8).write(to: sidecar, options: .atomic)
console.display("Wrote provenance to \(sidecar.path)")
}
return BuildResult(
name: packageConfiguration.name,
version: packageConfiguration.version,
Expand Down
132 changes: 132 additions & 0 deletions swiftpkg/Provenance.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,132 @@
import CryptoKit
import Foundation

/// Build attestation written next to the package as `<pkg>.provenance.json`.
public struct Provenance: Codable, Sendable, Equatable {
public let tool: String
public let toolVersion: String
public let builtAt: String
public let name: String
public let version: String
public let identifier: String
public let pkgPath: String
public let sha256: String
public let inputDigest: String
public let gitCommit: String?
public let gitRemote: String?

enum CodingKeys: String, CodingKey {
case tool
case toolVersion = "tool_version"
case builtAt = "built_at"
case name, version, identifier
case pkgPath = "pkg_path"
case sha256
case inputDigest = "input_digest"
case gitCommit = "git_commit"
case gitRemote = "git_remote"
}

public func jsonString() throws -> String {
let encoder = JSONEncoder()
encoder.outputFormatting = [.prettyPrinted, .sortedKeys, .withoutEscapingSlashes]
return String(decoding: try encoder.encode(self), as: UTF8.self)
}
}

/// Assembles a `Provenance` from the project, its inputs, and git metadata.
public struct ProvenanceBuilder {
private let runner: any ProcessRunning
private let fileManager: FileManager

public init(runner: any ProcessRunning, fileManager: FileManager = .default) {
self.runner = runner
self.fileManager = fileManager
}

public func build(configuration: PackageConfiguration, output: URL, project: URL, now: Date = Date()) throws -> Provenance {
let formatter = ISO8601DateFormatter()
formatter.formatOptions = [.withInternetDateTime]
return Provenance(
tool: "swiftpkg",
toolVersion: swiftpkgVersion,
builtAt: formatter.string(from: now),
name: configuration.name,
version: configuration.version,
identifier: configuration.identifier,
pkgPath: output.path,
sha256: try provenanceSHA256(ofFileAt: output),
inputDigest: try inputDigest(for: project),
gitCommit: gitOutput(["-C", project.path, "rev-parse", "HEAD"], in: project),
gitRemote: gitOutput(["-C", project.path, "remote", "get-url", "origin"], in: project).map(Self.sanitizedRemote)
)
}

/// Deterministic digest of the build inputs (payload, scripts, build-info),
/// hashing each file's project-relative path and contents in sorted order.
private func inputDigest(for project: URL) throws -> String {
var entries: [(path: String, url: URL)] = []
for subdirectory in ["payload", "scripts"] {
let directory = project.appendingPathComponent(subdirectory, isDirectory: true)
guard fileManager.directoryExists(at: directory) else { continue }
guard let enumerator = fileManager.enumerator(at: directory, includingPropertiesForKeys: [.isRegularFileKey, .isSymbolicLinkKey]) else { continue }
for case let fileURL as URL in enumerator {
let values = try? fileURL.resourceValues(forKeys: [.isRegularFileKey, .isSymbolicLinkKey])
guard values?.isRegularFile == true || values?.isSymbolicLink == true else { continue }
entries.append((relativePath(of: fileURL, under: project), fileURL))
}
}
for name in ["build-info.plist", "build-info.json", "build-info.yaml", "build-info.yml"] {
let url = project.appendingPathComponent(name)
if fileManager.fileExists(atPath: url.path) { entries.append((name, url)) }
}
entries.sort { $0.path < $1.path }

var hasher = SHA256()
for entry in entries {
hasher.update(data: Data(entry.path.utf8))
hasher.update(data: Data([0]))
let mode = (try fileManager.attributesOfItem(atPath: entry.url.path)[.posixPermissions] as? NSNumber)?.uint16Value ?? 0
hasher.update(data: withUnsafeBytes(of: (mode & 0o7777).littleEndian) { Data($0) })
if let destination = try? fileManager.destinationOfSymbolicLink(atPath: entry.url.path) {
hasher.update(data: Data(destination.utf8))
} else {
hasher.update(data: try Data(contentsOf: entry.url))
}
}
return hasher.finalize().map { String(format: "%02x", $0) }.joined()
}

private func relativePath(of url: URL, under project: URL) -> String {
let base = project.standardizedFileURL.path
let path = url.standardizedFileURL.path
return path.hasPrefix(base + "/") ? String(path.dropFirst(base.count + 1)) : url.lastPathComponent
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.

private func gitOutput(_ arguments: [String], in project: URL) -> String? {
guard let result = try? runner.run(executable: ToolPaths.git, arguments: arguments), result.status == 0 else { return nil }
let trimmed = result.stdoutString.trimmingCharacters(in: .whitespacesAndNewlines)
return trimmed.isEmpty ? nil : trimmed
}

/// Removes `user:pass@` userinfo from a remote URL before recording it.
static func sanitizedRemote(_ remote: String) -> String {
guard let schemeRange = remote.range(of: "://") else { return remote }
let authorityAndPath = remote[schemeRange.upperBound...]
guard let at = authorityAndPath.firstIndex(of: "@") else { return remote }
let firstSlash = authorityAndPath.firstIndex(of: "/") ?? authorityAndPath.endIndex
guard at < firstSlash else { return remote }
return String(remote[..<schemeRange.upperBound]) + String(authorityAndPath[authorityAndPath.index(after: at)...])
}
}

/// Streaming SHA-256 of a file, lowercase hex.
func provenanceSHA256(ofFileAt url: URL) throws -> String {
let handle = try FileHandle(forReadingFrom: url)
defer { try? handle.close() }
var hasher = SHA256()
while let chunk = try handle.read(upToCount: 1 << 20), !chunk.isEmpty {
hasher.update(data: chunk)
}
return hasher.finalize().map { String(format: "%02x", $0) }.joined()
}
1 change: 1 addition & 0 deletions swiftpkg/Support.swift
Original file line number Diff line number Diff line change
Expand Up @@ -193,6 +193,7 @@ enum ToolPaths {
static let pkgutil = "/usr/sbin/pkgutil"
static let productbuild = "/usr/bin/productbuild"
static let xcrun = "/usr/bin/xcrun"
static let git = "/usr/bin/git"
static let spctl = "/usr/sbin/spctl"
}

Expand Down
5 changes: 5 additions & 0 deletions swiftpkgCLI/CLI.swift
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,9 @@ public struct CLIOptions: ParsableArguments {
@Flag(name: .long, help: "Skip stapling after notarization.")
public var skipStapling = false

@Flag(name: .long, help: "Write a <pkg>.provenance.json sidecar (tool version, build time, git commit/remote, input digest, package hash) for supply-chain attestation.")
public var provenance = false

@Flag(name: .long, help: "After building, verify the package matches build-info: signature present when signing was requested (pkgutil), Gatekeeper-accepted when notarized (spctl). Fails the build on mismatch.")
public var verify = false

Expand Down Expand Up @@ -111,6 +114,7 @@ public enum CLICommand {
skipsSigning: options.skipSigning,
skipsNotarization: options.skipNotarization,
skipsStapling: options.skipStapling,
writesProvenance: options.provenance,
verifies: options.verify,
versionOverride: options.pkgVersion,
outputDirectory: options.outputDir.map { URL(fileURLWithPath: $0).standardizedFileURL }
Expand Down Expand Up @@ -149,6 +153,7 @@ public enum CLIParser {
--skip-signing Skip configured package signing.
--skip-notarization Skip configured notarization.
--skip-stapling Skip stapling after notarization.
--provenance Write a <pkg>.provenance.json attestation sidecar.
--verify Verify the built package matches build-info.
--output-format FORMAT Build result on stdout: text (default) or json.
--skip-import Accepted for munki-pkg compatibility; ignored.
Expand Down
129 changes: 129 additions & 0 deletions swiftpkgTests/ProvenanceTests.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,129 @@
import Foundation
import Testing
@testable import SwiftPkgCore

private final class GitRunner: ProcessRunning, @unchecked Sendable {
var commit = "abc123"
var remote = "https://github.com/example/repo.git"

func run(executable: String, arguments: [String]) throws -> ProcessResult {
let out: String
if arguments.contains("rev-parse") { out = commit }
else if arguments.contains("remote") { out = remote }
else { out = "" }
return ProcessResult(status: 0, stdout: Data((out + "\n").utf8), stderr: Data())
}
}

struct ProvenanceTests {

@Test("sanitizedRemote strips user:pass@ userinfo but leaves clean URLs")
func sanitizesRemote() {
#expect(ProvenanceBuilder.sanitizedRemote("https://user:pass@github.com/x/y.git") == "https://github.com/x/y.git")
#expect(ProvenanceBuilder.sanitizedRemote("https://token@github.com/x/y.git") == "https://github.com/x/y.git")
#expect(ProvenanceBuilder.sanitizedRemote("https://github.com/x/y.git") == "https://github.com/x/y.git")
#expect(ProvenanceBuilder.sanitizedRemote("git@github.com:x/y.git") == "git@github.com:x/y.git") // scp-style, no ://
}

@Test("provenance captures git metadata and a stable input digest")
func buildsProvenance() throws {
let temp = try TemporaryDirectory()
defer { temp.remove() }
let project = temp.url.appendingPathComponent("P", isDirectory: true)
let payload = project.appendingPathComponent("payload", isDirectory: true)
try FileManager.default.createDirectory(at: payload, withIntermediateDirectories: true)
try write("hello", to: payload.appendingPathComponent("file.txt"))
try write(#"{"name":"App-1.0.pkg","identifier":"com.example.app","version":"1.0"}"#, to: project.appendingPathComponent("build-info.json"))
let output = project.appendingPathComponent("build/App-1.0.pkg")
try FileManager.default.createDirectory(at: output.deletingLastPathComponent(), withIntermediateDirectories: true)
try write("PKGDATA", to: output)

let runner = GitRunner()
runner.remote = "https://user:secret@github.com/example/repo.git"
let builder = ProvenanceBuilder(runner: runner, fileManager: .default)
let config = try BuildInfoStore.load(from: project, requestedFormat: nil)
let now = Date(timeIntervalSince1970: 1_800_000_000)
let provenance = try builder.build(configuration: config, output: output, project: project, now: now)

#expect(provenance.tool == "swiftpkg")
#expect(provenance.gitCommit == "abc123")
#expect(provenance.gitRemote == "https://github.com/example/repo.git") // credentials stripped
#expect(provenance.identifier == "com.example.app")
#expect(provenance.sha256.count == 64)
#expect(provenance.inputDigest.count == 64)

// Input digest is deterministic for identical inputs.
let again = try builder.build(configuration: config, output: output, project: project, now: now)
#expect(again.inputDigest == provenance.inputDigest)

// JSON uses snake_case keys and round-trips.
let json = try provenance.jsonString()
#expect(json.contains("\"input_digest\""))
#expect(json.contains("\"git_commit\""))
let decoded = try JSONDecoder().decode(Provenance.self, from: Data(json.utf8))
#expect(decoded == provenance)
}

@Test("input digest changes when an input file changes")
func digestChangesWithInputs() throws {
let temp = try TemporaryDirectory()
defer { temp.remove() }
let project = temp.url.appendingPathComponent("P", isDirectory: true)
let payload = project.appendingPathComponent("payload", isDirectory: true)
try FileManager.default.createDirectory(at: payload, withIntermediateDirectories: true)
try write(#"{"name":"App-1.0.pkg","identifier":"com.example.app","version":"1.0"}"#, to: project.appendingPathComponent("build-info.json"))
let output = project.appendingPathComponent("build/App-1.0.pkg")
try FileManager.default.createDirectory(at: output.deletingLastPathComponent(), withIntermediateDirectories: true)
try write("PKG", to: output)
let builder = ProvenanceBuilder(runner: GitRunner(), fileManager: .default)
let config = try BuildInfoStore.load(from: project, requestedFormat: nil)

try write("v1", to: payload.appendingPathComponent("file.txt"))
let first = try builder.build(configuration: config, output: output, project: project).inputDigest
try write("v2", to: payload.appendingPathComponent("file.txt"))
let second = try builder.build(configuration: config, output: output, project: project).inputDigest
#expect(first != second)
}

private func makeDigestFixture() throws -> (TemporaryDirectory, URL, URL, ProvenanceBuilder, PackageConfiguration) {
let temp = try TemporaryDirectory()
let project = temp.url.appendingPathComponent("P", isDirectory: true)
let payload = project.appendingPathComponent("payload", isDirectory: true)
try FileManager.default.createDirectory(at: payload, withIntermediateDirectories: true)
try write(#"{"name":"App-1.0.pkg","identifier":"com.example.app","version":"1.0"}"#, to: project.appendingPathComponent("build-info.json"))
let output = project.appendingPathComponent("build/App-1.0.pkg")
try FileManager.default.createDirectory(at: output.deletingLastPathComponent(), withIntermediateDirectories: true)
try write("PKG", to: output)
let builder = ProvenanceBuilder(runner: GitRunner(), fileManager: .default)
let config = try BuildInfoStore.load(from: project, requestedFormat: nil)
return (temp, project, payload, builder, config)
}

@Test("input digest changes when a file's executable bit is toggled")
func digestChangesWithPermissions() throws {
let (temp, project, payload, builder, config) = try makeDigestFixture()
defer { temp.remove() }
let script = payload.appendingPathComponent("run.sh")
try write("#!/bin/sh\n", to: script)
try FileManager.default.setAttributes([.posixPermissions: 0o644], ofItemAtPath: script.path)
let before = try builder.build(configuration: config, output: output(for: project), project: project).inputDigest
try FileManager.default.setAttributes([.posixPermissions: 0o755], ofItemAtPath: script.path)
let after = try builder.build(configuration: config, output: output(for: project), project: project).inputDigest
#expect(before != after)
}

@Test("input digest changes when a symlink's target changes")
func digestChangesWithSymlinkTarget() throws {
let (temp, project, payload, builder, config) = try makeDigestFixture()
defer { temp.remove() }
let link = payload.appendingPathComponent("Current")
try FileManager.default.createSymbolicLink(atPath: link.path, withDestinationPath: "A")
let before = try builder.build(configuration: config, output: output(for: project), project: project).inputDigest
try FileManager.default.removeItem(at: link)
try FileManager.default.createSymbolicLink(atPath: link.path, withDestinationPath: "B")
let after = try builder.build(configuration: config, output: output(for: project), project: project).inputDigest
#expect(before != after)
}

private func output(for project: URL) -> URL { project.appendingPathComponent("build/App-1.0.pkg") }
}