diff --git a/CHANGELOG.md b/CHANGELOG.md index 6b32c04..3015357 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,29 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +## [0.36.1] - 2026-07-31 + +### Added + +- The generated pre-commit hook can authorize specific test fixtures that intentionally + contain secret-shaped values, so a project's own detector fixtures can be committed + without disabling the hook. Authorization is a committed, value-free manifest of exact + per-file, per-line fingerprints; run `pastewatch-cli hook fixture-fingerprint ` + to produce an entry without printing the fixture contents. Any unapproved secret, a + moved or edited fixture, a stale fingerprint, an inline self-authorization comment, or + a malformed manifest still blocks the commit. + +### Fixed + +- Hardened the generated hook and its staged-diff scanning against bypass: hook edits + refuse symlinked, dangling-symlinked, and hard-linked hook files and pin file identity + (device/inode) across the write to close TOCTOU races; hook upgrades replace only the + exact marker-bounded generated section and preserve existing permissions; the staged + scan disables external diff drivers, textconv, and rename detection so a fixture cannot + spoof its authorized path; and the manifest parser rejects duplicate keys, fractional + or overflowing numbers, and manifest changes made in the same commit that consumes its + authority — all failing closed. + ## [0.36.0] - 2026-07-31 ### Changed diff --git a/README.md b/README.md index a1fa7d2..51933f7 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,6 @@ # Pastewatch [![Stable](https://img.shields.io/badge/status-stable-brightgreen)](https://github.com/ppiankov/pastewatch/releases) -[![Version](https://img.shields.io/badge/version-0.36.0-blue)](https://github.com/ppiankov/pastewatch/releases/tag/v0.36.0) +[![Version](https://img.shields.io/badge/version-0.36.1-blue)](https://github.com/ppiankov/pastewatch/releases/tag/v0.36.1) [![License: MIT](https://img.shields.io/badge/license-MIT-yellow)](LICENSE) [![CI](https://github.com/ppiankov/pastewatch/actions/workflows/ci.yml/badge.svg)](https://github.com/ppiankov/pastewatch/actions/workflows/ci.yml) [![ANCC](https://img.shields.io/badge/ANCC-compliant-brightgreen)](https://ancc.dev) @@ -486,6 +486,6 @@ Do not pretend it guarantees compliance or safety. ## Project Status -**Status: Stable, feature-complete** · **v0.36.0** · Accepting compatibility and bug fixes only +**Status: Stable, feature-complete** · **v0.36.1** · Accepting compatibility and bug fixes only Pastewatch is stable and in maintenance mode. See [docs/status.md](docs/status.md) for the full feature-milestone breakdown. diff --git a/Sources/PastewatchCLI/HookCommand.swift b/Sources/PastewatchCLI/HookCommand.swift index 8053844..e786846 100644 --- a/Sources/PastewatchCLI/HookCommand.swift +++ b/Sources/PastewatchCLI/HookCommand.swift @@ -2,11 +2,22 @@ import ArgumentParser import Foundation import PastewatchCore +#if canImport(Darwin) +import Darwin +#else +import Glibc +#endif + +// WO-607: generated-section identity is shared by install and explicit upgrade. +private let pastewatchHookStartMarker = "# BEGIN PASTEWATCH" +private let pastewatchHookEndMarker = "# END PASTEWATCH" + +// WO-594: the hook group exposes the staged-check and fixture authorization boundaries. struct HookGroup: ParsableCommand { static let configuration = CommandConfiguration( commandName: "hook", abstract: "Manage git pre-commit hook", - subcommands: [Install.self, Uninstall.self] + subcommands: [Install.self, Uninstall.self, CheckStaged.self, FixtureFingerprint.self] ) } @@ -19,20 +30,33 @@ extension HookGroup { @Flag(name: .long, help: "Append to existing hook instead of failing") var append = false + // WO-607: replacing an installed Pastewatch section requires explicit operator intent. + @Flag(name: .long, help: "Replace an existing Pastewatch hook section") + var upgrade = false + func run() throws { + // WO-607: install and explicit upgrade share one operator-controlled entry point. let hooksDir = try findGitHooksDir() let hookPath = hooksDir + "/pre-commit" let fm = FileManager.default + let hookPathExists = fm.fileExists(atPath: hookPath) + || (try? fm.destinationOfSymbolicLink(atPath: hookPath)) != nil + guard !(append && upgrade) else { + FileHandle.standardError.write( + Data("error: --append and --upgrade cannot be used together\n".utf8) + ) + throw ExitCode(rawValue: ScanExitContract.operationalFailure) + } // Create hooks directory if needed if !fm.fileExists(atPath: hooksDir) { try fm.createDirectory(atPath: hooksDir, withIntermediateDirectories: true) } - // WO-130: generated hooks must fail closed on scan setup/shared-pattern failures. + // WO-594: one command owns staged-diff authorization and scan exit propagation. let hookContent = """ # BEGIN PASTEWATCH - git diff --cached --diff-filter=d --no-color | pastewatch-cli scan --check + pastewatch-cli hook check-staged PASTEWATCH_RESULT=$? # WO-580@v3: generated hooks consume the named scan findings contract. if [ "$PASTEWATCH_RESULT" -eq \(ScanExitContract.findingsDetected) ]; then @@ -47,32 +71,124 @@ extension HookGroup { # END PASTEWATCH """ - if fm.fileExists(atPath: hookPath) { - let existing = try String(contentsOfFile: hookPath, encoding: .utf8) - if existing.contains("BEGIN PASTEWATCH") { - FileHandle.standardError.write(Data("error: pastewatch hook already installed\n".utf8)) - throw ExitCode(rawValue: 2) - } - if !append { - FileHandle.standardError.write(Data("error: pre-commit hook already exists (use --append to add pastewatch)\n".utf8)) - throw ExitCode(rawValue: 2) + var createdHook = false + if hookPathExists { + if append || upgrade { + // WO-611@v2: a pinned descriptor rejects stale upgrade targets. + // WO-613@v2: the same descriptor prevents symlink-following during append. + let editor = try openHookFileEditor(atPath: hookPath) + let updated: String + if upgrade { + updated = try replacingPastewatchHookSection( + in: editor.content, + with: hookContent + ) + } else if editor.content.contains(pastewatchHookStartMarker) + || editor.content.contains(pastewatchHookEndMarker) { + FileHandle.standardError.write( + Data("error: pastewatch hook already installed\n".utf8) + ) + throw ExitCode(rawValue: ScanExitContract.operationalFailure) + } else { + updated = editor.content.trimmingCharacters( + in: .whitespacesAndNewlines + ) + "\n\n" + hookContent + "\n" + } + try replaceHookContent(updated, using: editor) + } else { + let existing = try String(contentsOfFile: hookPath, encoding: .utf8) + if existing.contains(pastewatchHookStartMarker) + || existing.contains(pastewatchHookEndMarker) { + FileHandle.standardError.write( + Data("error: pastewatch hook already installed\n".utf8) + ) + } else { + FileHandle.standardError.write( + Data( + ( + "error: pre-commit hook already exists " + + "(use --append to add pastewatch)\n" + ).utf8 + ) + ) + } + throw ExitCode(rawValue: ScanExitContract.operationalFailure) } - // Append to existing hook - let updated = existing.trimmingCharacters(in: .whitespacesAndNewlines) + "\n\n" + hookContent + "\n" - try updated.write(toFile: hookPath, atomically: true, encoding: .utf8) } else { + guard !upgrade else { + FileHandle.standardError.write( + Data("error: no installed Pastewatch hook to upgrade\n".utf8) + ) + throw ExitCode(rawValue: ScanExitContract.operationalFailure) + } // Create new hook with shebang let fullHook = "#!/bin/sh\n\n" + hookContent + "\n" try fullHook.write(toFile: hookPath, atomically: true, encoding: .utf8) + createdHook = true } - // Make executable (chmod +x) - try fm.setAttributes([.posixPermissions: 0o755], ofItemAtPath: hookPath) + if createdHook { + // Fresh hooks use the documented executable mode. + try fm.setAttributes([.posixPermissions: 0o755], ofItemAtPath: hookPath) + } print("installed pre-commit hook at \(hookPath)") } } + // WO-594: generated hooks call this hidden boundary instead of a lossy shell pipeline. + struct CheckStaged: ParsableCommand { + static let configuration = CommandConfiguration( + abstract: "Check staged changes with committed fixture authorizations", + shouldDisplay: false + ) + + func run() throws { + // WO-594: the generated hook preserves scan and operational exit semantics. + do { + let filteredDiff = try GitDiffScanner.filteredHookStagedDiff() + let status = try runScanCheck(input: filteredDiff) + guard status == ScanExitContract.clean else { + throw ExitCode(rawValue: status) + } + } catch let exitCode as ExitCode { + throw exitCode + } catch { + FileHandle.standardError.write(Data("error: \(error.localizedDescription)\n".utf8)) + throw ExitCode(rawValue: ScanExitContract.operationalFailure) + } + } + } + + // WO-594: operators can create a manifest entry without printing fixture content. + struct FixtureFingerprint: ParsableCommand { + static let configuration = CommandConfiguration( + commandName: "fixture-fingerprint", + abstract: "Print a value-free authorization entry for one fixture line" + ) + + @Argument(help: "Repository-relative fixture file") + var file: String + + @Option(name: .long, help: "One-based source line") + var line: Int + + func run() throws { + // WO-594: fixture metadata is derived without printing source content. + let authorization = try GitDiffScanner.hookFixtureAuthorization( + filePath: file, + line: line + ) + let encoder = JSONEncoder() + encoder.outputFormatting = [.sortedKeys] + let data = try encoder.encode(authorization) + guard let output = String(data: data, encoding: .utf8) else { + throw ExitCode.failure + } + print(output) + } + } + struct Uninstall: ParsableCommand { static let configuration = CommandConfiguration( abstract: "Remove pre-commit hook" @@ -123,6 +239,201 @@ extension HookGroup { } } +// WO-611@v2: a pinned regular-file identity prevents path replacement from becoming a write target. +final class HookFileEditor { + private struct Identity: Equatable { + let device: UInt64 + let inode: UInt64 + } + + let content: String + + private let path: String + private let identity: Identity + private let handle: FileHandle + + init(path: String) throws { + let descriptor = open(path, O_RDWR | O_NOFOLLOW) + guard descriptor >= 0 else { + throw HookFileEditorError.notRegular + } + let handle = FileHandle(fileDescriptor: descriptor, closeOnDealloc: true) + + var info = stat() + guard fstat(descriptor, &info) == 0, + (info.st_mode & mode_t(S_IFMT)) == mode_t(S_IFREG) else { + try? handle.close() + throw HookFileEditorError.notRegular + } + guard info.st_nlink == 1 else { + try? handle.close() + throw HookFileEditorError.multiplyLinked + } + // WO-617: nil means a valid zero-byte file, not a decode failure. + let data = try handle.readToEnd() ?? Data() + guard let content = String(data: data, encoding: .utf8) else { + try? handle.close() + throw HookFileEditorError.invalidUTF8 + } + + self.path = path + self.identity = Identity( + device: UInt64(info.st_dev), + inode: UInt64(info.st_ino) + ) + self.handle = handle + self.content = content + } + + // WO-611@v2: identity checks bracket writes without following a replaced path. + func replaceContent(_ replacement: String) throws { + guard try currentPathIdentity() == identity else { + throw HookFileEditorError.pathChanged + } + try handle.seek(toOffset: 0) + try handle.truncate(atOffset: 0) + try handle.write(contentsOf: Data(replacement.utf8)) + try handle.synchronize() + guard try currentPathIdentity() == identity else { + throw HookFileEditorError.pathChanged + } + } + + private func currentPathIdentity() throws -> Identity { + // WO-611@v2: every path check must still identify the descriptor-pinned file. + var info = stat() + guard lstat(path, &info) == 0, + (info.st_mode & mode_t(S_IFMT)) == mode_t(S_IFREG) else { + throw HookFileEditorError.pathChanged + } + return Identity( + device: UInt64(info.st_dev), + inode: UInt64(info.st_ino) + ) + } +} + +// WO-611@v2: file-identity failures remain explicit operational errors. +enum HookFileEditorError: LocalizedError, Equatable { + // WO-611@v2: unsafe file identities fail closed before any hook replacement. + case notRegular + case multiplyLinked + case invalidUTF8 + case pathChanged + + var errorDescription: String? { + switch self { + case .notRegular: + return "hook must be a regular file; update symlink-managed hooks at their target" + case .multiplyLinked: + return "hook has multiple hard links; update it through the owning hook manager" + case .invalidUTF8: + return "hook is not readable UTF-8" + case .pathChanged: + return "hook changed concurrently; no replacement path was written" + } + } +} + +private func openHookFileEditor(atPath path: String) throws -> HookFileEditor { + // WO-611@v2: translate file-identity failures to the hook operational contract. + do { + return try HookFileEditor(path: path) + } catch { + FileHandle.standardError.write(Data("error: \(error.localizedDescription)\n".utf8)) + throw ExitCode(rawValue: ScanExitContract.operationalFailure) + } +} + +// WO-611@v2: descriptor-pinned writes fail through the hook operational contract. +private func replaceHookContent( + _ content: String, + using editor: HookFileEditor +) throws { + // WO-611@v2: descriptor-pinned replacement failures remain operational errors. + do { + try editor.replaceContent(content) + } catch { + FileHandle.standardError.write(Data("error: \(error.localizedDescription)\n".utf8)) + throw ExitCode(rawValue: ScanExitContract.operationalFailure) + } +} + +// WO-604: resolve the running binary independently of argv[0]'s PATH spelling. +private func runScanCheck(input: String) throws -> Int32 { + guard let executableURL = Bundle.main.executableURL else { + throw CocoaError(.fileNoSuchFile) + } + let process = Process() + process.executableURL = executableURL + process.arguments = ["scan", "--check"] + + let inputPipe = Pipe() + process.standardInput = inputPipe + + try process.run() + do { + try inputPipe.fileHandleForWriting.write(contentsOf: Data(input.utf8)) + try inputPipe.fileHandleForWriting.close() + } catch { + if process.isRunning { + process.terminate() + } + process.waitUntilExit() + throw error + } + process.waitUntilExit() + return process.terminationStatus +} + +// WO-607: upgrade only one exact, well-formed generated section. +private func replacingPastewatchHookSection( + in existing: String, + with replacement: String +) throws -> String { + let startRanges = exactLineRanges(of: pastewatchHookStartMarker, in: existing) + let endRanges = exactLineRanges(of: pastewatchHookEndMarker, in: existing) + guard startRanges.count == 1, + endRanges.count == 1, + startRanges[0].lowerBound < endRanges[0].lowerBound else { + FileHandle.standardError.write( + Data("error: existing Pastewatch hook markers are malformed\n".utf8) + ) + throw ExitCode(rawValue: ScanExitContract.operationalFailure) + } + + return existing.replacingCharacters( + in: startRanges[0].lowerBound.. [Range] { + var ranges: [Range] = [] + var searchStart = content.startIndex + while searchStart < content.endIndex, + let range = content.range(of: marker, range: searchStart.. String { let process = Process() diff --git a/Sources/PastewatchCore/GitDiffScanner.swift b/Sources/PastewatchCore/GitDiffScanner.swift index a646624..fdc1e32 100644 --- a/Sources/PastewatchCore/GitDiffScanner.swift +++ b/Sources/PastewatchCore/GitDiffScanner.swift @@ -1,5 +1,57 @@ import Foundation +#if canImport(CryptoKit) +import CryptoKit +#else +import Crypto +#endif + +// WO-594: exact staged-fixture authorization contains only location and digest evidence. +public struct HookFixtureAuthorization: Codable, Equatable, Sendable { + public let path: String + public let line: Int + public let fingerprint: String + + public init(path: String, line: Int, fingerprint: String) { + self.path = path + self.line = line + self.fingerprint = fingerprint + } +} + +// WO-594: malformed or broadened authorization fails closed without exposing fixture values. +public enum HookFixtureAuthorizationError: LocalizedError { + case invalidManifest + case unsupportedVersion + case tooManyEntries + case invalidEntry(Int) + case duplicateLocation(Int) + case manifestChangedWithAuthorization + case unreadableFixture + case missingFixtureLine + + public var errorDescription: String? { + switch self { + case .invalidManifest: + return "invalid hook fixture authorization manifest" + case .unsupportedVersion: + return "unsupported hook fixture authorization manifest version" + case .tooManyEntries: + return "hook fixture authorization manifest has too many entries" + case .invalidEntry(let index): + return "invalid hook fixture authorization entry at index \(index)" + case .duplicateLocation(let index): + return "duplicate hook fixture authorization location at index \(index)" + case .manifestChangedWithAuthorization: + return "hook fixture authorization manifest must be unchanged in the consuming commit" + case .unreadableFixture: + return "fixture file is not readable UTF-8" + case .missingFixtureLine: + return "fixture line does not exist" + } + } +} + // WO-562@v3: shared helpers used by both GitDiffScanner and GitHistoryScanner. public enum GitScanHelpers { @@ -40,6 +92,21 @@ public enum GitScanHelpers { /// Scans git diff output for sensitive data, reporting only findings on added lines. public struct GitDiffScanner { + // WO-594: the committed manifest is the only staged-fixture authority. + public static let hookFixtureManifestPath = ".pastewatch-hook-fixtures.json" + + private static let hookFixtureManifestVersion = 1 + private static let maximumHookFixtureAuthorizations = 1_000 + + private struct HookFixtureLocation: Hashable { + let path: String + let line: Int + } + + private struct HookFixtureFilterOutcome { + let diff: String + let consumedAuthorizations: Int + } /// Parsed representation of one file in a unified diff. struct DiffFile { @@ -152,6 +219,311 @@ public struct GitDiffScanner { return results.sorted { $0.filePath < $1.filePath } } + // WO-594: produce reviewable authorization evidence without returning fixture text. + public static func hookFixtureAuthorization( + filePath: String, + line: Int, + limits: ScanInputLimits = .current() + ) throws -> HookFixtureAuthorization { + let normalizedPath = try validateHookFixturePath(filePath) + guard line > 0 else { + throw HookFixtureAuthorizationError.missingFixtureLine + } + + let data = try DetectionRules.readBoundedFileData( + atPath: normalizedPath, + limits: limits + ) + guard let content = String(data: data, encoding: .utf8) else { + throw HookFixtureAuthorizationError.unreadableFixture + } + let lines = content.components(separatedBy: "\n") + guard line <= lines.count else { + throw HookFixtureAuthorizationError.missingFixtureLine + } + let fixtureLine = normalizedHookFixtureLine(lines[line - 1]) + return HookFixtureAuthorization( + path: normalizedPath, + line: line, + fingerprint: hookFixtureFingerprint(fixtureLine) + ) + } + + // WO-594: SHA-256 binds authorization to the complete source line. + public static func hookFixtureFingerprint(_ line: String) -> String { + SHA256.hash(data: Data(line.utf8)) + .map { String(format: "%02x", $0) } + .joined() + } + + // WO-594: filter only exact committed path/line/digest matches from staged diff input. + public static func filteredHookStagedDiff( + limits: ScanInputLimits = .current() + ) throws -> String { + // WO-615/WO-616: read raw staged bytes and expand moves at their destination. + let diff = try runGit( + [ + "diff", "--cached", "--diff-filter=d", "--no-color", "--unified=0", + "--no-ext-diff", "--no-textconv", "--no-renames" + ], + limits: limits + ) + let authorizations = try committedHookFixtureAuthorizations(limits: limits) + let outcome = try filterAuthorizedHookFixturesWithOutcome( + in: diff, + authorizations: authorizations + ) + if outcome.consumedAuthorizations > 0 { + // WO-609: authority consumed by this commit must remain in its resulting tree. + let manifestChanges = try runGit( + [ + "diff", "--cached", "--name-only", + "--no-ext-diff", "--no-textconv", "--no-renames", "--", + hookFixtureManifestPath + ], + limits: limits + ) + guard manifestChanges.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty else { + throw HookFixtureAuthorizationError.manifestChangedWithAuthorization + } + } + return outcome.diff + } + + // WO-594: public pure transform keeps authorization edge cases deterministic in tests. + public static func filterAuthorizedHookFixtures( + in diff: String, + authorizations: [HookFixtureAuthorization] + ) throws -> String { + try filterAuthorizedHookFixturesWithOutcome( + in: diff, + authorizations: authorizations + ).diff + } + + // WO-608: explicit hunk state prevents source text from impersonating diff metadata. + private static func filterAuthorizedHookFixturesWithOutcome( + in diff: String, + authorizations: [HookFixtureAuthorization] + ) throws -> HookFixtureFilterOutcome { + var authorizedByLocation: [HookFixtureLocation: String] = [:] + for (index, authorization) in authorizations.enumerated() { + let path = try validateHookFixturePath(authorization.path) + guard authorization.line > 0, + isValidHookFixtureFingerprint(authorization.fingerprint) else { + throw HookFixtureAuthorizationError.invalidEntry(index) + } + let location = HookFixtureLocation(path: path, line: authorization.line) + guard authorizedByLocation[location] == nil else { + throw HookFixtureAuthorizationError.duplicateLocation(index) + } + authorizedByLocation[location] = authorization.fingerprint + } + + var currentPath: String? + var currentLine: Int? + var filtered: [String] = [] + var consumedAuthorizations = 0 + + for diffLine in diff.components(separatedBy: "\n") { + if diffLine.hasPrefix("diff --git ") { + currentPath = nil + currentLine = nil + } else if diffLine.hasPrefix("@@ ") { + currentLine = parseHunkHeader(diffLine) + } else if currentLine != nil, diffLine.hasPrefix("+") { + // WO-606: fingerprint generation and staged verification share CRLF handling. + let sourceLine = normalizedHookFixtureLine(String(diffLine.dropFirst())) + if let path = currentPath, + let line = currentLine, + authorizedByLocation[HookFixtureLocation(path: path, line: line)] + == hookFixtureFingerprint(sourceLine) { + filtered.append("+") + consumedAuthorizations += 1 + } else { + filtered.append(diffLine) + } + if let line = currentLine { + currentLine = line + 1 + } + continue + } else if currentLine != nil, diffLine.hasPrefix("-") { + filtered.append(diffLine) + continue + } else if currentLine != nil, + diffLine.hasPrefix(" ") || diffLine.isEmpty { + currentLine? += 1 + } else if currentLine == nil, diffLine.hasPrefix("+++ ") { + currentPath = extractPath(from: diffLine) + } + filtered.append(diffLine) + } + return HookFixtureFilterOutcome( + diff: filtered.joined(separator: "\n"), + consumedAuthorizations: consumedAuthorizations + ) + } + + // WO-594: staged manifest edits are deliberately ignored until separately committed. + static func committedHookFixtureAuthorizations( + limits: ScanInputLimits = .current() + ) throws -> [HookFixtureAuthorization] { + let manifest: String + do { + manifest = try runGit( + ["show", "HEAD:\(hookFixtureManifestPath)"], + limits: limits + ) + } catch let error as ScanInputLimitError { + throw error + } catch let error as ScanInputTextError { + throw error + } catch { + return [] + } + return try parseHookFixtureManifest(Data(manifest.utf8)) + } + + private static func parseHookFixtureManifest( + _ data: Data + ) throws -> [HookFixtureAuthorization] { + guard let root = try JSONSerialization.jsonObject(with: data) as? [String: Any], + Set(root.keys) == ["version", "fixtures"], + let versionNumber = root["version"] as? NSNumber, + let version = exactJSONInteger(versionNumber), + let entries = root["fixtures"] as? [Any] else { + throw HookFixtureAuthorizationError.invalidManifest + } + guard version == hookFixtureManifestVersion else { + throw HookFixtureAuthorizationError.unsupportedVersion + } + guard entries.count <= maximumHookFixtureAuthorizations else { + throw HookFixtureAuthorizationError.tooManyEntries + } + + var authorizations: [HookFixtureAuthorization] = [] + var locations = Set() + for (index, value) in entries.enumerated() { + guard let entry = value as? [String: Any], + Set(entry.keys) == ["path", "line", "fingerprint"], + let pathValue = entry["path"] as? String, + let lineValue = entry["line"] as? NSNumber, + let line = exactJSONInteger(lineValue), + let fingerprint = entry["fingerprint"] as? String else { + throw HookFixtureAuthorizationError.invalidEntry(index) + } + let path = try validateHookFixturePath(pathValue) + guard line > 0, isValidHookFixtureFingerprint(fingerprint) else { + throw HookFixtureAuthorizationError.invalidEntry(index) + } + let location = HookFixtureLocation(path: path, line: line) + guard locations.insert(location).inserted else { + throw HookFixtureAuthorizationError.duplicateLocation(index) + } + authorizations.append( + HookFixtureAuthorization( + path: path, + line: line, + fingerprint: fingerprint + ) + ) + } + // WO-610@v2: reject parser-ambiguous duplicate or escaped schema keys. + guard hasExactHookManifestKeyMultiplicity(data, entryCount: entries.count) else { + throw HookFixtureAuthorizationError.invalidManifest + } + return authorizations + } + + // WO-610@v2: Foundation collapses duplicate keys, so validate key tokens first. + private static func hasExactHookManifestKeyMultiplicity( + _ data: Data, + entryCount: Int + ) -> Bool { + let bytes = [UInt8](data) + var index = 0 + var counts = [ + "version": 0, + "fixtures": 0, + "path": 0, + "line": 0, + "fingerprint": 0 + ] + + while index < bytes.count { + guard bytes[index] == 0x22 else { + index += 1 + continue + } + index += 1 + var token: [UInt8] = [] + var escaped = false + while index < bytes.count, bytes[index] != 0x22 { + if bytes[index] == 0x5C { + escaped = true + index += 2 + } else { + token.append(bytes[index]) + index += 1 + } + } + guard index < bytes.count else { return false } + index += 1 + + var lookahead = index + while lookahead < bytes.count, + [0x20, 0x09, 0x0A, 0x0D].contains(bytes[lookahead]) { + lookahead += 1 + } + guard lookahead < bytes.count, bytes[lookahead] == 0x3A else { + continue + } + guard !escaped, + let key = String(bytes: token, encoding: .utf8) else { + return false + } + counts[key, default: 0] += 1 + } + + return counts == [ + "version": 1, + "fixtures": 1, + "path": entryCount, + "line": entryCount, + "fingerprint": entryCount + ] + } + + // WO-605: authorization metadata accepts JSON integer storage only, never truncation. + private static func exactJSONInteger(_ number: NSNumber) -> Int? { + let type = String(cString: number.objCType) + guard ["s", "i", "l", "q"].contains(type) else { + return nil + } + return Int(exactly: number.int64Value) + } + + // WO-606: CR is a line terminator artifact, not part of the authorized source line. + private static func normalizedHookFixtureLine(_ line: String) -> String { + line.hasSuffix("\r") ? String(line.dropLast()) : line + } + + private static func validateHookFixturePath(_ path: String) throws -> String { + let parts = path.split(separator: "/", omittingEmptySubsequences: false) + guard !path.isEmpty, + !path.hasPrefix("/"), + !path.contains("\\"), + !parts.contains(where: { $0.isEmpty || $0 == "." || $0 == ".." }) else { + throw HookFixtureAuthorizationError.invalidManifest + } + return parts.joined(separator: "/") + } + + private static func isValidHookFixtureFingerprint(_ fingerprint: String) -> Bool { + fingerprint.count == 64 + && fingerprint.allSatisfy { $0.isHexDigit && !$0.isUppercase } + } + // WO-599@v2: collect bounded Git output without inflating scan control flow. private static func collectDiffFiles( staged: Bool, diff --git a/Sources/PastewatchCore/Version.swift b/Sources/PastewatchCore/Version.swift index 2f52e9e..bdfa8f4 100644 --- a/Sources/PastewatchCore/Version.swift +++ b/Sources/PastewatchCore/Version.swift @@ -1,5 +1,5 @@ public enum AppVersion { // WO-528@v2: version updates drive the guarded release workflow. - // WO-529@v3: 0.36.0 packages opt-in ambiguous obfuscation. - public static let current = "0.36.0" + // WO-529@v3: 0.36.1 packages opt-in ambiguous obfuscation. + public static let current = "0.36.1" } diff --git a/Tests/PastewatchTests/HookTests.swift b/Tests/PastewatchTests/HookTests.swift index 8a53546..072236d 100644 --- a/Tests/PastewatchTests/HookTests.swift +++ b/Tests/PastewatchTests/HookTests.swift @@ -1,10 +1,13 @@ import Foundation +@testable import PastewatchCLI +@testable import PastewatchCore import XCTest final class HookTests: XCTestCase { var testDir: String! override func setUp() { + // WO-594: hook tests isolate repository and configuration state per case. super.setUp() testDir = NSTemporaryDirectory() + "pastewatch-hook-test-\(UUID().uuidString)" try? FileManager.default.createDirectory(atPath: testDir, withIntermediateDirectories: true) @@ -17,6 +20,8 @@ final class HookTests: XCTestCase { process.standardError = FileHandle.nullDevice try? process.run() process.waitUntilExit() + try? runGit(["config", "user.email", "pastewatch-tests@example.invalid"]) + try? runGit(["config", "user.name", "Pastewatch Tests"]) // Git templates may install real hooks; these tests need an empty pre-commit slot. try? FileManager.default.removeItem(atPath: testDir + "/.git/hooks/pre-commit") } @@ -34,14 +39,20 @@ final class HookTests: XCTestCase { // Test hook script content has correct structure func testHookScriptContainsMarkers() throws { + // WO-594: generated hooks call the structured staged-check boundary. try installPastewatchHook() let hookContent = try String(contentsOfFile: hookPath(), encoding: .utf8) XCTAssertTrue(hookContent.contains("BEGIN PASTEWATCH")) XCTAssertTrue(hookContent.contains("END PASTEWATCH")) - XCTAssertTrue(hookContent.contains("pastewatch-cli scan --check")) + XCTAssertTrue(hookContent.contains("pastewatch-cli hook check-staged")) + XCTAssertFalse(hookContent.contains("git diff --cached")) XCTAssertTrue(hookContent.contains("PASTEWATCH_RESULT")) XCTAssertTrue(hookContent.contains("scan failed with exit code")) + let permissions = try FileManager.default.attributesOfItem( + atPath: hookPath() + )[.posixPermissions] as? Int + XCTAssertEqual(permissions, 0o755) } // WO-130: clean scans are the only generated-hook success path. @@ -77,6 +88,667 @@ final class HookTests: XCTestCase { XCTAssertTrue(result.stderr.contains("scan failed with exit code 99"), result.stderr) } + // WO-594: committed exact path/line/digest evidence authorizes only its fixture. + func testCommittedFixtureAuthorizationAllowsExactStagedLine() throws { + let fixtureLine = syntheticFixtureLine() + try commitManifest([ + HookFixtureAuthorization( + path: "fixture.txt", + line: 1, + fingerprint: GitDiffScanner.hookFixtureFingerprint(fixtureLine) + ) + ]) + try stageFixture(fixtureLine, path: "fixture.txt") + + let result = try runRealHookCheck() + + XCTAssertEqual(result.status, 0, result.stderr) + XCTAssertFalse(result.stderr.contains(fixtureLine)) + } + + // WO-594: no committed authorization preserves the existing blocking behavior. + func testUnapprovedFixtureStillBlocks() throws { + let fixtureLine = syntheticFixtureLine() + try stageFixture(fixtureLine, path: "fixture.txt") + + let result = try runRealHookCheck() + + XCTAssertEqual(result.status, ScanExitContract.findingsDetected, result.stderr) + XCTAssertFalse(result.stderr.contains(fixtureLine)) + } + + // WO-594: changing an authorized line invalidates its digest. + func testStaleFixtureFingerprintBlocks() throws { + let fixtureLine = syntheticFixtureLine() + try commitManifest([ + HookFixtureAuthorization( + path: "fixture.txt", + line: 1, + fingerprint: GitDiffScanner.hookFixtureFingerprint(fixtureLine + "-stale") + ) + ]) + try stageFixture(fixtureLine, path: "fixture.txt") + + let result = try runRealHookCheck() + + XCTAssertEqual(result.status, ScanExitContract.findingsDetected, result.stderr) + XCTAssertFalse(result.stderr.contains(fixtureLine)) + } + + // WO-594: moving identical content to another path invalidates authorization. + func testMovedFixtureBlocks() throws { + let fixtureLine = syntheticFixtureLine() + try commitManifest([ + HookFixtureAuthorization( + path: "approved.txt", + line: 1, + fingerprint: GitDiffScanner.hookFixtureFingerprint(fixtureLine) + ) + ]) + try stageFixture(fixtureLine, path: "moved.txt") + + let result = try runRealHookCheck() + + XCTAssertEqual(result.status, ScanExitContract.findingsDetected, result.stderr) + XCTAssertFalse(result.stderr.contains(fixtureLine)) + } + + // WO-594: malformed committed policy fails before the staged scan can pass. + func testMalformedFixtureManifestFailsClosed() throws { + try commitRawManifest(#"{"version":1,"fixtures":[{"path":"fixture.txt"}]}"#) + let fixtureLine = syntheticFixtureLine() + try stageFixture(fixtureLine, path: "fixture.txt") + + let result = try runRealHookCheck() + + XCTAssertEqual(result.status, ScanExitContract.operationalFailure, result.stderr) + XCTAssertTrue(result.stderr.contains("invalid hook fixture authorization entry")) + XCTAssertFalse(result.stderr.contains(fixtureLine)) + } + + // WO-594: staged source comments cannot grant their own authorization. + func testInlineFixtureAuthorizationAttemptBlocks() throws { + let fixtureLine = syntheticFixtureLine() + let attemptedAuthorization = GitDiffScanner.hookFixtureFingerprint(fixtureLine) + try stageFixture( + "\(fixtureLine)\n// pastewatch fixture \(attemptedAuthorization)", + path: "fixture.txt" + ) + + let result = try runRealHookCheck() + + XCTAssertEqual(result.status, ScanExitContract.findingsDetected, result.stderr) + XCTAssertFalse(result.stderr.contains(fixtureLine)) + } + + // WO-604: a PATH-spelled argv[0] must still resolve the running CLI for recursive scan. + func testPATHInvokedHookCheckRunsCurrentExecutable() throws { + let result = try runRealHookCheck() + + XCTAssertEqual(result.status, ScanExitContract.clean, result.stderr) + } + + // WO-605: JSON numeric coercion cannot broaden fixture authorization. + func testFractionalManifestVersionFailsClosed() throws { + let fixtureLine = syntheticFixtureLine() + let fingerprint = GitDiffScanner.hookFixtureFingerprint(fixtureLine) + try commitRawManifest( + manifestJSON(version: "1.5", line: "1", fingerprint: fingerprint) + ) + try stageFixture(fixtureLine, path: "fixture.txt") + + let result = try runRealHookCheck() + + XCTAssertEqual(result.status, ScanExitContract.operationalFailure, result.stderr) + XCTAssertTrue(result.stderr.contains("invalid hook fixture authorization manifest")) + } + + // WO-605: fixture lines are exact positive integers, never truncated decimals. + func testFractionalManifestLineFailsClosed() throws { + let fixtureLine = syntheticFixtureLine() + let fingerprint = GitDiffScanner.hookFixtureFingerprint(fixtureLine) + try commitRawManifest( + manifestJSON(version: "1", line: "1.5", fingerprint: fingerprint) + ) + try stageFixture(fixtureLine, path: "fixture.txt") + + let result = try runRealHookCheck() + + XCTAssertEqual(result.status, ScanExitContract.operationalFailure, result.stderr) + XCTAssertTrue(result.stderr.contains("invalid hook fixture authorization entry")) + } + + // WO-605: integers outside the platform line-number range fail closed. + func testOverflowManifestLineFailsClosed() throws { + let fixtureLine = syntheticFixtureLine() + let fingerprint = GitDiffScanner.hookFixtureFingerprint(fixtureLine) + try commitRawManifest( + manifestJSON( + version: "1", + line: "9223372036854775808", + fingerprint: fingerprint + ) + ) + try stageFixture(fixtureLine, path: "fixture.txt") + + let result = try runRealHookCheck() + + XCTAssertEqual(result.status, ScanExitContract.operationalFailure, result.stderr) + XCTAssertTrue(result.stderr.contains("invalid hook fixture authorization entry")) + } + + // WO-605: exact future versions report the version contract, not generic corruption. + func testUnsupportedManifestVersionFailsClosed() throws { + let fixtureLine = syntheticFixtureLine() + let fingerprint = GitDiffScanner.hookFixtureFingerprint(fixtureLine) + try commitRawManifest( + manifestJSON(version: "2", line: "1", fingerprint: fingerprint) + ) + try stageFixture(fixtureLine, path: "fixture.txt") + + let result = try runRealHookCheck() + + XCTAssertEqual(result.status, ScanExitContract.operationalFailure, result.stderr) + XCTAssertTrue(result.stderr.contains("unsupported hook fixture authorization manifest version")) + } + + // WO-606: the CLI and staged diff agree on source lines terminated by CRLF. + func testCommittedCRLFFixtureAuthorizationAllowsExactLine() throws { + let fixtureLine = syntheticFixtureLine() + try Data("\(fixtureLine)\r\n".utf8).write( + to: URL(fileURLWithPath: testDir).appendingPathComponent("fixture.txt") + ) + let authorization = try fixtureAuthorization(path: "fixture.txt", line: 1) + try commitManifest([authorization]) + try runGit(["add", "fixture.txt"]) + + let result = try runRealHookCheck() + + XCTAssertEqual(result.status, ScanExitContract.clean, result.stderr) + XCTAssertEqual( + authorization.fingerprint, + GitDiffScanner.hookFixtureFingerprint(fixtureLine) + ) + } + + // WO-608: source payload that renders as +++ metadata cannot switch authorization paths. + func testAddedSourceCannotSpoofDiffPathHeader() throws { + let fixtureLine = syntheticFixtureLine() + try commitManifest([ + HookFixtureAuthorization( + path: "approved.swift", + line: 1, + fingerprint: GitDiffScanner.hookFixtureFingerprint(fixtureLine) + ) + ]) + try stageFixture( + "++ b/approved.swift\n\(fixtureLine)", + path: "attacker.swift" + ) + + let result = try runRealHookCheck() + + XCTAssertEqual(result.status, ScanExitContract.findingsDetected, result.stderr) + XCTAssertFalse(result.stderr.contains(fixtureLine)) + } + + // WO-615: repository diff helpers cannot erase staged hook input. + func testExternalDiffCannotBypassStagedScan() throws { + try runGit(["config", "diff.external", "/usr/bin/true"]) + try stageFixture(syntheticFixtureLine(), path: "fixture.txt") + + let result = try runRealHookCheck() + + XCTAssertEqual(result.status, ScanExitContract.findingsDetected, result.stderr) + } + + // WO-615: textconv output cannot replace the staged bytes seen by the hook. + func testTextconvCannotBypassStagedScan() throws { + try "*.txt diff=fixture\n".write( + toFile: testDir + "/.gitattributes", + atomically: true, + encoding: .utf8 + ) + try runGit(["add", ".gitattributes"]) + try runGit(["commit", "-m", "test diff attributes"]) + try runGit(["config", "diff.fixture.textconv", "/usr/bin/true"]) + try stageFixture(syntheticFixtureLine(), path: "fixture.txt") + + let result = try runRealHookCheck() + + XCTAssertEqual(result.status, ScanExitContract.findingsDetected, result.stderr) + } + + // WO-616: Git moves become a full addition at the unauthorized destination path. + func testRenamedAuthorizedFixtureRequiresDestinationAuthorization() throws { + let fixtureLine = syntheticFixtureLine() + try fixtureLine.write( + toFile: testDir + "/approved.txt", + atomically: true, + encoding: .utf8 + ) + try commitManifest([ + HookFixtureAuthorization( + path: "approved.txt", + line: 1, + fingerprint: GitDiffScanner.hookFixtureFingerprint(fixtureLine) + ) + ]) + try runGit(["add", "approved.txt"]) + try runGit(["commit", "-m", "test authorized fixture"]) + try runGit(["mv", "approved.txt", "moved.txt"]) + try stageEmptyManifest() + + let result = try runRealHookCheck() + + XCTAssertEqual(result.status, ScanExitContract.findingsDetected, result.stderr) + } + + // WO-609: a consuming commit cannot delete the authority it used. + func testAuthorizedFixtureWithManifestDeletionFailsClosed() throws { + let fixtureLine = syntheticFixtureLine() + try commitManifest([ + HookFixtureAuthorization( + path: "fixture.txt", + line: 1, + fingerprint: GitDiffScanner.hookFixtureFingerprint(fixtureLine) + ) + ]) + try stageFixture(fixtureLine, path: "fixture.txt") + try runGit(["rm", GitDiffScanner.hookFixtureManifestPath]) + + let result = try runRealHookCheck() + + XCTAssertEqual(result.status, ScanExitContract.operationalFailure, result.stderr) + XCTAssertTrue(result.stderr.contains("manifest must be unchanged")) + XCTAssertFalse(result.stderr.contains(fixtureLine)) + } + + // WO-609: editing an entry while consuming its HEAD value is equally rejected. + func testAuthorizedFixtureWithManifestEditFailsClosed() throws { + let fixtureLine = syntheticFixtureLine() + try commitManifest([ + HookFixtureAuthorization( + path: "fixture.txt", + line: 1, + fingerprint: GitDiffScanner.hookFixtureFingerprint(fixtureLine) + ) + ]) + try stageFixture(fixtureLine, path: "fixture.txt") + try stageEmptyManifest() + + let result = try runRealHookCheck() + + XCTAssertEqual(result.status, ScanExitContract.operationalFailure, result.stderr) + XCTAssertTrue(result.stderr.contains("manifest must be unchanged")) + } + + // WO-609: manifest maintenance remains possible when no authorization is consumed. + func testStandaloneManifestMaintenanceRemainsClean() throws { + let fixtureLine = syntheticFixtureLine() + try commitManifest([ + HookFixtureAuthorization( + path: "fixture.txt", + line: 1, + fingerprint: GitDiffScanner.hookFixtureFingerprint(fixtureLine) + ) + ]) + try stageEmptyManifest() + + let result = try runRealHookCheck() + + XCTAssertEqual(result.status, ScanExitContract.clean, result.stderr) + } + + // WO-610@v2: duplicate root keys cannot rely on Foundation's winner semantics. + func testDuplicateManifestRootKeyFailsClosed() throws { + let fixtureLine = syntheticFixtureLine() + let fingerprint = GitDiffScanner.hookFixtureFingerprint(fixtureLine) + try commitRawManifest( + """ + {"version":1,"version":1,"fixtures":[{"path":"fixture.txt","line":1,"fingerprint":"\(fingerprint)"}]} + """ + ) + try stageFixture(fixtureLine, path: "fixture.txt") + + let result = try runRealHookCheck() + + XCTAssertEqual(result.status, ScanExitContract.operationalFailure, result.stderr) + XCTAssertTrue(result.stderr.contains("invalid hook fixture authorization manifest")) + } + + // WO-610@v2: duplicate entry keys are rejected before dictionary collapse. + func testDuplicateManifestEntryKeyFailsClosed() throws { + let fixtureLine = syntheticFixtureLine() + let fingerprint = GitDiffScanner.hookFixtureFingerprint(fixtureLine) + try commitRawManifest( + """ + {"version":1,"fixtures":[{"path":"other.txt","path":"fixture.txt","line":1,"fingerprint":"\(fingerprint)"}]} + """ + ) + try stageFixture(fixtureLine, path: "fixture.txt") + + let result = try runRealHookCheck() + + XCTAssertEqual(result.status, ScanExitContract.operationalFailure, result.stderr) + XCTAssertTrue(result.stderr.contains("invalid hook fixture authorization manifest")) + } + + // WO-610@v2: escaped schema-key spellings cannot hide review ambiguity. + func testEscapedManifestKeyFailsClosed() throws { + let fixtureLine = syntheticFixtureLine() + let fingerprint = GitDiffScanner.hookFixtureFingerprint(fixtureLine) + try commitRawManifest( + """ + {"version":1,"fixtures":[{"\\u0070ath":"fixture.txt","line":1,"fingerprint":"\(fingerprint)"}]} + """ + ) + try stageFixture(fixtureLine, path: "fixture.txt") + + let result = try runRealHookCheck() + + XCTAssertEqual(result.status, ScanExitContract.operationalFailure, result.stderr) + XCTAssertTrue(result.stderr.contains("invalid hook fixture authorization manifest")) + } + + // WO-610@v2: zero entry-key occurrences are valid for an empty fixture list. + func testCommittedEmptyManifestRemainsClean() throws { + try commitRawManifest(#"{"version":1,"fixtures":[]}"#) + + let result = try runRealHookCheck() + + XCTAssertEqual(result.status, ScanExitContract.clean, result.stderr) + } + + // WO-594: the fingerprint command emits only reviewable location and digest metadata. + func testFixtureFingerprintCommandNeverPrintsFixtureValue() throws { + let fixtureLine = syntheticFixtureLine() + try fixtureLine.write( + toFile: testDir + "/fixture.txt", + atomically: true, + encoding: .utf8 + ) + + let process = Process() + process.executableURL = pastewatchCLIURL() + process.arguments = ["hook", "fixture-fingerprint", "fixture.txt", "--line", "1"] + process.currentDirectoryURL = URL(fileURLWithPath: testDir) + process.environment = testEnvironment() + let stdout = Pipe() + let stderr = Pipe() + process.standardOutput = stdout + process.standardError = stderr + + try process.run() + process.waitUntilExit() + + let outputData = stdout.fileHandleForReading.readDataToEndOfFile() + let errorText = String( + data: stderr.fileHandleForReading.readDataToEndOfFile(), + encoding: .utf8 + ) ?? "" + XCTAssertEqual(process.terminationStatus, 0, errorText) + XCTAssertFalse(String(data: outputData, encoding: .utf8)?.contains(fixtureLine) == true) + let authorization = try JSONDecoder().decode( + HookFixtureAuthorization.self, + from: outputData + ) + XCTAssertEqual(authorization.path, "fixture.txt") + XCTAssertEqual(authorization.line, 1) + XCTAssertEqual( + authorization.fingerprint, + GitDiffScanner.hookFixtureFingerprint(fixtureLine) + ) + } + + // WO-607: upgrade changes only the marked generated section. + func testUpgradePreservesOtherHookContent() throws { + let existing = """ + #!/bin/sh + printf before + # BEGIN PASTEWATCH + git diff --cached | pastewatch-cli scan --check + # END PASTEWATCH + printf after + """ + try existing.write(toFile: hookPath(), atomically: true, encoding: .utf8) + + try installPastewatchHook(arguments: ["--upgrade"]) + + let upgraded = try String(contentsOfFile: hookPath(), encoding: .utf8) + XCTAssertTrue(upgraded.hasPrefix("#!/bin/sh\nprintf before\n")) + XCTAssertTrue(upgraded.hasSuffix("\nprintf after")) + XCTAssertTrue(upgraded.contains("pastewatch-cli hook check-staged")) + XCTAssertFalse(upgraded.contains("git diff --cached")) + XCTAssertEqual(upgraded.components(separatedBy: "# BEGIN PASTEWATCH").count, 2) + XCTAssertEqual(upgraded.components(separatedBy: "# END PASTEWATCH").count, 2) + } + + // WO-607: default installation never rewrites an existing Pastewatch section. + func testInstallWithoutUpgradePreservesExistingSection() throws { + let existing = "#!/bin/sh\n# BEGIN PASTEWATCH\nprintf old\n# END PASTEWATCH\n" + try existing.write(toFile: hookPath(), atomically: true, encoding: .utf8) + + let result = try runHookInstall() + + XCTAssertEqual(result.status, ScanExitContract.operationalFailure, result.stderr) + XCTAssertEqual( + try String(contentsOfFile: hookPath(), encoding: .utf8), + existing + ) + } + + // WO-607: malformed marker layouts fail before any hook bytes are changed. + func testUpgradeRejectsMalformedMarkersWithoutWriting() throws { + let malformedHooks = [ + "#!/bin/sh\n# BEGIN PASTEWATCH\nprintf old\n", + "#!/bin/sh\n# END PASTEWATCH\n# BEGIN PASTEWATCH\n", + "#!/bin/sh\n# BEGIN PASTEWATCH\n# END PASTEWATCH\n# END PASTEWATCH\n", + "#!/bin/sh\n# BEGIN PASTEWATCH\n# END PASTEWATCH\n# BEGIN PASTEWATCH\n# END PASTEWATCH\n" + ] + + for existing in malformedHooks { + try existing.write(toFile: hookPath(), atomically: true, encoding: .utf8) + + let result = try runHookInstall(arguments: ["--upgrade"]) + + XCTAssertEqual(result.status, ScanExitContract.operationalFailure, result.stderr) + XCTAssertEqual( + try String(contentsOfFile: hookPath(), encoding: .utf8), + existing + ) + } + } + + // WO-611@v2: upgrading cannot detach a repository from a managed hook target. + func testUpgradeRejectsSymlinkManagedHook() throws { + let targetPath = testDir + "/shared-pre-commit" + let target = "#!/bin/sh\n# BEGIN PASTEWATCH\nprintf old\n# END PASTEWATCH\n" + try target.write(toFile: targetPath, atomically: true, encoding: .utf8) + try FileManager.default.createSymbolicLink( + atPath: hookPath(), + withDestinationPath: targetPath + ) + + let result = try runHookInstall(arguments: ["--upgrade"]) + + XCTAssertEqual(result.status, ScanExitContract.operationalFailure, result.stderr) + XCTAssertTrue(result.stderr.contains("symlink-managed")) + XCTAssertEqual( + try FileManager.default.destinationOfSymbolicLink(atPath: hookPath()), + targetPath + ) + XCTAssertEqual( + try String(contentsOfFile: targetPath, encoding: .utf8), + target + ) + } + + // WO-612: explicit upgrade preserves the operator-selected access mode. + func testUpgradePreservesExistingHookPermissions() throws { + let existing = "#!/bin/sh\n# BEGIN PASTEWATCH\nprintf old\n# END PASTEWATCH\n" + for expectedMode in [0o700, 0o750] { + try existing.write(toFile: hookPath(), atomically: true, encoding: .utf8) + try FileManager.default.setAttributes( + [.posixPermissions: expectedMode], + ofItemAtPath: hookPath() + ) + + try installPastewatchHook(arguments: ["--upgrade"]) + + let permissions = try FileManager.default.attributesOfItem( + atPath: hookPath() + )[.posixPermissions] as? Int + XCTAssertEqual(permissions, expectedMode) + } + } + + // WO-613@v2: append cannot detach a repository from a symlink-managed hook. + func testAppendRejectsSymlinkManagedHook() throws { + let targetPath = testDir + "/shared-pre-commit" + let target = "#!/bin/sh\nprintf managed\n" + try target.write(toFile: targetPath, atomically: true, encoding: .utf8) + try FileManager.default.createSymbolicLink( + atPath: hookPath(), + withDestinationPath: targetPath + ) + + let result = try runHookInstall(arguments: ["--append"]) + + XCTAssertEqual(result.status, ScanExitContract.operationalFailure, result.stderr) + XCTAssertTrue(result.stderr.contains("symlink-managed")) + XCTAssertEqual( + try FileManager.default.destinationOfSymbolicLink(atPath: hookPath()), + targetPath + ) + XCTAssertEqual( + try String(contentsOfFile: targetPath, encoding: .utf8), + target + ) + } + + // WO-613@v2: a missing symlink target cannot make append treat the path as fresh. + func testAppendRejectsDanglingSymlinkManagedHook() throws { + let missingTarget = testDir + "/missing-shared-pre-commit" + try FileManager.default.createSymbolicLink( + atPath: hookPath(), + withDestinationPath: missingTarget + ) + + let result = try runHookInstall(arguments: ["--append"]) + + XCTAssertEqual(result.status, ScanExitContract.operationalFailure, result.stderr) + XCTAssertTrue(result.stderr.contains("symlink-managed")) + XCTAssertEqual( + try FileManager.default.destinationOfSymbolicLink(atPath: hookPath()), + missingTarget + ) + XCTAssertFalse(FileManager.default.fileExists(atPath: missingTarget)) + } + + // WO-614: append preserves every existing regular-hook access mode. + func testAppendPreservesExistingHookPermissions() throws { + let existing = "#!/bin/sh\nprintf existing\n" + for expectedMode in [0o700, 0o750] { + try existing.write(toFile: hookPath(), atomically: true, encoding: .utf8) + try FileManager.default.setAttributes( + [.posixPermissions: expectedMode], + ofItemAtPath: hookPath() + ) + + try installPastewatchHook(arguments: ["--append"]) + + let permissions = try FileManager.default.attributesOfItem( + atPath: hookPath() + )[.posixPermissions] as? Int + let content = try String(contentsOfFile: hookPath(), encoding: .utf8) + XCTAssertEqual(permissions, expectedMode) + XCTAssertTrue(content.contains("printf existing")) + XCTAssertTrue(content.contains("pastewatch-cli hook check-staged")) + } + } + + // WO-617: a touched but empty regular hook remains a valid append target. + func testAppendSupportsEmptyExistingHook() throws { + try Data().write(to: URL(fileURLWithPath: hookPath())) + try FileManager.default.setAttributes( + [.posixPermissions: 0o700], + ofItemAtPath: hookPath() + ) + + let result = try runHookInstall(arguments: ["--append"]) + + XCTAssertEqual(result.status, ScanExitContract.clean, result.stderr) + let content = try String(contentsOfFile: hookPath(), encoding: .utf8) + let permissions = try FileManager.default.attributesOfItem( + atPath: hookPath() + )[.posixPermissions] as? Int + XCTAssertTrue(content.contains("pastewatch-cli hook check-staged")) + XCTAssertEqual(permissions, 0o700) + } + + // WO-617: undecodable existing hook bytes fail without lossy replacement. + func testAppendRejectsInvalidUTF8WithoutWriting() throws { + let invalid = Data([0xFF, 0xFE]) + try invalid.write(to: URL(fileURLWithPath: hookPath())) + + let result = try runHookInstall(arguments: ["--append"]) + + XCTAssertEqual(result.status, ScanExitContract.operationalFailure, result.stderr) + XCTAssertTrue(result.stderr.contains("not readable UTF-8")) + XCTAssertEqual(try Data(contentsOf: URL(fileURLWithPath: hookPath())), invalid) + } + + // WO-611@v2: path replacement after open is rejected through pinned identity. + func testHookEditorRejectsConcurrentPathReplacement() throws { + let existing = "#!/bin/sh\n# BEGIN PASTEWATCH\nprintf old\n# END PASTEWATCH\n" + try existing.write(toFile: hookPath(), atomically: true, encoding: .utf8) + let editor = try HookFileEditor(path: hookPath()) + let targetPath = testDir + "/replacement-target" + let target = "#!/bin/sh\nprintf replacement\n" + try target.write(toFile: targetPath, atomically: true, encoding: .utf8) + try FileManager.default.removeItem(atPath: hookPath()) + try FileManager.default.createSymbolicLink( + atPath: hookPath(), + withDestinationPath: targetPath + ) + + XCTAssertThrowsError(try editor.replaceContent("unexpected")) { error in + XCTAssertEqual(error as? HookFileEditorError, .pathChanged) + } + XCTAssertEqual( + try FileManager.default.destinationOfSymbolicLink(atPath: hookPath()), + targetPath + ) + XCTAssertEqual( + try String(contentsOfFile: targetPath, encoding: .utf8), + target + ) + } + + // WO-618: descriptor writes cannot mutate every name of a shared hard-linked hook. + func testAppendAndUpgradeRejectMultiplyLinkedHook() throws { + let targetPath = testDir + "/shared-hardlink-hook" + let target = "#!/bin/sh\n# BEGIN PASTEWATCH\nprintf managed\n# END PASTEWATCH\n" + try target.write(toFile: targetPath, atomically: true, encoding: .utf8) + try FileManager.default.linkItem(atPath: targetPath, toPath: hookPath()) + + for arguments in [["--append"], ["--upgrade"]] { + let result = try runHookInstall(arguments: arguments) + + XCTAssertEqual(result.status, ScanExitContract.operationalFailure, result.stderr) + XCTAssertTrue(result.stderr.contains("multiple hard links")) + XCTAssertEqual( + try String(contentsOfFile: hookPath(), encoding: .utf8), + target + ) + XCTAssertEqual( + try String(contentsOfFile: targetPath, encoding: .utf8), + target + ) + } + } + // Test section removal from multi-hook file func testSectionRemoval() { let content = """ @@ -177,10 +849,24 @@ final class HookTests: XCTestCase { ) } - private func installPastewatchHook() throws { + private func installPastewatchHook(arguments: [String] = []) throws { + // WO-594: test setup installs only through the public hook command. + let result = try runHookInstall(arguments: arguments) + guard result.status == 0 else { + throw NSError( + domain: "HookTests", + code: Int(result.status), + userInfo: [NSLocalizedDescriptionKey: "hook install failed: \(result.stderr)"] + ) + } + } + + // WO-607: install helpers exercise explicit upgrade arguments through the CLI. + private func runHookInstall(arguments: [String] = []) throws -> HookResult { + // WO-607: tests exercise explicit install and upgrade arguments through the CLI. let process = Process() process.executableURL = pastewatchCLIURL() - process.arguments = ["hook", "install"] + process.arguments = ["hook", "install"] + arguments process.currentDirectoryURL = URL(fileURLWithPath: testDir) process.environment = testEnvironment() process.standardOutput = FileHandle.nullDevice @@ -191,27 +877,24 @@ final class HookTests: XCTestCase { try process.run() process.waitUntilExit() - guard process.terminationStatus == 0 else { - let errorOutput = String( + return HookResult( + status: process.terminationStatus, + stderr: String( data: stderr.fileHandleForReading.readDataToEndOfFile(), encoding: .utf8 ) ?? "" - throw NSError( - domain: "HookTests", - code: Int(process.terminationStatus), - userInfo: [NSLocalizedDescriptionKey: "hook install failed: \(errorOutput)"] - ) - } + ) } private func writeFakePastewatchCLI(scanExitCode: Int32) throws { + // WO-594: the generated shell hook is tested against deterministic child exits. let binDir = testDir + "/bin" try FileManager.default.createDirectory(atPath: binDir, withIntermediateDirectories: true) let cliPath = binDir + "/pastewatch-cli" let script = """ #!/bin/sh cat >/dev/null - if [ "$1" = "scan" ] && [ "$2" = "--check" ]; then + if [ "$1" = "hook" ] && [ "$2" = "check-staged" ]; then exit \(scanExitCode) fi exit 64 @@ -226,6 +909,159 @@ final class HookTests: XCTestCase { try runGit(["add", "staged.txt"]) } + // WO-594: synthetic value is assembled at runtime so the repository hook is never bypassed. + // WO-596 made ambiguous classes (credential/email/etc.) default-OFF, so a + // password= fixture is no longer guard-blocking at default config. The hook tests + // must stage a value that ALWAYS blocks regardless of config -- use an intrinsic + // (AWS-key-shaped) secret so unapproved fixtures are genuinely detected. + private func syntheticFixtureLine() -> String { + ["aws_key = ", "AKIA", "Z9Q7K2M4N8P1R3T5"].joined() + } + + // WO-594: manifest commits are separate from fixture staging by construction. + private func commitManifest( + _ authorizations: [HookFixtureAuthorization] + ) throws { + let fixtures = authorizations.map { authorization in + [ + "path": authorization.path, + "line": authorization.line, + "fingerprint": authorization.fingerprint + ] as [String: Any] + } + let data = try JSONSerialization.data( + withJSONObject: ["version": 1, "fixtures": fixtures], + options: [.sortedKeys] + ) + try data.write( + to: URL(fileURLWithPath: testDir) + .appendingPathComponent(GitDiffScanner.hookFixtureManifestPath) + ) + try commitManifestFile() + } + + // WO-594: malformed committed policy fixtures exercise the fail-closed parser. + private func commitRawManifest(_ manifest: String) throws { + try manifest.write( + toFile: testDir + "/" + GitDiffScanner.hookFixtureManifestPath, + atomically: true, + encoding: .utf8 + ) + try commitManifestFile() + } + + // WO-605: raw numeric spellings exercise JSON parsing without NSNumber construction. + private func manifestJSON( + version: String, + line: String, + fingerprint: String + ) -> String { + """ + {"version":\(version),"fixtures":[{"path":"fixture.txt","line":\(line),"fingerprint":"\(fingerprint)"}]} + """ + } + + private func commitManifestFile() throws { + // WO-594: fixture authority must exist in committed history before staged use. + try runGit(["add", GitDiffScanner.hookFixtureManifestPath]) + try runGit(["commit", "-m", "test fixture policy"]) + } + + // WO-609: stage a valid authority-removal commit independently of fixture content. + private func stageEmptyManifest() throws { + try commitManifestData([ + "version": 1, + "fixtures": [] + ]) + try runGit(["add", GitDiffScanner.hookFixtureManifestPath]) + } + + private func commitManifestData(_ object: [String: Any]) throws { + // WO-594: tests write only value-free authorization metadata. + let data = try JSONSerialization.data(withJSONObject: object, options: [.sortedKeys]) + try data.write( + to: URL(fileURLWithPath: testDir) + .appendingPathComponent(GitDiffScanner.hookFixtureManifestPath) + ) + } + + // WO-594: fixture staging happens only after the committed policy boundary. + private func stageFixture(_ content: String, path: String) throws { + let url = URL(fileURLWithPath: testDir).appendingPathComponent(path) + try FileManager.default.createDirectory( + at: url.deletingLastPathComponent(), + withIntermediateDirectories: true + ) + try content.write(to: url, atomically: true, encoding: .utf8) + try runGit(["add", path]) + } + + // WO-604: invoke through PATH so argv[0] matches the generated-hook deployment. + private func runRealHookCheck() throws -> HookResult { + let binDirectory = testDir + "/bin" + try FileManager.default.createDirectory( + atPath: binDirectory, + withIntermediateDirectories: true + ) + let commandPath = binDirectory + "/pastewatch-cli" + try? FileManager.default.removeItem(atPath: commandPath) + try FileManager.default.createSymbolicLink( + atPath: commandPath, + withDestinationPath: pastewatchCLIURL().path + ) + + let process = Process() + process.executableURL = URL(fileURLWithPath: "/bin/sh") + process.arguments = ["-c", "pastewatch-cli hook check-staged"] + process.currentDirectoryURL = URL(fileURLWithPath: testDir) + process.environment = testEnvironment(pathPrefix: binDirectory) + process.standardOutput = FileHandle.nullDevice + let stderr = Pipe() + process.standardError = stderr + + try process.run() + process.waitUntilExit() + + return HookResult( + status: process.terminationStatus, + stderr: String( + data: stderr.fileHandleForReading.readDataToEndOfFile(), + encoding: .utf8 + ) ?? "" + ) + } + + // WO-606: exercise line normalization through the public CLI boundary. + private func fixtureAuthorization( + path: String, + line: Int + ) throws -> HookFixtureAuthorization { + let process = Process() + process.executableURL = pastewatchCLIURL() + process.arguments = [ + "hook", "fixture-fingerprint", path, "--line", String(line) + ] + process.currentDirectoryURL = URL(fileURLWithPath: testDir) + process.environment = testEnvironment() + let stdout = Pipe() + process.standardOutput = stdout + process.standardError = FileHandle.nullDevice + + try process.run() + process.waitUntilExit() + guard process.terminationStatus == 0 else { + throw NSError( + domain: "HookTests", + code: Int(process.terminationStatus), + userInfo: [NSLocalizedDescriptionKey: "fixture fingerprint failed"] + ) + } + return try JSONDecoder().decode( + HookFixtureAuthorization.self, + from: stdout.fileHandleForReading.readDataToEndOfFile() + ) + } + private func runGit(_ arguments: [String]) throws { let process = Process() process.executableURL = URL(fileURLWithPath: "/usr/bin/git") @@ -261,11 +1097,13 @@ final class HookTests: XCTestCase { } private func testEnvironment(pathPrefix: String? = nil) -> [String: String] { + // WO-604: subprocess tests control PATH without changing the executable under test. let basePath = "/usr/bin:/bin" let path = pathPrefix.map { "\($0):\(basePath)" } ?? basePath return [ "GIT_CONFIG_GLOBAL": "/dev/null", "GIT_CONFIG_NOSYSTEM": "1", + "HOME": testDir ?? NSTemporaryDirectory(), "PATH": path ] } diff --git a/docs/agent-safety.md b/docs/agent-safety.md index c4b9ae0..e2dae22 100644 --- a/docs/agent-safety.md +++ b/docs/agent-safety.md @@ -303,7 +303,7 @@ pastewatch-cli hook install # .pre-commit-config.yaml repos: - repo: https://github.com/ppiankov/pastewatch - rev: v0.36.0 + rev: v0.36.1 hooks: - id: pastewatch ``` diff --git a/docs/cli-reference.md b/docs/cli-reference.md index 395cc15..e68ee50 100644 --- a/docs/cli-reference.md +++ b/docs/cli-reference.md @@ -475,10 +475,59 @@ pastewatch-cli hook install # Append to existing hook pastewatch-cli hook install --append +# Upgrade an existing Pastewatch section in place +pastewatch-cli hook install --upgrade + # Remove hook pastewatch-cli hook uninstall ``` +`--upgrade` is explicit and replaces only one well-formed section between the +`BEGIN PASTEWATCH` and `END PASTEWATCH` markers. Content outside that section is +preserved. Review or back up a customized hook before upgrading; malformed, +duplicate, or unmatched markers are rejected without modifying the file. +Symlink-managed hooks are rejected for both `--append` and `--upgrade` so the +repository is not detached from its shared hook; update the symlink target through +the system that owns it. Multiply linked regular hooks are rejected for the same +reason. Existing single-link regular-file permissions are preserved. + +### Positive test fixtures + +The generated hook can authorize an exact detector-positive test fixture without +weakening scanning for other staged content. Authorization is bound to the +repository-relative file path, one-based line number, and SHA-256 fingerprint of +the complete source line. + +```bash +pastewatch-cli hook fixture-fingerprint Tests/ExampleTests.swift --line 42 +``` + +The command prints a JSON entry containing only `path`, `line`, and `fingerprint`. +Add that entry to a root `.pastewatch-hook-fixtures.json` manifest: + +```json +{ + "version": 1, + "fixtures": [ + { + "path": "Tests/ExampleTests.swift", + "line": 42, + "fingerprint": "" + } + ] +} +``` + +Commit and review the manifest change before staging the fixture. The hook reads +authorization only from the manifest already committed in `HEAD`; a staged +manifest edit, source comment, moved line, changed value, malformed entry, or +directory-wide convention cannot authorize the current commit. Renew an entry by +generating and committing its new fingerprint separately. A commit that consumes +an authorization must leave the manifest unchanged, so remove or revise entries +in a later standalone commit. File moves are scanned as additions at the destination +path and require a separately committed destination authorization. The manifest and +hook diagnostics never contain the fixture value. + ## Baseline Diff Create a baseline of known findings, then only report new ones: @@ -536,7 +585,7 @@ Works with any comment style (`#`, `//`, `/* */`). # .pre-commit-config.yaml repos: - repo: https://github.com/ppiankov/pastewatch - rev: v0.36.0 + rev: v0.36.1 hooks: - id: pastewatch ``` diff --git a/docs/status.md b/docs/status.md index 38c66ab..966e6d9 100644 --- a/docs/status.md +++ b/docs/status.md @@ -2,7 +2,7 @@ ## Current State -**Stable, feature-complete - v0.36.0** +**Stable, feature-complete - v0.36.1** Accepting compatibility, safety, and bug fixes only. No major new features planned.