142 lines
4.8 KiB
Go
142 lines
4.8 KiB
Go
package hop
|
|
|
|
import (
|
|
"bytes"
|
|
"context"
|
|
"io/fs"
|
|
"os"
|
|
"path/filepath"
|
|
"strings"
|
|
"testing"
|
|
)
|
|
|
|
func TestRedactPromptSecrets(t *testing.T) {
|
|
apiKey := "sk-proj-" + strings.Repeat("A7", 24)
|
|
githubToken := "ghp_" + strings.Repeat("b9", 20)
|
|
bearer := strings.Repeat("abcDEF123._-", 3)
|
|
generic := strings.Repeat("customKey9", 4)
|
|
privateKey := "-----BEGIN PRIVATE KEY-----\n" + strings.Repeat("base64material", 4) + "\n-----END PRIVATE KEY-----"
|
|
|
|
tests := []struct {
|
|
name string
|
|
prompt string
|
|
secret string
|
|
marker string
|
|
}{
|
|
{"provider API key", "Use " + apiKey + " for this request", apiKey, "[REDACTED:api_key]"},
|
|
{"provider access token", "token=" + githubToken, githubToken, "[REDACTED:access_token]"},
|
|
{"authorization header", "Authorization: Bearer " + bearer, bearer, "[REDACTED:auth_token]"},
|
|
{"contextual generic key", "OPENAI_API_KEY=\"" + generic + "\"", generic, "[REDACTED:credential]"},
|
|
{"natural language key", "my api key is " + generic, generic, "[REDACTED:credential]"},
|
|
{"private key", "deploy with\n" + privateKey + "\nnow", privateKey, "[REDACTED:private_key]"},
|
|
}
|
|
for _, test := range tests {
|
|
t.Run(test.name, func(t *testing.T) {
|
|
redacted, findings := RedactPromptSecrets(test.prompt)
|
|
if strings.Contains(redacted, test.secret) {
|
|
t.Fatalf("redacted prompt still contains secret: %q", redacted)
|
|
}
|
|
if !strings.Contains(redacted, test.marker) {
|
|
t.Fatalf("redacted prompt = %q, want marker %q", redacted, test.marker)
|
|
}
|
|
if len(findings) == 0 {
|
|
t.Fatal("redaction metadata is empty")
|
|
}
|
|
})
|
|
}
|
|
|
|
plain := "Read OPENAI_API_KEY from the environment; the key is configuration; inspect commit 0123456789abcdef0123456789abcdef."
|
|
if redacted, findings := RedactPromptSecrets(plain); redacted != plain || len(findings) != 0 {
|
|
t.Fatalf("plain prompt changed to %q with findings %#v", redacted, findings)
|
|
}
|
|
}
|
|
|
|
func TestPromptSecretNeverReachesDurableProjectBytes(t *testing.T) {
|
|
root := t.TempDir()
|
|
writeTestFile(t, filepath.Join(root, "base.txt"), "base\n")
|
|
service, _, err := InitProject(context.Background(), root)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
secret := "sk-proj-" + strings.Repeat("NeverPersist7", 4)
|
|
result, err := service.CreatePrompt(context.Background(), "Use this API key: "+secret, "", "test")
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if len(result.Redactions) != 1 || result.Redactions[0].Count != 1 {
|
|
t.Fatalf("redactions = %#v, want one finding", result.Redactions)
|
|
}
|
|
if strings.Contains(result.Prompt.Prompt, secret) || !strings.Contains(result.Prompt.Prompt, redactedMarkerPrefix) {
|
|
t.Fatalf("stored prompt was not redacted: %q", result.Prompt.Prompt)
|
|
}
|
|
check, err := service.RunCheck(context.Background(), result.Prompt.ID, []string{
|
|
"sh", "-c", "printf '%s' '" + secret + "'",
|
|
})
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if strings.Contains(strings.Join(check.Command, " "), secret) || strings.Contains(check.Output, secret) {
|
|
t.Fatalf("stored check leaked secret: %#v / %q", check.Command, check.Output)
|
|
}
|
|
proposal, err := service.Propose(context.Background(), result.Prompt.ID, "Completed with "+secret)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if strings.Contains(proposal.Proposal.Summary, secret) {
|
|
t.Fatalf("stored proposal summary leaked secret: %q", proposal.Proposal.Summary)
|
|
}
|
|
if err := service.Close(); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
needle := []byte(secret)
|
|
for _, tree := range []string{filepath.Join(root, ".hop"), filepath.Join(root, ".git", "refs", "hop")} {
|
|
err := filepath.WalkDir(tree, func(path string, entry fs.DirEntry, walkErr error) error {
|
|
if walkErr != nil {
|
|
return walkErr
|
|
}
|
|
if entry.IsDir() {
|
|
return nil
|
|
}
|
|
contents, readErr := os.ReadFile(path)
|
|
if readErr != nil {
|
|
return readErr
|
|
}
|
|
if bytes.Contains(contents, needle) {
|
|
t.Errorf("secret leaked into durable file %s", path)
|
|
}
|
|
return nil
|
|
})
|
|
if err != nil && !os.IsNotExist(err) {
|
|
t.Fatal(err)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestBeginJSONNeverEchoesPromptSecret(t *testing.T) {
|
|
root := t.TempDir()
|
|
previousDirectory, err := os.Getwd()
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if err := os.Chdir(root); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
t.Cleanup(func() { _ = os.Chdir(previousDirectory) })
|
|
|
|
secret := "ghp_" + strings.Repeat("NoEcho7", 6)
|
|
var stdout, stderr bytes.Buffer
|
|
code := RunCLIWithInput(
|
|
[]string{"begin", "--agent", "codex", "--session", "secret-test", "--stdin", "--json"},
|
|
strings.NewReader("Use token="+secret), &stdout, &stderr)
|
|
if code != 0 {
|
|
t.Fatalf("begin exited %d: %s", code, stderr.String())
|
|
}
|
|
if strings.Contains(stdout.String(), secret) || strings.Contains(stderr.String(), secret) {
|
|
t.Fatal("begin output echoed the prompt secret")
|
|
}
|
|
if !strings.Contains(stdout.String(), redactedMarkerPrefix) || !strings.Contains(stdout.String(), `"redactions"`) {
|
|
t.Fatalf("begin JSON omitted redaction evidence: %s", stdout.String())
|
|
}
|
|
}
|