mirror of
https://github.com/tiennm99/goclaw.git
synced 2026-09-13 12:20:18 +00:00
* feat(secure-cli): Phase 1 schema + storage delta (issue #82) Adds `adapter_name` column to secure_cli_binaries and `credential_type` + `host_scope` columns to secure_cli_user_credentials. LookupByBinary LEFT JOIN now projects AdapterName, UserCredentialType, and UserHostScope. Extends SecureCLIStore with SetUserCredentialsTyped(ctx, binaryID, userID, env, credType, hostScope); legacy SetUserCredentials delegates to typed variant with nil/nil for backward compat. PG migration 73 + RequiredSchemaVersion bumped to 73. SQLite incremental migrations (versions 39–41) + SchemaVersion 42. 7 SQLite + 6 PG integration tests covering schema, round-trip, NULL legacy, LookupByBinary projection. Fixes #82 * feat(secure-cli): Phase 2 CredentialAdapter framework + passthrough (issue #82) Adds CredentialAdapter interface + Injection{ArgvPrefix,Env,Cleanup,ScrubValues} struct. Registry resolves by name; falls back to passthrough for empty/unknown. passthroughAdapter is default no-op — all existing presets (gh/aws/gcloud/kubectl/terraform/gws) behave bit-for-bit identically. Per-request WithScrubBag(ctx) + AddScrubValuesCtx + ScrubCredentialsCtx for multi-tenant secret isolation (replaces package-global slice). Hook in executeCredentialed between env merge and exec: resolve adapter by bin.AdapterName, reject non-passthrough in sandbox, call Prepare, splice ArgvPrefix, merge Env, defer Cleanup, register ScrubValues. Audit log security.system_env_injection records adapter name + env key names + argv_prefix_len + sha256(host_scope) — NEVER values. CLIPreset.AdapterName field added; empty default for legacy presets. 12 unit tests covering passthrough no-op, registry fallback + nil safety, Injection shape, hashHostScope determinism, sortedKeys, scrub bag per-request isolation + concurrent + short-value guard. Fixes #82 * feat(secure-cli): Phase 2b extensibility helpers + psql stub adapter (issue #82) Proves CredentialAdapter framework generalizes beyond git. Adds materializeEphemeral(ctx, content, prefix) shared helper — 0600 tmpfile + idempotent atomic.Bool cleanup latch; memfd intentionally rejected (resolves "self" against child process). Adds psqlAdapter consuming framework end-to-end via PGPASSFILE pattern; libpq-spec .pgpass escaping for `:` and `\`. Registers psql preset with AdapterName: "psql" (production UI for typed creds lands in v2). Interface validation gate passes: Injection shape unchanged, hook is psql-agnostic, no special branch needed. Tests: ephemeral write/cleanup/concurrent/zero-content; psql routing/content/escaping/error paths/registration. Fixes #82 * feat(secure-cli): git adapter PAT + SSH implementation (issue #82) Phase 3 — PAT path - gitAdapter PAT branch via GIT_CONFIG_COUNT/KEY_0/VALUE_0 env (git 2.31+) so the token never lands on argv, .git/config, or remote URL - Host-scope enforcement with IDN normalization (golang.org/x/net/idna) and embedded-userinfo rejection in URL parsing - CVE-2018-17456 mitigation: resolve remote URLs via `git config --get` not `git remote get-url` to dodge ext::sh protocol handler injection - Case-insensitive DenyArgs blocking `-c http.`, `-c credential.`, `-c core.sshcommand`, `config --global/--system`, `credential-helper`, bare `daemon` - New WithExecCwd / ExecCwdFromContext context helpers — fixes a latent design gap where the adapter's pre-flight `git config --get` ran in goclaw's daemon CWD instead of the agent's repo - 14 unit tests covering subcommand routing, host normalization, scp-form parsing, userinfo rejection, CRLF token rejection, CVE-2018-17456 regression, DenyArgs preset coverage - 3 integration tests against a local TLS git-http-backend server proving end-to-end clone + fetch + host-mismatch rejection with zero token leakage into the cloned .git/config Phase 4 — SSH path - gitAdapter ssh_key branch materializes per-call 0600 tmpfile via the Phase 2b materializeEphemeral helper, injects GIT_SSH_COMMAND with -o IdentitiesOnly=yes -o BatchMode=yes -o StrictHostKeyChecking=accept-new, idempotent cleanup - ValidateSSHKey using golang.org/x/crypto/ssh rejects passphrase-protected keys via ErrSSHKeyPassphraseUnsupported sentinel - 8 unit tests covering passphrase rejection, env shape, cleanup lifecycle, host-mismatch reuse, malformed-blob rejection - 3 integration tests proving tmpfile 0600 lifecycle, env propagation to child process, cleanup-on-exec-failure, no-orphan-on-rejection - 2 new i18n keys (en/vi/zh) for SSH-passphrase and SSH-key-invalid Verification: go vet clean, go build ./... clean, go build -tags sqliteonly ./... clean, go test -race ./internal/tools/ pass, go test -tags integration ./tests/integration/ -run TestGitAdapter pass. Refs #82 * feat(secure-cli): UI presets + typed credential HTTP path + i18n (issue #82) Phase 5. Typed PUT envelope with {error:{code,message}, error_key} for field-level errors. CliCredentialGitFields React component (PAT/SSH picker, host_scope input, CRLF→LF normalization, masked-edit). 17 i18n keys × 3 locales (backend + frontend). i18n parity test. * feat(secure-cli): audit log schema + adapter framework docs (issue #82) Phase 6. emitSystemEnvInjectionAudit helper centralizes security.system_env_injection slog with host_scope_hash (SHA-256 8 hex, plaintext hostname omitted for PII safety). Audit shape pinned by TestEmitSystemEnvInjectionAudit_*. New docs: git-credential-adapter.md (user guide), credential-adapter-playbook.md (R1 implementer guide w/ kubectl/docker/npm/aws/psql worked mappings). 09-security.md § 14 trust-boundary diagram + SSH TOFU + SIGKILL caveats. 03-tools-system.md § 8a. Changelog entry. * docs(journal): issue #82 CLI credential adapters shipped Retrospective covering 6 commits across phases 1-6: framework, git PAT/SSH, psql stub, UI/i18n, audit log schema, docs. Notes memfd-drop rationale, TOFU/SIGKILL caveats, sentinel-length lesson from audit-shape tests. * fix(secure-cli): honor per-request scrub bag on success+failure paths (#82) Sandbox and host exec paths called the non-Ctx ScrubCredentials, so adapter ScrubValues registered into the per-request bag during Prepare (e.g. GitLab glpat-, Bitbucket app-passwords, Azure DevOps PATs, Gitea tokens, SSH key tmpfile paths) were ignored on stdout/stderr returned to the agent. Only the package-global regex pass ran — covering ghp_ but nothing else. Switch all four exec/sandbox call sites to ScrubCredentialsCtx so the bag is consulted. Also scrub the slog adapter_cleanup_failed line — os.Remove errors embed the full tmpfile path. Add 5 regression tests that pin success path, failure path, negative control (proves the bag is what catches the sentinel), classic-PAT sanity, and timeout path no-leak. Locks AC6 against non-GitHub PAT providers. * fix(secure-cli): address review-pr #89 findings - ui/web: SSH key Textarea was 'text-xs' on all viewports, triggering iOS Safari auto-zoom on focus. Switch to 'text-base md:text-xs' so mobile renders 16px (no zoom) while desktop keeps compact mono font. - psql adapter: add on-disk pgpass tmpfile path to ScrubValues. psql echoes 'could not open password file "<path>"' on IO errors, so the path needs scrubbing alongside the password. Mirrors the git SSH adapter pattern. Update test assertion accordingly. - psql adapter: replace literal 'nil' context with 'context.TODO()' to silence staticcheck SA1012.
226 lines
7.1 KiB
Go
226 lines
7.1 KiB
Go
package tools
|
|
|
|
import (
|
|
"context"
|
|
"regexp"
|
|
"strings"
|
|
"sync"
|
|
)
|
|
|
|
// Credential patterns to scrub from tool output before returning to the LLM.
|
|
// Inspired by zeroclaw's credential scrubbing system.
|
|
var credentialPatterns = []*regexp.Regexp{
|
|
// OpenAI
|
|
regexp.MustCompile(`sk-[a-zA-Z0-9]{20,}`),
|
|
// Anthropic
|
|
regexp.MustCompile(`sk-ant-[a-zA-Z0-9-]{20,}`),
|
|
// GitHub personal access tokens
|
|
regexp.MustCompile(`ghp_[a-zA-Z0-9]{36}`),
|
|
regexp.MustCompile(`gho_[a-zA-Z0-9]{36}`),
|
|
regexp.MustCompile(`ghu_[a-zA-Z0-9]{36}`),
|
|
regexp.MustCompile(`ghs_[a-zA-Z0-9]{36}`),
|
|
regexp.MustCompile(`ghr_[a-zA-Z0-9]{36}`),
|
|
// AWS
|
|
regexp.MustCompile(`AKIA[A-Z0-9]{16}`),
|
|
// Generic key=value patterns (case-insensitive)
|
|
regexp.MustCompile(`(?i)(api[_-]?key|token|secret|password|bearer|authorization)\s*[:=]\s*["']?\S{8,}["']?`),
|
|
|
|
// Connection strings (PostgreSQL, MySQL, MongoDB, Redis, AMQP)
|
|
regexp.MustCompile(`(?i)(postgres|postgresql|mysql|mongodb|redis|amqp)://[^\s"']+`),
|
|
// Generic KEY=/SECRET=/CREDENTIAL= env-var patterns (skip already-redacted [REDACTED] values)
|
|
regexp.MustCompile(`(?i)[A-Z_]*(KEY|SECRET|CREDENTIAL|PRIVATE)[A-Z_]*\s*=\s*[^\[\s]{8,}`),
|
|
// DSN/DATABASE_URL env vars (skip already-redacted values)
|
|
regexp.MustCompile(`(?i)(DSN|DATABASE_URL|REDIS_URL|MONGO_URI)\s*=\s*[^\[\s]{8,}`),
|
|
// VIRTUAL_* env vars (internal runtime config, should not leak)
|
|
regexp.MustCompile(`(?i)VIRTUAL_[A-Z_]+\s*=\s*[^\[\s]{4,}`),
|
|
// Long hex strings (64+ chars) — likely encryption keys, hashes, or secrets
|
|
regexp.MustCompile(`[a-fA-F0-9]{64,}`),
|
|
}
|
|
|
|
const redactedPlaceholder = "[REDACTED]"
|
|
const serverIPPlaceholder = "[SERVER_IP]"
|
|
|
|
// dynamicScrubValues holds runtime-discovered values to scrub (e.g., server IPs).
|
|
var (
|
|
dynamicScrubMu sync.RWMutex
|
|
dynamicScrubValues []string
|
|
)
|
|
|
|
// credentialScrubValues holds credential values to scrub (replaced with [REDACTED]).
|
|
// Separate from dynamicScrubValues which use [SERVER_IP] placeholder.
|
|
var (
|
|
credentialScrubMu sync.RWMutex
|
|
credentialScrubValues []string
|
|
)
|
|
|
|
// AddDynamicScrubValues adds exact string values to the dynamic scrub list.
|
|
// Thread-safe. Deduplicates. Empty strings are ignored.
|
|
func AddDynamicScrubValues(values ...string) {
|
|
dynamicScrubMu.Lock()
|
|
defer dynamicScrubMu.Unlock()
|
|
|
|
existing := make(map[string]bool, len(dynamicScrubValues))
|
|
for _, v := range dynamicScrubValues {
|
|
existing[v] = true
|
|
}
|
|
for _, v := range values {
|
|
if v != "" && !existing[v] {
|
|
dynamicScrubValues = append(dynamicScrubValues, v)
|
|
existing[v] = true
|
|
}
|
|
}
|
|
}
|
|
|
|
// DynamicScrubCount returns the number of dynamic scrub values registered.
|
|
func DynamicScrubCount() int {
|
|
dynamicScrubMu.RLock()
|
|
defer dynamicScrubMu.RUnlock()
|
|
return len(dynamicScrubValues)
|
|
}
|
|
|
|
// ResetDynamicScrubValues clears all dynamic scrub values. For testing only.
|
|
func ResetDynamicScrubValues() {
|
|
dynamicScrubMu.Lock()
|
|
defer dynamicScrubMu.Unlock()
|
|
dynamicScrubValues = nil
|
|
}
|
|
|
|
// ResetCredentialScrubValues clears all credential scrub values.
|
|
// Called when credentials are updated to prevent stale values from accumulating.
|
|
func ResetCredentialScrubValues() {
|
|
credentialScrubMu.Lock()
|
|
defer credentialScrubMu.Unlock()
|
|
credentialScrubValues = nil
|
|
}
|
|
|
|
// AddCredentialScrubValues adds credential values to the scrub list.
|
|
// These are replaced with [REDACTED] (not [SERVER_IP]).
|
|
// Thread-safe. Deduplicates. Empty/short strings ignored.
|
|
func AddCredentialScrubValues(values ...string) {
|
|
credentialScrubMu.Lock()
|
|
defer credentialScrubMu.Unlock()
|
|
|
|
existing := make(map[string]bool, len(credentialScrubValues))
|
|
for _, v := range credentialScrubValues {
|
|
existing[v] = true
|
|
}
|
|
for _, v := range values {
|
|
// Skip short values to avoid false-positive scrubbing
|
|
if len(v) >= 6 && !existing[v] {
|
|
credentialScrubValues = append(credentialScrubValues, v)
|
|
existing[v] = true
|
|
}
|
|
}
|
|
}
|
|
|
|
// --- Per-request scrub bag (multi-tenant safe) ---
|
|
//
|
|
// The package-global credentialScrubValues above accumulates across all
|
|
// goroutines for the life of the process. For the adapter pipeline (Phase 2+)
|
|
// each exec gets its own scrub bag via context so tenant A's credentials never
|
|
// reach tenant B's output, even when adapters run concurrently.
|
|
|
|
type scrubBag struct {
|
|
mu sync.RWMutex
|
|
values []string
|
|
}
|
|
|
|
type scrubBagKey struct{}
|
|
|
|
type execCwdKey struct{}
|
|
|
|
// WithExecCwd returns a context carrying the working directory the
|
|
// credentialed exec will use. Adapters consult this so any pre-flight
|
|
// sub-exec (e.g. `git config --get remote.origin.url`) runs inside the
|
|
// caller's repo, not goclaw's daemon CWD.
|
|
func WithExecCwd(ctx context.Context, cwd string) context.Context {
|
|
if cwd == "" {
|
|
return ctx
|
|
}
|
|
return context.WithValue(ctx, execCwdKey{}, cwd)
|
|
}
|
|
|
|
// ExecCwdFromContext returns the cwd planted by WithExecCwd, or empty.
|
|
func ExecCwdFromContext(ctx context.Context) string {
|
|
if s, ok := ctx.Value(execCwdKey{}).(string); ok {
|
|
return s
|
|
}
|
|
return ""
|
|
}
|
|
|
|
// WithScrubBag returns a context carrying a fresh per-request scrub list.
|
|
// Call at the top of executeCredentialed; pass the returned ctx downstream.
|
|
func WithScrubBag(ctx context.Context) context.Context {
|
|
return context.WithValue(ctx, scrubBagKey{}, &scrubBag{})
|
|
}
|
|
|
|
// AddScrubValuesCtx appends adapter-derived secrets to the per-request bag.
|
|
// No-op when ctx has no bag (legacy callers). Skips values shorter than 6
|
|
// chars to avoid false-positive matches like "x" or "1".
|
|
func AddScrubValuesCtx(ctx context.Context, vs ...string) {
|
|
bag, ok := ctx.Value(scrubBagKey{}).(*scrubBag)
|
|
if !ok {
|
|
return
|
|
}
|
|
bag.mu.Lock()
|
|
defer bag.mu.Unlock()
|
|
for _, v := range vs {
|
|
if len(v) >= 6 {
|
|
bag.values = append(bag.values, v)
|
|
}
|
|
}
|
|
}
|
|
|
|
// ScrubCredentialsCtx runs the same regex pass as ScrubCredentials AND any
|
|
// per-request bag values, but does NOT consult the package-global slice.
|
|
// Use this in the adapter pipeline so one tenant's secrets cannot leak into
|
|
// another tenant's output through the shared global.
|
|
func ScrubCredentialsCtx(ctx context.Context, text string) string {
|
|
for _, pat := range credentialPatterns {
|
|
text = pat.ReplaceAllString(text, redactedPlaceholder)
|
|
}
|
|
if bag, ok := ctx.Value(scrubBagKey{}).(*scrubBag); ok {
|
|
bag.mu.RLock()
|
|
vals := append([]string(nil), bag.values...)
|
|
bag.mu.RUnlock()
|
|
for _, v := range vals {
|
|
text = strings.ReplaceAll(text, v, redactedPlaceholder)
|
|
}
|
|
}
|
|
// Dynamic server-IP values still apply — they're infra metadata, not creds.
|
|
dynamicScrubMu.RLock()
|
|
dyn := dynamicScrubValues
|
|
dynamicScrubMu.RUnlock()
|
|
for _, v := range dyn {
|
|
text = strings.ReplaceAll(text, v, serverIPPlaceholder)
|
|
}
|
|
return text
|
|
}
|
|
|
|
// ScrubCredentials replaces known credential patterns and dynamic values in text.
|
|
func ScrubCredentials(text string) string {
|
|
for _, pat := range credentialPatterns {
|
|
text = pat.ReplaceAllString(text, redactedPlaceholder)
|
|
}
|
|
|
|
// Credential values (from credentialed exec)
|
|
credentialScrubMu.RLock()
|
|
credVals := credentialScrubValues
|
|
credentialScrubMu.RUnlock()
|
|
|
|
for _, v := range credVals {
|
|
text = strings.ReplaceAll(text, v, redactedPlaceholder)
|
|
}
|
|
|
|
// Dynamic values (server IPs, etc.)
|
|
dynamicScrubMu.RLock()
|
|
vals := dynamicScrubValues
|
|
dynamicScrubMu.RUnlock()
|
|
|
|
for _, v := range vals {
|
|
text = strings.ReplaceAll(text, v, serverIPPlaceholder)
|
|
}
|
|
|
|
return text
|
|
}
|