hop: initial project state

This commit is contained in:
Hop
2026-07-11 08:35:07 -07:00
parent ed2cba60db
commit a70773826f
14 changed files with 960 additions and 174 deletions
+245
View File
@@ -0,0 +1,245 @@
package hop
import (
"math"
"regexp"
"sort"
"strings"
)
const redactedMarkerPrefix = "[REDACTED:"
type secretPattern struct {
pattern *regexp.Regexp
group int
kind string
priority int
}
type secretCandidate struct {
start int
end int
kind string
priority int
}
var promptSecretPatterns = []secretPattern{
{
pattern: regexp.MustCompile(`(?s)-----BEGIN [A-Z0-9 ]*PRIVATE KEY-----.*?-----END [A-Z0-9 ]*PRIVATE KEY-----`),
kind: "private_key",
priority: 120,
},
{
pattern: regexp.MustCompile(`(?s)<secret>.*?</secret>`),
kind: "explicit_secret",
priority: 120,
},
{
pattern: regexp.MustCompile(`\bsk-ant-[A-Za-z0-9_-]{20,}\b`),
kind: "api_key",
priority: 110,
},
{
pattern: regexp.MustCompile(`\bsk-(?:proj-|svcacct-)?[A-Za-z0-9_-]{20,}\b`),
kind: "api_key",
priority: 110,
},
{
pattern: regexp.MustCompile(`\b(?:gh[pousr]_[A-Za-z0-9]{20,}|github_pat_[A-Za-z0-9_]{20,})\b`),
kind: "access_token",
priority: 110,
},
{
pattern: regexp.MustCompile(`\b(?:sk|rk)_(?:live|test)_[A-Za-z0-9]{16,}\b`),
kind: "api_key",
priority: 110,
},
{
pattern: regexp.MustCompile(`\bAIza[0-9A-Za-z_-]{30,}\b`),
kind: "api_key",
priority: 110,
},
{
pattern: regexp.MustCompile(`\bxox[baprs]-[A-Za-z0-9-]{20,}\b`),
kind: "access_token",
priority: 110,
},
{
pattern: regexp.MustCompile(`\b(?:AKIA|ASIA|AIDA|AROA|AIPA|ANPA|ANVA|AGPA)[A-Z0-9]{16}\b`),
kind: "access_key",
priority: 110,
},
{
pattern: regexp.MustCompile(`\beyJ[A-Za-z0-9_-]{8,}\.[A-Za-z0-9_-]{8,}\.[A-Za-z0-9_-]{8,}\b`),
kind: "auth_token",
priority: 105,
},
{
pattern: regexp.MustCompile(`(?i)(authorization[ \t]*:[ \t]*(?:bearer|token|basic)[ \t]+)([A-Za-z0-9._~+/=-]{12,})`),
group: 2,
kind: "auth_token",
priority: 100,
},
{
pattern: regexp.MustCompile(`(?i)\b(?:postgres(?:ql)?|mysql|mongodb(?:\+srv)?|redis|https?)://([^@\s/]+)@`),
group: 1,
kind: "connection_credential",
priority: 100,
},
{
pattern: regexp.MustCompile(
`(?i)\b[a-z0-9_.-]*(?:api[ _-]?key|access[ _-]?key|secret[ _-]?key|access[ _-]?token|auth[ _-]?token|client[ _-]?secret|api[ _-]?secret|password|passwd|credential|token|secret|key)\b[ \t]*(?:=|:|is\b)[ \t]*["']?([^\s"'` + "`" + `]{12,})`,
),
group: 1,
kind: "credential",
priority: 80,
},
}
// RedactPromptSecrets removes high-confidence credentials before a prompt is
// passed to any durable Hop component. It returns only category/count metadata;
// secret values, hashes, and byte positions are deliberately discarded.
func RedactPromptSecrets(prompt string) (string, []PromptRedaction) {
if prompt == "" {
return prompt, nil
}
var candidates []secretCandidate
for _, detector := range promptSecretPatterns {
for _, match := range detector.pattern.FindAllStringSubmatchIndex(prompt, -1) {
index := detector.group * 2
if index+1 >= len(match) || match[index] < 0 || match[index+1] <= match[index] {
continue
}
start, end := match[index], match[index+1]
if detector.kind == "credential" {
for end > start && strings.ContainsRune(".,;:!?)]}", rune(prompt[end-1])) {
end--
}
}
if end <= start || strings.HasPrefix(prompt[start:end], redactedMarkerPrefix) {
continue
}
if detector.kind == "credential" && !likelyCredentialValue(prompt[start:end]) {
continue
}
candidates = append(candidates, secretCandidate{
start: start,
end: end,
kind: detector.kind,
priority: detector.priority,
})
}
}
if len(candidates) == 0 {
return prompt, nil
}
sort.Slice(candidates, func(i, j int) bool {
if candidates[i].priority != candidates[j].priority {
return candidates[i].priority > candidates[j].priority
}
if candidates[i].start != candidates[j].start {
return candidates[i].start < candidates[j].start
}
return candidates[i].end > candidates[j].end
})
selected := make([]secretCandidate, 0, len(candidates))
for _, candidate := range candidates {
overlaps := false
for _, existing := range selected {
if candidate.start < existing.end && existing.start < candidate.end {
overlaps = true
break
}
}
if !overlaps {
selected = append(selected, candidate)
}
}
sort.Slice(selected, func(i, j int) bool { return selected[i].start < selected[j].start })
counts := make(map[string]int)
var redacted strings.Builder
cursor := 0
for _, candidate := range selected {
redacted.WriteString(prompt[cursor:candidate.start])
redacted.WriteString(redactedMarkerPrefix)
redacted.WriteString(candidate.kind)
redacted.WriteByte(']')
counts[candidate.kind]++
cursor = candidate.end
}
redacted.WriteString(prompt[cursor:])
kinds := make([]string, 0, len(counts))
for kind := range counts {
kinds = append(kinds, kind)
}
sort.Strings(kinds)
findings := make([]PromptRedaction, 0, len(kinds))
for _, kind := range kinds {
findings = append(findings, PromptRedaction{Kind: kind, Count: counts[kind]})
}
return redacted.String(), findings
}
func redactSecretStrings(values []string) ([]string, []PromptRedaction) {
redacted := make([]string, len(values))
counts := make(map[string]int)
for index, value := range values {
var findings []PromptRedaction
redacted[index], findings = RedactPromptSecrets(value)
for _, finding := range findings {
counts[finding.Kind] += finding.Count
}
}
kinds := make([]string, 0, len(counts))
for kind := range counts {
kinds = append(kinds, kind)
}
sort.Strings(kinds)
findings := make([]PromptRedaction, 0, len(kinds))
for _, kind := range kinds {
findings = append(findings, PromptRedaction{Kind: kind, Count: counts[kind]})
}
return redacted, findings
}
func likelyCredentialValue(value string) bool {
if len(value) < 12 {
return false
}
classes := 0
var lower, upper, digit, symbol bool
counts := make(map[byte]int)
for index := 0; index < len(value); index++ {
character := value[index]
counts[character]++
switch {
case character >= 'a' && character <= 'z':
lower = true
case character >= 'A' && character <= 'Z':
upper = true
case character >= '0' && character <= '9':
digit = true
default:
symbol = true
}
}
for _, present := range []bool{lower, upper, digit, symbol} {
if present {
classes++
}
}
if classes < 2 {
return false
}
entropy := 0.0
length := float64(len(value))
for _, count := range counts {
probability := float64(count) / length
entropy -= probability * math.Log2(probability)
}
return entropy >= 3.0
}