mirror of
https://github.com/tiennm99/goclaw.git
synced 2026-07-28 10:19:56 +00:00
141 lines
5.1 KiB
Go
141 lines
5.1 KiB
Go
package tools
|
|
|
|
import (
|
|
"context"
|
|
"crypto/sha256"
|
|
"encoding/hex"
|
|
"log/slog"
|
|
"sort"
|
|
"sync"
|
|
|
|
"github.com/nextlevelbuilder/goclaw/internal/store"
|
|
)
|
|
|
|
// CredentialAdapter transforms a user credential into the shape a specific CLI
|
|
// binary expects. Each binary's adapter_name column routes runtime exec to one
|
|
// of these. The default "passthrough" adapter is a no-op so unrelated presets
|
|
// (gh, aws, kubectl, gcloud, terraform, gws) keep their legacy env-injection
|
|
// path bit-for-bit.
|
|
//
|
|
// Adapters never see or log raw secret bytes — `Prepare` returns an Injection
|
|
// describing how to mutate argv/env, plus a Cleanup hook for ephemeral files
|
|
// and a ScrubValues slice that the caller registers with the per-request
|
|
// scrubber (see scrub.go WithScrubBag).
|
|
type CredentialAdapter interface {
|
|
Name() string
|
|
ShouldInject(argv []string) bool
|
|
Prepare(ctx context.Context, bin *store.SecureCLIBinary, cred *store.SecureCLIUserCredential, argv []string) (*Injection, error)
|
|
}
|
|
|
|
// Injection is the adapter's instruction set for one exec call.
|
|
//
|
|
// - ArgvPrefix is spliced in BETWEEN binary and user-supplied args (so
|
|
// `git clone X` becomes `git <prefix...> clone X`). The slice is not
|
|
// re-quoted or shell-parsed — it goes straight into exec.Command.
|
|
// - Env is merged on top of the base env after legacy ValidateGrantEnvVars
|
|
// has already vetted the user-controlled credential blob. Keys here come
|
|
// from hard-coded adapter code only.
|
|
// - Cleanup is deferred AFTER the synchronous exec returns; safe to be nil.
|
|
// - ScrubValues lists adapter-derived secrets the per-request scrubber must
|
|
// redact from stdout/stderr/error messages before they reach the LLM.
|
|
type Injection struct {
|
|
ArgvPrefix []string
|
|
Env map[string]string
|
|
Cleanup func() error
|
|
ScrubValues []string
|
|
}
|
|
|
|
var (
|
|
adaptersMu sync.RWMutex
|
|
adapters = map[string]CredentialAdapter{
|
|
"passthrough": passthroughAdapter{},
|
|
}
|
|
)
|
|
|
|
// RegisterAdapter registers a CredentialAdapter under its Name(). Idempotent —
|
|
// re-registering the same name overwrites. Intended for init() of each
|
|
// adapter file (e.g. credential_adapter_git.go in Phase 3).
|
|
func RegisterAdapter(a CredentialAdapter) {
|
|
if a == nil {
|
|
return
|
|
}
|
|
adaptersMu.Lock()
|
|
defer adaptersMu.Unlock()
|
|
adapters[a.Name()] = a
|
|
}
|
|
|
|
// AdapterFor resolves an adapter by name, falling back to passthrough on
|
|
// empty/unknown so an operator-set adapter_name typo can never break exec —
|
|
// it just degrades to legacy behavior with a clear audit trail.
|
|
func AdapterFor(name string) CredentialAdapter {
|
|
adaptersMu.RLock()
|
|
defer adaptersMu.RUnlock()
|
|
if a, ok := adapters[name]; ok {
|
|
return a
|
|
}
|
|
return adapters["passthrough"]
|
|
}
|
|
|
|
// passthroughAdapter is the default. It does nothing — preserves the legacy
|
|
// env-injection-only path so every existing preset behaves identically.
|
|
type passthroughAdapter struct{}
|
|
|
|
func (passthroughAdapter) Name() string { return "passthrough" }
|
|
func (passthroughAdapter) ShouldInject([]string) bool { return false }
|
|
func (passthroughAdapter) Prepare(context.Context, *store.SecureCLIBinary, *store.SecureCLIUserCredential, []string) (*Injection, error) {
|
|
return &Injection{}, nil
|
|
}
|
|
|
|
// hashHostScope returns the SHA-256 hex prefix of the host-scope value, or
|
|
// "none" when nil. Used in audit logs instead of plaintext hostname so SIEM
|
|
// pipelines can correlate without exposing internal infra hostnames.
|
|
func hashHostScope(hs *string) string {
|
|
if hs == nil || *hs == "" {
|
|
return "none"
|
|
}
|
|
sum := sha256.Sum256([]byte(*hs))
|
|
return hex.EncodeToString(sum[:4]) // 8 hex chars
|
|
}
|
|
|
|
// sortedKeys returns env map keys in deterministic order for audit logs.
|
|
func sortedKeys(m map[string]string) []string {
|
|
if len(m) == 0 {
|
|
return nil
|
|
}
|
|
keys := make([]string, 0, len(m))
|
|
for k := range m {
|
|
keys = append(keys, k)
|
|
}
|
|
sort.Strings(keys)
|
|
return keys
|
|
}
|
|
|
|
// emitSystemEnvInjectionAudit is the single source of truth for the
|
|
// `security.system_env_injection` slog line. Operators grep for this event
|
|
// name in their log aggregator (see docs/09-security.md). The field schema is
|
|
// pinned by `TestEmitSystemEnvInjectionAudit_FieldSchema` — any change here
|
|
// must update both that test and the operator-facing docs.
|
|
//
|
|
// Fields:
|
|
// - adapter: adapter name (e.g. "git", "passthrough")
|
|
// - binary: binary name (e.g. "git", "gh")
|
|
// - user_id: tenant user UUID (or empty for global-only contexts)
|
|
// - credential_source: source selected by runtime ("user", "context", "agent", or empty)
|
|
// - env_keys: sorted env-var NAMES (never values)
|
|
// - argv_prefix_len: number of argv elements prepended (NOT their content)
|
|
// - host_scope_hash: SHA-256 first 8 hex chars of host_scope (or "none")
|
|
func emitSystemEnvInjectionAudit(adapter, binary, userID, credentialSource string, inj *Injection, hostScope *string) {
|
|
if inj == nil {
|
|
return
|
|
}
|
|
slog.Warn("security.system_env_injection",
|
|
"adapter", adapter,
|
|
"user_id", userID,
|
|
"credential_source", credentialSource,
|
|
"binary", binary,
|
|
"env_keys", sortedKeys(inj.Env),
|
|
"argv_prefix_len", len(inj.ArgvPrefix),
|
|
"host_scope_hash", hashHostScope(hostScope),
|
|
)
|
|
}
|