From be963dfdc0b6ffd1099170c457e4fcce34a2459d Mon Sep 17 00:00:00 2001 From: Agent Date: Mon, 16 Mar 2026 16:58:12 +0000 Subject: [PATCH] docs/test: add SECURITY.md + env builder tests --- SECURITY.md | 38 ++++++++++++ Sources/GitwCore/GitRunner.swift | 60 ++++++++++++------- .../GitEnvironmentBuilderTests.swift | 42 +++++++++++++ 3 files changed, 119 insertions(+), 21 deletions(-) create mode 100644 SECURITY.md create mode 100644 Tests/GitwCoreTests/GitEnvironmentBuilderTests.swift diff --git a/SECURITY.md b/SECURITY.md new file mode 100644 index 0000000..281c556 --- /dev/null +++ b/SECURITY.md @@ -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 ` 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. diff --git a/Sources/GitwCore/GitRunner.swift b/Sources/GitwCore/GitRunner.swift index e9e3764..07dc7b2 100644 --- a/Sources/GitwCore/GitRunner.swift +++ b/Sources/GitwCore/GitRunner.swift @@ -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? @@ -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. } @@ -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) diff --git a/Tests/GitwCoreTests/GitEnvironmentBuilderTests.swift b/Tests/GitwCoreTests/GitEnvironmentBuilderTests.swift new file mode 100644 index 0000000..51663ae --- /dev/null +++ b/Tests/GitwCoreTests/GitEnvironmentBuilderTests.swift @@ -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") + } +}