require "file_utils"
require "json"
require "process"
require "uri"
require "io"
require "./fetch"
MAX_PACK_BYTES = 50 * 1024 * 1024
CHUNK_SIZE = 64 * 1024
OVERLAP = 4096
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
]
SIDEBAND_PREFIX = "0008" + "\x02"
SIDEBAND_ERR_PREFIX = "0008" + "\x03"
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
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
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))
Process.exec(cmd[0], cmd[1..-1])
end
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?
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
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
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
AUTH_URL = "https://gitfoss.dev/_ssh/auth"
TRIGGER_BASE = "https://gitfoss.dev"
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", true)
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
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
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
write_to_file("repo_dir: #{repoDir}\n")
if SSH_ORIGINAL_COMMAND.starts_with?("git-receive-pack")
mode = :receive
elsif SSH_ORIGINAL_COMMAND.starts_with?("git-upload-pack") || SSH_ORIGINAL_COMMAND.starts_with?("git-upload-archive")
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")
if mode == :upload
exec_replace(["/bin/sh", "-c", SSH_ORIGINAL_COMMAND])
end
tmpdir = Dir.tempdir
raw = "".to_slice
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
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
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
pack_path = File.join(tmpdir, "incoming.pack")
File.open(pack_path, "w") do |f|
f.write(pack_bytes) if pack_bytes.size > 0
end
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
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
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)
require "file_utils"
require "json"
require "process"
require "uri"
require "io"
require "./fetch"
MAX_PACK_BYTES = 50 * 1024 * 1024
CHUNK_SIZE = 64 * 1024
OVERLAP = 4096
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
]
SIDEBAND_PREFIX = "0008" + "\x02"
SIDEBAND_ERR_PREFIX = "0008" + "\x03"
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
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
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))
Process.exec(cmd[0], cmd[1..-1])
end
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?
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
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
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
AUTH_URL = "https://gitfoss.dev/_ssh/auth"
TRIGGER_BASE = "https://gitfoss.dev"
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", true)
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
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
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
write_to_file("repo_dir: #{repoDir}\n")
if SSH_ORIGINAL_COMMAND.starts_with?("git-receive-pack")
mode = :receive
elsif SSH_ORIGINAL_COMMAND.starts_with?("git-upload-pack") || SSH_ORIGINAL_COMMAND.starts_with?("git-upload-archive")
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")
if mode == :upload
exec_replace(["/bin/sh", "-c", SSH_ORIGINAL_COMMAND])
end
tmpdir = Dir.tempdir
raw = "".to_slice
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
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
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
pack_path = File.join(tmpdir, "incoming.pack")
File.open(pack_path, "w") do |f|
f.write(pack_bytes) if pack_bytes.size > 0
end
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
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
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])