gitfoss | 2be10c00836127535a237123c3bb3de0e15984c2 | packages/gitfoss-ssh-command/src/ssh-command.cr ∙ GitFOSS
.cr
Crystal
(text/x-crystal)
#!/usr/bin/env crystal
# ssh-key-wrapper-crystal
#
# - Uses only stdlib
# - Args: argv[0]=user argv[1]=key_blob argv[2]=fingerprint (fingerprint optional)
# - Validates key via HTTP POST to /auth/ssh
# - For git-receive-pack (push): scans incoming pack for secrets, triggers pipelines, enforces policy
# - For git-upload-pack / git-upload-archive (fetch/pull/clone): enforces auth and allows fetch (no server-side pack scanning)
# - Build: crystal build --release ssh-key-wrapper-crystal.cr -o /usr/local/bin/ssh-key-wrapper-crystal
require "file_utils"
require "json"
require "process"
require "uri"
require "io"

require "./fetch"

# ---------- Config ----------
MAX_PACK_BYTES = 50 * 1024 * 1024  # 50 MiB
CHUNK_SIZE = 64 * 1024             # 64 KiB
OVERLAP = 4096                     # 4 KiB overlap
MAX_MATCHES = 500
LOG_FILE = "/var/log/gitfoss/git_ssh.log"

PATTERNS = [
  /AKIA[0-9A-Z]{16}/,
  /AIza[0-9A-Za-z\-_]{35}/,
  /-----BEGIN PRIVATE KEY-----/,
  /password\s*[:=]\s*[^ \t\r\n]{6,}/i
]

# prefix
SIDEBAND_PREFIX = "0008" + "\x02"
SIDEBAND_ERR_PREFIX = "0008" + "\x03"

# ---------- Helpers ----------
def write_to_file(msg : String, divider : Bool = false)
  txt = msg.gsub(SIDEBAND_PREFIX, "").gsub(SIDEBAND_ERR_PREFIX, "")
  File.open(LOG_FILE, mode: "a", encoding: "utf-8") do |file|
    file.flock_exclusive # prevent concurrent writes
    file.print "\n#{"=" * 80}\n\n" if divider
    file.print "[#{Time.utc.to_unix}] #{txt.ends_with?("\n") ? txt : txt + "\n"}"
    file.flush
    file.flock_unlock
  end
end

def die_with_message(msg : String, code = 1)
  txt = "#{SIDEBAND_ERR_PREFIX}#{msg.ends_with?("\n") ? msg : msg + "\n"}"
  STDERR.puts txt
  write_to_file txt
  exit(code)
end

def sideband_println(msg : String)
  txt = "#{SIDEBAND_PREFIX}#{msg.ends_with?("\n") ? msg : msg + "\n"}"
  STDERR.puts txt
  write_to_file txt
end

def bytes_to_utf8_str(buf : Bytes) : String
  String.new(buf) rescue buf.map(&.chr).join
end

def compute_line_col(text : String, index_in_bytes : Int32)
  line = 1
  col = 1
  bcount = 0
  text.each_codepoint do |cp|
    ch = cp.chr  # returns a one-char String
    bytes = ch.bytesize
    break if bcount + bytes > index_in_bytes
    if cp == 10
      line += 1
      col = 1
    else
      col += 1
    end
    bcount += bytes
  end
  {line: line, column: col}
end

def run_cmd_capture(cmd : Array(String), cwd = nil)
  output_io = IO::Memory.new
  error_io = IO::Memory.new
  process = Process.run(
    command: cmd[0],
    args: cmd[1..-1],
    chdir: cwd,
    output: output_io,
    error: error_io,
  )
  out = output_io.gets_to_end
  err = error_io.gets_to_end
  code = process.exit_code
  { code: code, out: out, err: err }
end

def exec_replace(cmd : Array(String), cwd : String)
  Process.exec(cmd[0], cmd[1..-1], chdir: cwd)
end

# Extract repo path (org/repo.git) from SSH_ORIGINAL_COMMAND argument
def extract_org_repo(original_cmd : String)
  arg = nil
  if original_cmd.includes?(" ")
    parts = original_cmd.split(" ", 2)
    arg = parts[1].strip
    if (arg.starts_with?("'") && arg.ends_with?("'")) || (arg.starts_with?("\"") && arg.ends_with?("\""))
      arg = arg[1..-2]
    end
  end
  return {org: nil, repo: nil, host: nil} if arg.nil? || arg.empty?

  # If URL
  begin
    if arg.starts_with?("http://") || arg.starts_with?("https://")
      u = URI.parse(arg)
      segs = u.path.split("/").reject &.empty?
      if segs.size >= 2
        org = segs[-2]
        repo = segs[-1]
        repo = repo[0, repo.size - 4] if repo.ends_with?(".git")
        host = "#{u.scheme}://#{u.host}"
        return {org: org, repo: repo, host: host}
      end
    end
  rescue
  end

  # If filesystem or bare "org/repo.git" or "/srv/git/org/repo.git"
  segs = arg.split("/").reject &.empty?
  if segs.size >= 2
    org = segs[-2]
    repo = segs[-1]
    repo = repo[0, repo.size - 4] if repo.ends_with?(".git")
    return {org: org, repo: repo, host: nil}
  end

  {org: nil, repo: nil, host: nil}
end

# Parse pkt-line data into lines (simple)
def parse_pkt_lines(data : Bytes) : Array(String)
  lines = [] of String
  i = 0
  while i < data.size
    break if i + 4 > data.size
    len_hex = data[i,4].to_s
    if len_hex == "0000"
      i += 4
      next
    end
    len = len_hex.to_i(16)
    break if len < 4
    payload_len = len - 4
    break if i + 4 + payload_len > data.size
    payload = data[i+4, payload_len].to_s
    lines << payload
    i += 4 + payload_len
  end
  lines
end

# ---------- Step 0: get params ----------
# Endpoint base for auth and pipeline trigger (adjust as needed)
AUTH_URL = "https://gitfoss.dev/_ssh/auth"
TRIGGER_BASE = "https://gitfoss.dev"  # base host for pipeline triggers

write_to_file("", true)

# ---------- Step 1: gather inputs ----------

user = ARGV[0]
key_blob = ARGV[1]
fingerprint = ARGV[2] rescue nil

if user.nil? || key_blob.nil?
  die_with_message "usage: ssh-key-wrapper <user> <key_blob> [fingerprint]", 2
end

SSH_ORIGINAL_COMMAND = ENV["SSH_ORIGINAL_COMMAND"]?.try(&.to_s) || ""

if SSH_ORIGINAL_COMMAND.chomp == ""
  die_with_message "Script has not been invoked by ssh. Missing environment variable SSH_ORIGINAL_COMMAND.", 2
end

write_to_file("new client: #{user} -> #{key_blob} (#{fingerprint || "none"})\n")
write_to_file("command: #{SSH_ORIGINAL_COMMAND}\n")

org_repo = extract_org_repo(SSH_ORIGINAL_COMMAND)
org = org_repo[:org]
repo = org_repo[:repo]
host = org_repo[:host]

write_to_file("target: #{org}/#{repo}.git\n")

repoDir = ""

if ARGV.size < 2
  die_with_message "usage: ssh-command <user> <key_blob> [fingerprint]\n", 2
end

# ---------- Step 2: validate via HTTP POST (auth) ----------
begin
  parsed = URI.parse(AUTH_URL)
  info = extract_org_repo(SSH_ORIGINAL_COMMAND)
  resp = Fetch.post(AUTH_URL.to_s, {
    headers: {
      "Content-Type" => "application/json",
      "Accept" => "application/json",
    },
    body: {
      "command" => SSH_ORIGINAL_COMMAND,
      "username" => user,
      "repoSlug" => "#{info[:org]}/#{info[:repo]}.git",
      "publicKey" => key_blob,
      "publicKeyFingerprint" => fingerprint || "",
    },
  })

  if resp.status != HTTP::Status::OK
    die_with_message("Key validation: disallowed (#{resp.status_code} - #{resp.status_message})")
  else
    # authorized
    write_to_file("Key validation: allowed (200 - OK)\n")
    write_to_file("> #{resp.body.to_s}\n")
    json = JSON.parse(resp.body)
    repoDir = json.as_h["gitRepositoryDir"].to_s
  end
rescue ex : Exception
  die_with_message("Key validation: error: #{ex.message}")
end

if repoDir.chomp == ""
  die_with_message("Could not determine repo_dir from response.")
end

write_to_file("repo_dir: #{repoDir}\n")

# ---------- Step 3: dispatch by SSH_ORIGINAL_COMMAND ----------
if SSH_ORIGINAL_COMMAND.starts_with?("git-receive-pack") # push
  mode = :receive
elsif SSH_ORIGINAL_COMMAND.starts_with?("git-upload-pack") || SSH_ORIGINAL_COMMAND.starts_with?("git-upload-archive") # clone/fetch/pull
  mode = :upload
else
  die_with_message("Unsupported command: only git-receive-pack, git-upload-pack, git-upload-archive are allowed")
end

write_to_file("mode: #{mode.to_s}\n")

# For upload (fetch/pull), just exec the original command (auth already done).
if mode == :upload
  exec_replace(["/bin/sh", "-c", SSH_ORIGINAL_COMMAND.split(" ")[0]], repoDir)
end

# ---------- From here: mode == :receive (push) ----------
# Read stdin fully into memory (preamble + pack) but enforce size limit
tmpdir = Dir.tempdir #("gitpack-")
raw = "".to_slice # Bytes.new
total = 0
begin
  loop do
    buf = Bytes.new(CHUNK_SIZE)
    slice = buf[0, CHUNK_SIZE]
    read = STDIN.read(slice) rescue 0
    break if read == 0
    total += read
    if total > MAX_PACK_BYTES
      FileUtils.rm_rf(tmpdir) rescue nil
      die_with_message("Pack exceeds limit of #{MAX_PACK_BYTES} bytes; rejecting push")
    end
    raw += buf[0, read]
  end
rescue ex
  FileUtils.rm_rf(tmpdir) rescue nil
  die_with_message("Error reading stdin: #{ex.message}")
end

# Split preamble (pkt-lines) and pack data by locating "PACK" header
pack_sig = "PACK".to_slice.as(Bytes)
pack_index = raw.index(pack_sig) || -1
pre_bytes = raw[0, pack_index] if pack_index >= 0
pre_bytes ||= raw
pack_bytes = raw[pack_index, raw.size - pack_index] if pack_index >= 0
pack_bytes ||= "".to_slice

# Parse pkt-lines to get ref updates
pkt_lines = parse_pkt_lines(pre_bytes)
ref_updates = [] of Hash(String, String)
pkt_lines.each do |line|
  next if line.nil? || line.empty?
  if line.includes?("\0")
    line = line.split("\0", 2)[0]
  end
  parts = line.split(" ")
  if parts.size >= 3 && parts[0] =~ /^[0-9a-f]{40}$/ && parts[1] =~ /^[0-9a-f]{40}$/
    old_sha = parts[0]
    new_sha = parts[1]
    refname = parts[2].strip
    ref_updates << {"old" => old_sha, "new" => new_sha, "ref" => refname}
  end
end

pushed_refs = [] of Hash(String, String)
ref_updates.each do |r|
  ref = r["ref"].as(String)
  if ref.starts_with?("refs/heads/")
    pushed_refs << {
      "type" => "branch",
      "name" => ref["refs/heads/".size .. -1],
      "new" => r["new"],
    }
  elsif ref.starts_with?("refs/tags/")
    pushed_refs << {
      "type" => "tag",
      "name" => ref["refs/tags/".size .. -1],
      "new" => r["new"],
    }
  else
    pushed_refs << {
      "type" => "other",
      "name" => ref,
      "new" => r["new"],
    }
  end
end

# Write pack bytes to file (if any)
pack_path = File.join(tmpdir, "incoming.pack")
File.open(pack_path, "w") do |f|
  f.write(pack_bytes) if pack_bytes.size > 0
end

# If pack exists, index/unpack into temp bare repo and scan blobs
detections = [] of Hash(String, String | Int32)

if File.size(pack_path).to_i > 0
  repo_dir = File.join(tmpdir, "repo.git")
  if !Dir.exists?(repo_dir)
    run_cmd_capture(["git", "init", "--bare", repo_dir])
  end

  pack_dest_dir = File.join(repo_dir, "objects", "pack")
  FileUtils.mkdir_p(pack_dest_dir)
  pack_dest = File.join(pack_dest_dir, "incoming.pack")
  FileUtils.mv(pack_path, pack_dest) rescue FileUtils.cp(pack_path, pack_dest)

  index_res = run_cmd_capture(["git", "index-pack", "--stdin"], repo_dir)
  if index_res[:code] != 0
    index_res = run_cmd_capture(["git", "index-pack", pack_dest], repo_dir)
    if index_res[:code] != 0
      FileUtils.rm_rf(tmpdir) rescue nil
      die_with_message("git index-pack failed: #{index_res[:err]}")
    end
  end

  idx_file = Dir.glob(File.join(pack_dest_dir, "*.idx"))[0] rescue nil
  unless idx_file
    FileUtils.rm_rf(tmpdir) rescue nil
    die_with_message("git index-pack did not produce .idx; aborting")
  end

  verify = run_cmd_capture(["git", "verify-pack", "-v", idx_file], repo_dir)
  if verify[:code] != 0
    FileUtils.rm_rf(tmpdir) rescue nil
    die_with_message("git verify-pack failed: #{verify[:err]}")
  end

  blob_shas = [] of String
  verify[:out].each_line do |l|
    l = l.chomp
    next if l.empty?
    parts = l.split
    sha = parts[0]
    next unless sha && sha =~ /^[0-9a-f]{40}$/
    t = run_cmd_capture(["git", "cat-file", "-t", sha], repo_dir)
    next if t[:code] != 0
    typ = t[:out].strip
    if typ == "blob"
      blob_shas << sha
    end
  end

  blob_shas.each do |sha|
    break if detections.size >= MAX_MATCHES
    cat = run_cmd_capture(["git", "cat-file", "-p", sha], repo_dir)
    next if cat[:code] != 0
    blob_text = bytes_to_utf8_str(cat[:out].to_slice)

    PATTERNS.each do |pat|
      start = 0
      while m = pat.match(blob_text, start)
        match_str = m[0]
        prefix = blob_text[0, m.begin(0)]
        byte_offset = prefix.bytesize
        lc = compute_line_col(blob_text, byte_offset)

        paths = [] of String
        revs = run_cmd_capture(["git", "rev-list", "--all"], repo_dir)
        if revs[:code] == 0
          revs[:out].each_line do |commit|
            commit = commit.strip
            next if commit.empty?
            ls = run_cmd_capture(["git", "ls-tree", "-r", "--full-tree", commit], repo_dir)
            next if ls[:code] != 0
            ls[:out].each_line do |line|
              if m2 = line.match(/^\d+\s+blob\s+([0-9a-f]{40})\t(.+)$/)
                if m2[1] == sha
                  paths << m2[2]
                end
              end
            end
            break if !paths.empty?
          end
        end

        detections << {
          "pattern" => pat.to_s,
          "blob" => sha,
          "paths" => paths.join(", "),
          "line" => lc[:line],
          "column" => lc[:column],
          "excerpt" => match_str
        }

        start = m.end(0)
        break if detections.size >= MAX_MATCHES
      end
      break if detections.size >= MAX_MATCHES
    end
  end
end

# ---------- Decision ----------
if detections.size > 0
  sideband_println("Push rejected: #{detections.size} secret(s) detected in incoming pack")
  detections.each_with_index do |d, i|
    sideband_println("Match #{i+1}: pattern=#{d["pattern"]} blob=#{d["blob"]} paths=#{d["paths"]} line=#{d["line"]} col=#{d["column"]}")
    sideband_println("Excerpt: #{d["excerpt"]}")
  end
  FileUtils.rm_rf(tmpdir) rescue nil
  exit 1
end

# ---------- Trigger pipelines (only when auth OK and no detections) ----------
if org.nil? || repo.nil?
  sideband_println("Warning: could not determine org/repo from SSH_ORIGINAL_COMMAND; skipping pipeline triggers")
else
  pushed_refs.each do |pr|
    next unless pr["type"] == "branch" || pr["type"] == "tag"
    commit = pr["new"].as(String)
    branch = pr["type"] == "branch" ? (pr["name"].as(String)) : nil
    tag = pr["type"] == "tag" ? (pr["name"].as(String)) : nil
    next if commit.nil? || commit.strip.empty? || commit == ("0" * 40)

    path = "/#{org}/#{repo}/pipeline/trigger"
    params = URI::Params.build do |p|
      p.add "commit", URI.encode_path(commit)
      p.add "branch", URI.encode_path(branch) if branch
      p.add "tag", URI.encode_path(tag) if tag
      p.add "trigger_username", URI.encode_path(user)
      p.add "trigger_pubkey", URI.encode_path(key_blob)
      p.add "trigger_fingerprint", URI.encode_path(fingerprint || "")
    end
    url = "#{(host || TRIGGER_BASE)}#{path}?#{params.to_s}"

    begin
      res = Fetch.post(url)
      sideband_println("Pipeline trigger for #{pr["type"]}: HTTP #{res.status_code}")
    rescue ex
      sideband_println("Pipeline trigger error: #{ex.message}")
    end
  end
end

FileUtils.rm_rf(tmpdir) rescue nil
sideband_println("No secrets detected; allowing push")
exec_replace(["/bin/sh", "-c", SSH_ORIGINAL_COMMAND])

.cr
Crystal
(text/x-crystal)
#!/usr/bin/env crystal
# ssh-key-wrapper-crystal
#
# - Uses only stdlib
# - Args: argv[0]=user argv[1]=key_blob argv[2]=fingerprint (fingerprint optional)
# - Validates key via HTTP POST to /auth/ssh
# - For git-receive-pack (push): scans incoming pack for secrets, triggers pipelines, enforces policy
# - For git-upload-pack / git-upload-archive (fetch/pull/clone): enforces auth and allows fetch (no server-side pack scanning)
# - Build: crystal build --release ssh-key-wrapper-crystal.cr -o /usr/local/bin/ssh-key-wrapper-crystal
require "file_utils"
require "json"
require "process"
require "uri"
require "io"

require "./fetch"

# ---------- Config ----------
MAX_PACK_BYTES = 50 * 1024 * 1024  # 50 MiB
CHUNK_SIZE = 64 * 1024             # 64 KiB
OVERLAP = 4096                     # 4 KiB overlap
MAX_MATCHES = 500
LOG_FILE = "/var/log/gitfoss/git_ssh.log"

PATTERNS = [
  /AKIA[0-9A-Z]{16}/,
  /AIza[0-9A-Za-z\-_]{35}/,
  /-----BEGIN PRIVATE KEY-----/,
  /password\s*[:=]\s*[^ \t\r\n]{6,}/i
]

# prefix
SIDEBAND_PREFIX = "0008" + "\x02"
SIDEBAND_ERR_PREFIX = "0008" + "\x03"

# ---------- Helpers ----------
def write_to_file(msg : String, divider : Bool = false)
  txt = msg.gsub(SIDEBAND_PREFIX, "").gsub(SIDEBAND_ERR_PREFIX, "")
  File.open(LOG_FILE, mode: "a", encoding: "utf-8") do |file|
    file.flock_exclusive # prevent concurrent writes
    file.print "\n#{"=" * 80}\n\n" if divider
    file.print "[#{Time.utc.to_unix}] #{txt.ends_with?("\n") ? txt : txt + "\n"}"
    file.flush
    file.flock_unlock
  end
end

def die_with_message(msg : String, code = 1)
  txt = "#{SIDEBAND_ERR_PREFIX}#{msg.ends_with?("\n") ? msg : msg + "\n"}"
  STDERR.puts txt
  write_to_file txt
  exit(code)
end

def sideband_println(msg : String)
  txt = "#{SIDEBAND_PREFIX}#{msg.ends_with?("\n") ? msg : msg + "\n"}"
  STDERR.puts txt
  write_to_file txt
end

def bytes_to_utf8_str(buf : Bytes) : String
  String.new(buf) rescue buf.map(&.chr).join
end

def compute_line_col(text : String, index_in_bytes : Int32)
  line = 1
  col = 1
  bcount = 0
  text.each_codepoint do |cp|
    ch = cp.chr  # returns a one-char String
    bytes = ch.bytesize
    break if bcount + bytes > index_in_bytes
    if cp == 10
      line += 1
      col = 1
    else
      col += 1
    end
    bcount += bytes
  end
  {line: line, column: col}
end

def run_cmd_capture(cmd : Array(String), cwd = nil)
  output_io = IO::Memory.new
  error_io = IO::Memory.new
  process = Process.run(
    command: cmd[0],
    args: cmd[1..-1],
    chdir: cwd,
    output: output_io,
    error: error_io,
  )
  out = output_io.gets_to_end
  err = error_io.gets_to_end
  code = process.exit_code
  { code: code, out: out, err: err }
end

def exec_replace(cmd : Array(String), cwd : String)
  Process.exec(cmd[0], cmd[1..-1], chdir: cwd)
end

# Extract repo path (org/repo.git) from SSH_ORIGINAL_COMMAND argument
def extract_org_repo(original_cmd : String)
  arg = nil
  if original_cmd.includes?(" ")
    parts = original_cmd.split(" ", 2)
    arg = parts[1].strip
    if (arg.starts_with?("'") && arg.ends_with?("'")) || (arg.starts_with?("\"") && arg.ends_with?("\""))
      arg = arg[1..-2]
    end
  end
  return {org: nil, repo: nil, host: nil} if arg.nil? || arg.empty?

  # If URL
  begin
    if arg.starts_with?("http://") || arg.starts_with?("https://")
      u = URI.parse(arg)
      segs = u.path.split("/").reject &.empty?
      if segs.size >= 2
        org = segs[-2]
        repo = segs[-1]
        repo = repo[0, repo.size - 4] if repo.ends_with?(".git")
        host = "#{u.scheme}://#{u.host}"
        return {org: org, repo: repo, host: host}
      end
    end
  rescue
  end

  # If filesystem or bare "org/repo.git" or "/srv/git/org/repo.git"
  segs = arg.split("/").reject &.empty?
  if segs.size >= 2
    org = segs[-2]
    repo = segs[-1]
    repo = repo[0, repo.size - 4] if repo.ends_with?(".git")
    return {org: org, repo: repo, host: nil}
  end

  {org: nil, repo: nil, host: nil}
end

# Parse pkt-line data into lines (simple)
def parse_pkt_lines(data : Bytes) : Array(String)
  lines = [] of String
  i = 0
  while i < data.size
    break if i + 4 > data.size
    len_hex = data[i,4].to_s
    if len_hex == "0000"
      i += 4
      next
    end
    len = len_hex.to_i(16)
    break if len < 4
    payload_len = len - 4
    break if i + 4 + payload_len > data.size
    payload = data[i+4, payload_len].to_s
    lines << payload
    i += 4 + payload_len
  end
  lines
end

# ---------- Step 0: get params ----------
# Endpoint base for auth and pipeline trigger (adjust as needed)
AUTH_URL = "https://gitfoss.dev/_ssh/auth"
TRIGGER_BASE = "https://gitfoss.dev"  # base host for pipeline triggers

write_to_file("", true)

# ---------- Step 1: gather inputs ----------

user = ARGV[0]
key_blob = ARGV[1]
fingerprint = ARGV[2] rescue nil

if user.nil? || key_blob.nil?
  die_with_message "usage: ssh-key-wrapper <user> <key_blob> [fingerprint]", 2
end

SSH_ORIGINAL_COMMAND = ENV["SSH_ORIGINAL_COMMAND"]?.try(&.to_s) || ""

if SSH_ORIGINAL_COMMAND.chomp == ""
  die_with_message "Script has not been invoked by ssh. Missing environment variable SSH_ORIGINAL_COMMAND.", 2
end

write_to_file("new client: #{user} -> #{key_blob} (#{fingerprint || "none"})\n")
write_to_file("command: #{SSH_ORIGINAL_COMMAND}\n")

org_repo = extract_org_repo(SSH_ORIGINAL_COMMAND)
org = org_repo[:org]
repo = org_repo[:repo]
host = org_repo[:host]

write_to_file("target: #{org}/#{repo}.git\n")

repoDir = ""

if ARGV.size < 2
  die_with_message "usage: ssh-command <user> <key_blob> [fingerprint]\n", 2
end

# ---------- Step 2: validate via HTTP POST (auth) ----------
begin
  parsed = URI.parse(AUTH_URL)
  info = extract_org_repo(SSH_ORIGINAL_COMMAND)
  resp = Fetch.post(AUTH_URL.to_s, {
    headers: {
      "Content-Type" => "application/json",
      "Accept" => "application/json",
    },
    body: {
      "command" => SSH_ORIGINAL_COMMAND,
      "username" => user,
      "repoSlug" => "#{info[:org]}/#{info[:repo]}.git",
      "publicKey" => key_blob,
      "publicKeyFingerprint" => fingerprint || "",
    },
  })

  if resp.status != HTTP::Status::OK
    die_with_message("Key validation: disallowed (#{resp.status_code} - #{resp.status_message})")
  else
    # authorized
    write_to_file("Key validation: allowed (200 - OK)\n")
    write_to_file("> #{resp.body.to_s}\n")
    json = JSON.parse(resp.body)
    repoDir = json.as_h["gitRepositoryDir"].to_s
  end
rescue ex : Exception
  die_with_message("Key validation: error: #{ex.message}")
end

if repoDir.chomp == ""
  die_with_message("Could not determine repo_dir from response.")
end

write_to_file("repo_dir: #{repoDir}\n")

# ---------- Step 3: dispatch by SSH_ORIGINAL_COMMAND ----------
if SSH_ORIGINAL_COMMAND.starts_with?("git-receive-pack") # push
  mode = :receive
elsif SSH_ORIGINAL_COMMAND.starts_with?("git-upload-pack") || SSH_ORIGINAL_COMMAND.starts_with?("git-upload-archive") # clone/fetch/pull
  mode = :upload
else
  die_with_message("Unsupported command: only git-receive-pack, git-upload-pack, git-upload-archive are allowed")
end

write_to_file("mode: #{mode.to_s}\n")

# For upload (fetch/pull), just exec the original command (auth already done).
if mode == :upload
  exec_replace(["/bin/sh", "-c", SSH_ORIGINAL_COMMAND.split(" ")[0]], repoDir)
end

# ---------- From here: mode == :receive (push) ----------
# Read stdin fully into memory (preamble + pack) but enforce size limit
tmpdir = Dir.tempdir #("gitpack-")
raw = "".to_slice # Bytes.new
total = 0
begin
  loop do
    buf = Bytes.new(CHUNK_SIZE)
    slice = buf[0, CHUNK_SIZE]
    read = STDIN.read(slice) rescue 0
    break if read == 0
    total += read
    if total > MAX_PACK_BYTES
      FileUtils.rm_rf(tmpdir) rescue nil
      die_with_message("Pack exceeds limit of #{MAX_PACK_BYTES} bytes; rejecting push")
    end
    raw += buf[0, read]
  end
rescue ex
  FileUtils.rm_rf(tmpdir) rescue nil
  die_with_message("Error reading stdin: #{ex.message}")
end

# Split preamble (pkt-lines) and pack data by locating "PACK" header
pack_sig = "PACK".to_slice.as(Bytes)
pack_index = raw.index(pack_sig) || -1
pre_bytes = raw[0, pack_index] if pack_index >= 0
pre_bytes ||= raw
pack_bytes = raw[pack_index, raw.size - pack_index] if pack_index >= 0
pack_bytes ||= "".to_slice

# Parse pkt-lines to get ref updates
pkt_lines = parse_pkt_lines(pre_bytes)
ref_updates = [] of Hash(String, String)
pkt_lines.each do |line|
  next if line.nil? || line.empty?
  if line.includes?("\0")
    line = line.split("\0", 2)[0]
  end
  parts = line.split(" ")
  if parts.size >= 3 && parts[0] =~ /^[0-9a-f]{40}$/ && parts[1] =~ /^[0-9a-f]{40}$/
    old_sha = parts[0]
    new_sha = parts[1]
    refname = parts[2].strip
    ref_updates << {"old" => old_sha, "new" => new_sha, "ref" => refname}
  end
end

pushed_refs = [] of Hash(String, String)
ref_updates.each do |r|
  ref = r["ref"].as(String)
  if ref.starts_with?("refs/heads/")
    pushed_refs << {
      "type" => "branch",
      "name" => ref["refs/heads/".size .. -1],
      "new" => r["new"],
    }
  elsif ref.starts_with?("refs/tags/")
    pushed_refs << {
      "type" => "tag",
      "name" => ref["refs/tags/".size .. -1],
      "new" => r["new"],
    }
  else
    pushed_refs << {
      "type" => "other",
      "name" => ref,
      "new" => r["new"],
    }
  end
end

# Write pack bytes to file (if any)
pack_path = File.join(tmpdir, "incoming.pack")
File.open(pack_path, "w") do |f|
  f.write(pack_bytes) if pack_bytes.size > 0
end

# If pack exists, index/unpack into temp bare repo and scan blobs
detections = [] of Hash(String, String | Int32)

if File.size(pack_path).to_i > 0
  repo_dir = File.join(tmpdir, "repo.git")
  if !Dir.exists?(repo_dir)
    run_cmd_capture(["git", "init", "--bare", repo_dir])
  end

  pack_dest_dir = File.join(repo_dir, "objects", "pack")
  FileUtils.mkdir_p(pack_dest_dir)
  pack_dest = File.join(pack_dest_dir, "incoming.pack")
  FileUtils.mv(pack_path, pack_dest) rescue FileUtils.cp(pack_path, pack_dest)

  index_res = run_cmd_capture(["git", "index-pack", "--stdin"], repo_dir)
  if index_res[:code] != 0
    index_res = run_cmd_capture(["git", "index-pack", pack_dest], repo_dir)
    if index_res[:code] != 0
      FileUtils.rm_rf(tmpdir) rescue nil
      die_with_message("git index-pack failed: #{index_res[:err]}")
    end
  end

  idx_file = Dir.glob(File.join(pack_dest_dir, "*.idx"))[0] rescue nil
  unless idx_file
    FileUtils.rm_rf(tmpdir) rescue nil
    die_with_message("git index-pack did not produce .idx; aborting")
  end

  verify = run_cmd_capture(["git", "verify-pack", "-v", idx_file], repo_dir)
  if verify[:code] != 0
    FileUtils.rm_rf(tmpdir) rescue nil
    die_with_message("git verify-pack failed: #{verify[:err]}")
  end

  blob_shas = [] of String
  verify[:out].each_line do |l|
    l = l.chomp
    next if l.empty?
    parts = l.split
    sha = parts[0]
    next unless sha && sha =~ /^[0-9a-f]{40}$/
    t = run_cmd_capture(["git", "cat-file", "-t", sha], repo_dir)
    next if t[:code] != 0
    typ = t[:out].strip
    if typ == "blob"
      blob_shas << sha
    end
  end

  blob_shas.each do |sha|
    break if detections.size >= MAX_MATCHES
    cat = run_cmd_capture(["git", "cat-file", "-p", sha], repo_dir)
    next if cat[:code] != 0
    blob_text = bytes_to_utf8_str(cat[:out].to_slice)

    PATTERNS.each do |pat|
      start = 0
      while m = pat.match(blob_text, start)
        match_str = m[0]
        prefix = blob_text[0, m.begin(0)]
        byte_offset = prefix.bytesize
        lc = compute_line_col(blob_text, byte_offset)

        paths = [] of String
        revs = run_cmd_capture(["git", "rev-list", "--all"], repo_dir)
        if revs[:code] == 0
          revs[:out].each_line do |commit|
            commit = commit.strip
            next if commit.empty?
            ls = run_cmd_capture(["git", "ls-tree", "-r", "--full-tree", commit], repo_dir)
            next if ls[:code] != 0
            ls[:out].each_line do |line|
              if m2 = line.match(/^\d+\s+blob\s+([0-9a-f]{40})\t(.+)$/)
                if m2[1] == sha
                  paths << m2[2]
                end
              end
            end
            break if !paths.empty?
          end
        end

        detections << {
          "pattern" => pat.to_s,
          "blob" => sha,
          "paths" => paths.join(", "),
          "line" => lc[:line],
          "column" => lc[:column],
          "excerpt" => match_str
        }

        start = m.end(0)
        break if detections.size >= MAX_MATCHES
      end
      break if detections.size >= MAX_MATCHES
    end
  end
end

# ---------- Decision ----------
if detections.size > 0
  sideband_println("Push rejected: #{detections.size} secret(s) detected in incoming pack")
  detections.each_with_index do |d, i|
    sideband_println("Match #{i+1}: pattern=#{d["pattern"]} blob=#{d["blob"]} paths=#{d["paths"]} line=#{d["line"]} col=#{d["column"]}")
    sideband_println("Excerpt: #{d["excerpt"]}")
  end
  FileUtils.rm_rf(tmpdir) rescue nil
  exit 1
end

# ---------- Trigger pipelines (only when auth OK and no detections) ----------
if org.nil? || repo.nil?
  sideband_println("Warning: could not determine org/repo from SSH_ORIGINAL_COMMAND; skipping pipeline triggers")
else
  pushed_refs.each do |pr|
    next unless pr["type"] == "branch" || pr["type"] == "tag"
    commit = pr["new"].as(String)
    branch = pr["type"] == "branch" ? (pr["name"].as(String)) : nil
    tag = pr["type"] == "tag" ? (pr["name"].as(String)) : nil
    next if commit.nil? || commit.strip.empty? || commit == ("0" * 40)

    path = "/#{org}/#{repo}/pipeline/trigger"
    params = URI::Params.build do |p|
      p.add "commit", URI.encode_path(commit)
      p.add "branch", URI.encode_path(branch) if branch
      p.add "tag", URI.encode_path(tag) if tag
      p.add "trigger_username", URI.encode_path(user)
      p.add "trigger_pubkey", URI.encode_path(key_blob)
      p.add "trigger_fingerprint", URI.encode_path(fingerprint || "")
    end
    url = "#{(host || TRIGGER_BASE)}#{path}?#{params.to_s}"

    begin
      res = Fetch.post(url)
      sideband_println("Pipeline trigger for #{pr["type"]}: HTTP #{res.status_code}")
    rescue ex
      sideband_println("Pipeline trigger error: #{ex.message}")
    end
  end
end

FileUtils.rm_rf(tmpdir) rescue nil
sideband_println("No secrets detected; allowing push")
exec_replace(["/bin/sh", "-c", SSH_ORIGINAL_COMMAND])

GitFOSS • v0.2.0 (#7b50ebe) • MIT License