mirror of
https://github.com/tiennm99/goclaw.git
synced 2026-07-28 06:19:43 +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.
255 lines
7.3 KiB
Go
255 lines
7.3 KiB
Go
package tools
|
|
|
|
import (
|
|
"context"
|
|
"errors"
|
|
"sync"
|
|
"sync/atomic"
|
|
"testing"
|
|
|
|
"github.com/nextlevelbuilder/goclaw/internal/store"
|
|
)
|
|
|
|
func TestPassthroughAdapter_NoOp(t *testing.T) {
|
|
a := AdapterFor("passthrough")
|
|
if a.Name() != "passthrough" {
|
|
t.Fatalf("Name()=%q, want passthrough", a.Name())
|
|
}
|
|
|
|
cases := [][]string{
|
|
nil,
|
|
{},
|
|
{"clone", "https://github.com/x/y.git"},
|
|
{"--help"},
|
|
}
|
|
for _, argv := range cases {
|
|
if a.ShouldInject(argv) {
|
|
t.Fatalf("ShouldInject(%v)=true, want false (passthrough must never inject)", argv)
|
|
}
|
|
}
|
|
|
|
inj, err := a.Prepare(context.Background(), &store.SecureCLIBinary{}, nil, []string{"clone"})
|
|
if err != nil {
|
|
t.Fatalf("Prepare: %v", err)
|
|
}
|
|
if inj == nil {
|
|
t.Fatalf("Prepare returned nil Injection")
|
|
}
|
|
if len(inj.ArgvPrefix) != 0 || len(inj.Env) != 0 || len(inj.ScrubValues) != 0 {
|
|
t.Fatalf("passthrough produced non-empty Injection: %+v", inj)
|
|
}
|
|
if inj.Cleanup != nil {
|
|
t.Fatalf("passthrough must not register Cleanup")
|
|
}
|
|
}
|
|
|
|
func TestAdapterFor_FallsBackToPassthrough(t *testing.T) {
|
|
cases := []string{"", "nonexistent", "git-not-registered-yet"}
|
|
for _, name := range cases {
|
|
a := AdapterFor(name)
|
|
if a.Name() != "passthrough" {
|
|
t.Fatalf("AdapterFor(%q)=%q, want passthrough", name, a.Name())
|
|
}
|
|
}
|
|
}
|
|
|
|
// recordingAdapter captures Prepare calls so the hook-point test can verify
|
|
// the runtime wired the adapter correctly.
|
|
type recordingAdapter struct {
|
|
name string
|
|
shouldFn func([]string) bool
|
|
prepareErr error
|
|
calls int32
|
|
gotArgv []string
|
|
}
|
|
|
|
func (r *recordingAdapter) Name() string { return r.name }
|
|
func (r *recordingAdapter) ShouldInject(argv []string) bool {
|
|
if r.shouldFn != nil {
|
|
return r.shouldFn(argv)
|
|
}
|
|
return true
|
|
}
|
|
func (r *recordingAdapter) Prepare(_ context.Context, _ *store.SecureCLIBinary, _ *store.SecureCLIUserCredential, argv []string) (*Injection, error) {
|
|
atomic.AddInt32(&r.calls, 1)
|
|
r.gotArgv = append([]string(nil), argv...)
|
|
if r.prepareErr != nil {
|
|
return nil, r.prepareErr
|
|
}
|
|
return &Injection{}, nil
|
|
}
|
|
|
|
func TestRegisterAdapter_RoundTrip(t *testing.T) {
|
|
name := "test-roundtrip-adapter"
|
|
t.Cleanup(func() {
|
|
adaptersMu.Lock()
|
|
delete(adapters, name)
|
|
adaptersMu.Unlock()
|
|
})
|
|
|
|
r := &recordingAdapter{name: name}
|
|
RegisterAdapter(r)
|
|
|
|
got := AdapterFor(name)
|
|
if got.Name() != name {
|
|
t.Fatalf("AdapterFor(%q)=%q, want %q", name, got.Name(), name)
|
|
}
|
|
}
|
|
|
|
func TestRegisterAdapter_NilIgnored(t *testing.T) {
|
|
// Should not panic, should not affect registry.
|
|
before := AdapterFor("passthrough")
|
|
RegisterAdapter(nil)
|
|
after := AdapterFor("passthrough")
|
|
if before.Name() != after.Name() {
|
|
t.Fatalf("nil RegisterAdapter changed passthrough resolution")
|
|
}
|
|
}
|
|
|
|
func TestInjection_StructShape(t *testing.T) {
|
|
// Locks Injection field types so future refactors can't silently change
|
|
// the contract the adapter pipeline depends on.
|
|
flag := false
|
|
inj := Injection{
|
|
ArgvPrefix: []string{"-c", "foo=bar"},
|
|
Env: map[string]string{"GIT_TERMINAL_PROMPT": "0"},
|
|
Cleanup: func() error { flag = true; return nil },
|
|
ScrubValues: []string{"secretvalue"},
|
|
}
|
|
if inj.ArgvPrefix[0] != "-c" || inj.ArgvPrefix[1] != "foo=bar" {
|
|
t.Fatalf("ArgvPrefix not preserved")
|
|
}
|
|
if inj.Env["GIT_TERMINAL_PROMPT"] != "0" {
|
|
t.Fatalf("Env not preserved")
|
|
}
|
|
if err := inj.Cleanup(); err != nil || !flag {
|
|
t.Fatalf("Cleanup did not fire: err=%v flag=%v", err, flag)
|
|
}
|
|
if inj.ScrubValues[0] != "secretvalue" {
|
|
t.Fatalf("ScrubValues not preserved")
|
|
}
|
|
}
|
|
|
|
func TestHashHostScope(t *testing.T) {
|
|
if got := hashHostScope(nil); got != "none" {
|
|
t.Fatalf("hashHostScope(nil)=%q, want none", got)
|
|
}
|
|
empty := ""
|
|
if got := hashHostScope(&empty); got != "none" {
|
|
t.Fatalf("hashHostScope(&\"\")=%q, want none", got)
|
|
}
|
|
|
|
a := "github.com"
|
|
b := "gitlab.com"
|
|
ha1 := hashHostScope(&a)
|
|
ha2 := hashHostScope(&a)
|
|
hb := hashHostScope(&b)
|
|
|
|
if ha1 != ha2 {
|
|
t.Fatalf("same input produced different hashes: %q vs %q", ha1, ha2)
|
|
}
|
|
if ha1 == hb {
|
|
t.Fatalf("different inputs collided: %q == %q", ha1, hb)
|
|
}
|
|
if len(ha1) != 8 {
|
|
t.Fatalf("hash length=%d, want 8 hex chars", len(ha1))
|
|
}
|
|
// Must not contain the plaintext hostname anywhere.
|
|
if ha1 == a {
|
|
t.Fatalf("hash leaked plaintext hostname")
|
|
}
|
|
}
|
|
|
|
func TestSortedKeys_Deterministic(t *testing.T) {
|
|
in := map[string]string{
|
|
"GIT_SSH_COMMAND": "ssh -i /tmp/x",
|
|
"GIT_TERMINAL_PROMPT": "0",
|
|
"GIT_CONFIG_COUNT": "1",
|
|
}
|
|
got := sortedKeys(in)
|
|
want := []string{"GIT_CONFIG_COUNT", "GIT_SSH_COMMAND", "GIT_TERMINAL_PROMPT"}
|
|
if len(got) != len(want) {
|
|
t.Fatalf("sortedKeys len=%d, want %d", len(got), len(want))
|
|
}
|
|
for i := range got {
|
|
if got[i] != want[i] {
|
|
t.Fatalf("sortedKeys[%d]=%q, want %q", i, got[i], want[i])
|
|
}
|
|
}
|
|
|
|
if sortedKeys(nil) != nil {
|
|
t.Fatalf("sortedKeys(nil) must return nil")
|
|
}
|
|
if sortedKeys(map[string]string{}) != nil {
|
|
t.Fatalf("sortedKeys(empty map) must return nil")
|
|
}
|
|
}
|
|
|
|
func TestScrubBag_PerRequestIsolation(t *testing.T) {
|
|
// Two parallel contexts must not see each other's scrub values.
|
|
ctxA := WithScrubBag(context.Background())
|
|
ctxB := WithScrubBag(context.Background())
|
|
|
|
AddScrubValuesCtx(ctxA, "secretAAAAAA")
|
|
AddScrubValuesCtx(ctxB, "secretBBBBBB")
|
|
|
|
gotA := ScrubCredentialsCtx(ctxA, "value: secretAAAAAA leaked + secretBBBBBB visible")
|
|
gotB := ScrubCredentialsCtx(ctxB, "value: secretAAAAAA visible + secretBBBBBB leaked")
|
|
|
|
// A's bag redacts A's secret, leaves B's untouched.
|
|
if got := gotA; got != "value: [REDACTED] leaked + secretBBBBBB visible" {
|
|
t.Fatalf("ctxA scrub leaked across tenants: %q", got)
|
|
}
|
|
if got := gotB; got != "value: secretAAAAAA visible + [REDACTED] leaked" {
|
|
t.Fatalf("ctxB scrub leaked across tenants: %q", got)
|
|
}
|
|
}
|
|
|
|
func TestScrubBag_NoBagInContextIsNoop(t *testing.T) {
|
|
// AddScrubValuesCtx without a bag must not panic.
|
|
AddScrubValuesCtx(context.Background(), "should-not-crash")
|
|
// ScrubCredentialsCtx without a bag still runs regex pass.
|
|
got := ScrubCredentialsCtx(context.Background(), "api_key=ghp_aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa")
|
|
if got == "api_key=ghp_aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa" {
|
|
t.Fatalf("regex pass should still scrub even without bag, got %q", got)
|
|
}
|
|
}
|
|
|
|
func TestScrubBag_ConcurrentAdds(t *testing.T) {
|
|
// Race-detector smoke test for the per-bag mutex.
|
|
ctx := WithScrubBag(context.Background())
|
|
var wg sync.WaitGroup
|
|
for i := 0; i < 16; i++ {
|
|
wg.Add(1)
|
|
go func(i int) {
|
|
defer wg.Done()
|
|
AddScrubValuesCtx(ctx, "concurrentvalueXXXXXX")
|
|
}(i)
|
|
}
|
|
wg.Wait()
|
|
out := ScrubCredentialsCtx(ctx, "echo concurrentvalueXXXXXX done")
|
|
if out != "echo [REDACTED] done" {
|
|
t.Fatalf("concurrent bag adds failed: %q", out)
|
|
}
|
|
}
|
|
|
|
func TestScrubBag_ShortValuesIgnored(t *testing.T) {
|
|
ctx := WithScrubBag(context.Background())
|
|
AddScrubValuesCtx(ctx, "x", "ab", "abc", "abcde") // all < 6 chars
|
|
got := ScrubCredentialsCtx(ctx, "abc abcde x ab")
|
|
if got != "abc abcde x ab" {
|
|
t.Fatalf("short values were redacted (false positive): %q", got)
|
|
}
|
|
}
|
|
|
|
// recordingAdapter is the kind of probe a future hook-point integration test
|
|
// would register. The compile-time assertion here keeps the interface stable.
|
|
func TestRecordingAdapter_ImplementsInterface(t *testing.T) {
|
|
var _ CredentialAdapter = (*recordingAdapter)(nil)
|
|
// Sanity: an adapter returning prepareErr does not panic.
|
|
r := &recordingAdapter{name: "boom", prepareErr: errors.New("forced")}
|
|
if _, err := r.Prepare(context.Background(), nil, nil, []string{"x"}); err == nil {
|
|
t.Fatalf("Prepare did not return forced error")
|
|
}
|
|
}
|