Skip to content

Commit e1f5ea6

Browse files
author
Gary Blankenship
committed
feat(commit): add secrets detection and tests for execute subcommand
1 parent 0d089c8 commit e1f5ea6

2 files changed

Lines changed: 223 additions & 0 deletions

File tree

commit_execute_secrets.go

Lines changed: 88 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,88 @@
1+
package repomap
2+
3+
import (
4+
"encoding/json"
5+
"errors"
6+
"fmt"
7+
"os"
8+
"slices"
9+
"strings"
10+
)
11+
12+
// checkSecrets blocks execute when the plan contains any unresolved secret
13+
// signal. Callers must adjudicate all findings (detected and ambiguous) before
14+
// calling execute — execute is dumb-deterministic and never resolves findings.
15+
//
16+
// Blocking conditions:
17+
//
18+
// FlagCount > 0 — deterministic FLAG hits (auto-fixable but not yet fixed)
19+
// AmbiguousCount > 0 — REVIEW/secret findings requiring LLM adjudication
20+
// !Clean && Gitleaks > 0 — gitleaks hits not captured by FlagCount (legacy plans)
21+
func checkSecrets(s SecretsSummary, findingsPath string) error {
22+
hasDetected := s.FlagCount > 0 || (!s.Clean && s.GitleaksFindings > 0)
23+
hasAmbiguous := s.AmbiguousCount > 0
24+
if !hasDetected && !hasAmbiguous {
25+
return nil
26+
}
27+
28+
// Collect per-category file lists from the findings file when available.
29+
detectedFiles, ambiguousFiles := loadSecretFileLists(findingsPath)
30+
31+
var b strings.Builder
32+
fmt.Fprintf(&b, "plan has unresolved secrets — adjudicate before execute:")
33+
if hasDetected {
34+
fmt.Fprintf(&b, "\n detected: %d file(s)", s.FlagCount)
35+
for _, f := range detectedFiles {
36+
fmt.Fprintf(&b, "\n %s", f)
37+
}
38+
}
39+
if hasAmbiguous {
40+
fmt.Fprintf(&b, "\n ambiguous: %d file(s)", s.AmbiguousCount)
41+
for _, f := range ambiguousFiles {
42+
fmt.Fprintf(&b, "\n %s", f)
43+
}
44+
}
45+
return errors.New(b.String())
46+
}
47+
48+
// loadSecretFileLists parses a findings JSON file and returns two deduplicated,
49+
// sorted file lists: one for FLAG findings (detected) and one for REVIEW
50+
// findings with default_action=review (ambiguous). Returns empty slices when
51+
// the file is absent or unparseable — callers treat that as "no detail available".
52+
func loadSecretFileLists(findingsPath string) (detected, ambiguous []string) {
53+
if findingsPath == "" {
54+
return nil, nil
55+
}
56+
data, err := os.ReadFile(findingsPath)
57+
if err != nil {
58+
return nil, nil
59+
}
60+
var findings []Finding
61+
if err := json.Unmarshal(data, &findings); err != nil {
62+
return nil, nil
63+
}
64+
detectedSet := make(map[string]bool)
65+
ambiguousSet := make(map[string]bool)
66+
for _, f := range findings {
67+
switch {
68+
case f.Class == "FLAG":
69+
detectedSet[f.File] = true
70+
case f.Class == "REVIEW" && f.DefaultAction == "review":
71+
ambiguousSet[f.File] = true
72+
}
73+
}
74+
return sortedKeys(detectedSet), sortedKeys(ambiguousSet)
75+
}
76+
77+
// sortedKeys returns the keys of m in sorted order.
78+
func sortedKeys(m map[string]bool) []string {
79+
if len(m) == 0 {
80+
return nil
81+
}
82+
out := make([]string, 0, len(m))
83+
for k := range m {
84+
out = append(out, k)
85+
}
86+
slices.Sort(out)
87+
return out
88+
}

commit_execute_secrets_test.go

Lines changed: 135 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,135 @@
1+
package repomap
2+
3+
import (
4+
"context"
5+
"encoding/json"
6+
"os"
7+
"strings"
8+
"testing"
9+
)
10+
11+
// makePlanFileWithSecrets builds a plan file with the given SecretsSummary.
12+
func makePlanFileWithSecrets(t *testing.T, groups []CommitGroup, secrets SecretsSummary) string {
13+
t.Helper()
14+
a := CommitAnalysis{
15+
Version: 1,
16+
Secrets: secrets,
17+
Groups: groups,
18+
}
19+
data, err := json.Marshal(a)
20+
if err != nil {
21+
t.Fatalf("marshal plan: %v", err)
22+
}
23+
f, err := os.CreateTemp(t.TempDir(), "plan-*.json")
24+
if err != nil {
25+
t.Fatalf("create plan file: %v", err)
26+
}
27+
if _, err := f.Write(data); err != nil {
28+
t.Fatalf("write plan: %v", err)
29+
}
30+
f.Close()
31+
return f.Name()
32+
}
33+
34+
// Test_Execute_BadTagExitCode verifies that an invalid tag returns an execError
35+
// with code 2 and makes no commits.
36+
func Test_Execute_BadTagExitCode(t *testing.T) {
37+
t.Parallel()
38+
root := initTestRepo(t,
39+
map[string]string{"a.go": "package fixture\n"},
40+
map[string]string{"a.go": "package fixture\n// changed\n"},
41+
)
42+
groups := []CommitGroup{
43+
{ID: "g1", SuggestedMsg: "feat: update a", Files: []string{"a.go"}},
44+
}
45+
planFile := makePlanFile(t, groups)
46+
beforeCount := gitLogCount(t, root)
47+
48+
_, err := ExecuteCommit(context.Background(), ExecuteOptions{
49+
Root: root,
50+
PlanFile: planFile,
51+
Tag: "not-semver",
52+
SkipFix: true,
53+
})
54+
if err == nil {
55+
t.Fatal("expected error for invalid tag, got nil")
56+
}
57+
if code := ExecExitCode(err); code != 2 {
58+
t.Errorf("ExecExitCode = %d, want 2", code)
59+
}
60+
if after := gitLogCount(t, root); after != beforeCount {
61+
t.Errorf("commits landed despite validation failure: before=%d after=%d", beforeCount, after)
62+
}
63+
}
64+
65+
// Test_Execute_AmbiguousSecretsRefused verifies that a plan with
66+
// ambiguous_count > 0 is refused with exit code 2, before any git work.
67+
func Test_Execute_AmbiguousSecretsRefused(t *testing.T) {
68+
t.Parallel()
69+
root := initTestRepo(t,
70+
map[string]string{"a.go": "package fixture\n"},
71+
map[string]string{"a.go": "package fixture\n// changed\n"},
72+
)
73+
groups := []CommitGroup{
74+
{ID: "g1", SuggestedMsg: "feat: update a", Files: []string{"a.go"}},
75+
}
76+
planFile := makePlanFileWithSecrets(t, groups, SecretsSummary{
77+
Clean: true,
78+
AmbiguousCount: 1,
79+
})
80+
beforeCount := gitLogCount(t, root)
81+
82+
_, err := ExecuteCommit(context.Background(), ExecuteOptions{
83+
Root: root,
84+
PlanFile: planFile,
85+
SkipFix: true,
86+
})
87+
if err == nil {
88+
t.Fatal("expected error for ambiguous secrets, got nil")
89+
}
90+
if code := ExecExitCode(err); code != 2 {
91+
t.Errorf("ExecExitCode = %d, want 2", code)
92+
}
93+
if !strings.Contains(err.Error(), "ambiguous") {
94+
t.Errorf("error message missing 'ambiguous': %v", err)
95+
}
96+
if after := gitLogCount(t, root); after != beforeCount {
97+
t.Errorf("commits landed despite secrets gate: before=%d after=%d", beforeCount, after)
98+
}
99+
}
100+
101+
// Test_Execute_DetectedSecretsRefused verifies that a plan with flag_count > 0
102+
// is refused with exit code 2, before any git work.
103+
func Test_Execute_DetectedSecretsRefused(t *testing.T) {
104+
t.Parallel()
105+
root := initTestRepo(t,
106+
map[string]string{"a.go": "package fixture\n"},
107+
map[string]string{"a.go": "package fixture\n// changed\n"},
108+
)
109+
groups := []CommitGroup{
110+
{ID: "g1", SuggestedMsg: "feat: update a", Files: []string{"a.go"}},
111+
}
112+
planFile := makePlanFileWithSecrets(t, groups, SecretsSummary{
113+
Clean: false,
114+
FlagCount: 1,
115+
})
116+
beforeCount := gitLogCount(t, root)
117+
118+
_, err := ExecuteCommit(context.Background(), ExecuteOptions{
119+
Root: root,
120+
PlanFile: planFile,
121+
SkipFix: true,
122+
})
123+
if err == nil {
124+
t.Fatal("expected error for detected secrets, got nil")
125+
}
126+
if code := ExecExitCode(err); code != 2 {
127+
t.Errorf("ExecExitCode = %d, want 2", code)
128+
}
129+
if !strings.Contains(err.Error(), "detected") {
130+
t.Errorf("error message missing 'detected': %v", err)
131+
}
132+
if after := gitLogCount(t, root); after != beforeCount {
133+
t.Errorf("commits landed despite secrets gate: before=%d after=%d", beforeCount, after)
134+
}
135+
}

0 commit comments

Comments
 (0)