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
38 changes: 38 additions & 0 deletions SECURITY.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
# Security

## Threat model

`gitw` is designed for environments where the account running automation (e.g. an agent user) is **not fully trusted**, but the installation prefix (e.g. `/usr/local/bin`) and the OS admin/root account are trusted.

### We aim to protect against

- Accidental or malicious use of non-GitHub remotes.
- SSH-based Git URLs (`git@…`) and non-HTTPS remotes.
- Git credential helpers storing or retrieving secrets implicitly.
- Token leakage via argv, files, or long-lived helper processes.

### Non-goals

- If **admin/root** is compromised, `gitw` cannot meaningfully protect secrets.
- A process already running with the same privileges as the user invoking `gitw` can always run its own tools; `gitw` focuses on making the *Git invocation* fail closed and reducing accidental leakage.

## Key design points

- `/usr/bin/git` only: no PATH lookups; code signature is checked against a baked-in requirement.
- Credentials at rest live in **macOS Keychain**.
- Authentication uses Git’s `GIT_ASKPASS` mechanism via `gitw-askpass`, but the token is served by a short-lived in-memory broker over a **Unix domain socket**.
- A per-run **nonce** binds askpass requests to the broker.
- Helpers are disabled and prompting is blocked (`GIT_TERMINAL_PROMPT=0`).

## Keychain schema

- `--as <alias>` selects a local alias.
- Keychain `kSecAttrAccount` = alias
- Keychain `kSecAttrComment` = actual GitHub username (used for HTTP auth)
- Keychain secret data = token

## CI notes

GitHub Actions runners may have a differently signed `/usr/bin/git` than your local system. CI can opt out of `/usr/bin/git` signature checking in DEBUG builds using `GITW_SKIP_GIT_SIGNATURE_CHECK=1`.

Release builds always enforce signature policy.
60 changes: 39 additions & 21 deletions Sources/GitwCore/GitRunner.swift
Original file line number Diff line number Diff line change
Expand Up @@ -49,23 +49,7 @@ public enum GitRunner {
try validateRepoRemotes(gitPath: gitEnv.gitPath, prefixArgs: extractGitPrefixArgs(args))
}

var env = ProcessInfo.processInfo.environment
// Fail closed: never allow terminal prompting.
env["GIT_TERMINAL_PROMPT"] = "0"

// Disable helpers so creds never spill to disk, keychain via git, or other helpers.
env["GIT_CONFIG_COUNT"] = "2"
env["GIT_CONFIG_KEY_0"] = "credential.helper"
env["GIT_CONFIG_VALUE_0"] = ""
env["GIT_CONFIG_KEY_1"] = "credential.useHttpPath"
env["GIT_CONFIG_VALUE_1"] = "true"


// Scrub potentially dangerous overrides.
env["GIT_ASKPASS"] = nil
env["SSH_ASKPASS"] = nil
env["GIT_SSH"] = nil
env["GIT_SSH_COMMAND"] = nil
var env = buildGitEnvironment(base: ProcessInfo.processInfo.environment)

var broker: AskpassBroker?
var tmpDir: URL?
Expand All @@ -90,10 +74,10 @@ public enum GitRunner {
try b.start()
broker = b

env["GIT_ASKPASS"] = askpassPath
env["SSH_ASKPASS"] = askpassPath
env["GITW_SOCKET"] = sock
env["GITW_NONCE"] = nonce
env = buildGitEnvironment(base: env,
askpassPath: askpassPath,
brokerSocket: sock,
brokerNonce: nonce)
} else {
// No credentials. Askpass is not set; git will fail closed if it prompts.
}
Expand All @@ -118,6 +102,40 @@ public enum GitRunner {
return p.terminationStatus
}

/// Build the environment for invoking `git`.
/// Kept as a separate function so it can be unit-tested.
public static func buildGitEnvironment(base: [String: String],
askpassPath: String? = nil,
brokerSocket: String? = nil,
brokerNonce: String? = nil) -> [String: String] {
var env = base

// Fail closed: never allow terminal prompting.
env["GIT_TERMINAL_PROMPT"] = "0"

// Disable helpers so creds never spill to disk, keychain via git, or other helpers.
env["GIT_CONFIG_COUNT"] = "2"
env["GIT_CONFIG_KEY_0"] = "credential.helper"
env["GIT_CONFIG_VALUE_0"] = ""
env["GIT_CONFIG_KEY_1"] = "credential.useHttpPath"
env["GIT_CONFIG_VALUE_1"] = "true"

// Scrub potentially dangerous overrides.
env["GIT_ASKPASS"] = nil
env["SSH_ASKPASS"] = nil
env["GIT_SSH"] = nil
env["GIT_SSH_COMMAND"] = nil

if let askpassPath, let brokerSocket, let brokerNonce {
env["GIT_ASKPASS"] = askpassPath
env["SSH_ASKPASS"] = askpassPath
env["GITW_SOCKET"] = brokerSocket
env["GITW_NONCE"] = brokerNonce
}

return env
}

public static func runAndCapture(_ path: String, _ args: [String]) throws -> String {
let p = Process()
p.executableURL = URL(fileURLWithPath: path)
Expand Down
42 changes: 42 additions & 0 deletions Tests/GitwCoreTests/GitEnvironmentBuilderTests.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
import Foundation
import Testing
@testable import GitwCore

struct GitEnvironmentBuilderTests {
@Test
func baseEnvironmentIsHardenedAndScrubbed() {
var base: [String: String] = [
"GIT_ASKPASS": "evil",
"SSH_ASKPASS": "evil",
"GIT_SSH": "evil",
"GIT_SSH_COMMAND": "evil"
]

let env = GitRunner.buildGitEnvironment(base: base)

#expect(env["GIT_TERMINAL_PROMPT"] == "0")
#expect(env["GIT_CONFIG_COUNT"] == "2")
#expect(env["GIT_CONFIG_KEY_0"] == "credential.helper")
#expect(env["GIT_CONFIG_VALUE_0"] == "")

#expect(env["GIT_ASKPASS"] == nil)
#expect(env["SSH_ASKPASS"] == nil)
#expect(env["GIT_SSH"] == nil)
#expect(env["GIT_SSH_COMMAND"] == nil)
}

@Test
func askpassAndBrokerVarsAreSetWhenProvided() {
let env = GitRunner.buildGitEnvironment(
base: [:],
askpassPath: "/usr/local/bin/gitw-askpass",
brokerSocket: "/tmp/sock",
brokerNonce: "nonce"
)

#expect(env["GIT_ASKPASS"] == "/usr/local/bin/gitw-askpass")
#expect(env["SSH_ASKPASS"] == "/usr/local/bin/gitw-askpass")
#expect(env["GITW_SOCKET"] == "/tmp/sock")
#expect(env["GITW_NONCE"] == "nonce")
}
}
Loading