Skip to content

Commit 29e1986

Browse files
committed
Address review feedback for PR #249
1 parent bc77207 commit 29e1986

File tree

7 files changed

+274
-12
lines changed

7 files changed

+274
-12
lines changed

client/cmd/code_scanning_apply.go

Lines changed: 23 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -28,12 +28,14 @@ type applyAction struct {
2828
}
2929

3030
type applySummary struct {
31-
TotalAlerts int `json:"totalAlerts"`
32-
DismissCount int `json:"dismissCount"`
33-
NoChangeCount int `json:"noChangeCount"`
34-
AppliedCount int `json:"appliedCount"`
35-
ErrorCount int `json:"errorCount"`
36-
DryRun bool `json:"dryRun"`
31+
TotalAlerts int `json:"totalAlerts"`
32+
DismissCount int `json:"dismissCount"`
33+
AuthorizedDismissCount int `json:"authorizedDismissCount"`
34+
UnauthorizedDismissCount int `json:"unauthorizedDismissCount"`
35+
NoChangeCount int `json:"noChangeCount"`
36+
AppliedCount int `json:"appliedCount"`
37+
ErrorCount int `json:"errorCount"`
38+
DryRun bool `json:"dryRun"`
3739
}
3840

3941
type applyPlan struct {
@@ -95,14 +97,25 @@ func buildApplyPlan(assessed []assessedAlert, opts applyOptions) applyPlan {
9597
}
9698
}
9799

100+
var authorizedCount, unauthorizedCount int
101+
for _, a := range actions {
102+
if a.Authorized {
103+
authorizedCount++
104+
} else {
105+
unauthorizedCount++
106+
}
107+
}
108+
98109
return applyPlan{
99110
GeneratedAt: time.Now().UTC().Format(time.RFC3339),
100111
Actions: actions,
101112
Summary: applySummary{
102-
TotalAlerts: len(assessed),
103-
DismissCount: len(actions),
104-
NoChangeCount: noChange,
105-
DryRun: opts.dryRun,
113+
TotalAlerts: len(assessed),
114+
DismissCount: len(actions),
115+
AuthorizedDismissCount: authorizedCount,
116+
UnauthorizedDismissCount: unauthorizedCount,
117+
NoChangeCount: noChange,
118+
DryRun: opts.dryRun,
106119
},
107120
}
108121
}

client/cmd/code_scanning_apply_test.go

Lines changed: 62 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -154,3 +154,65 @@ func TestBuildApplyPlan_ReviewWithoutAcceptIsNotAuthorized(t *testing.T) {
154154
t.Errorf("action = %q, want dismiss", plan.Actions[0].Action)
155155
}
156156
}
157+
158+
func TestBuildApplyPlan_AuthorizedVsUnauthorizedCounts(t *testing.T) {
159+
assessed := []assessedAlert{
160+
{Number: 1, State: "open", Rule: ruleEntry{ID: "r1"}, Recommendation: "discard"},
161+
{Number: 2, State: "open", Rule: ruleEntry{ID: "r2"}, Recommendation: "review"},
162+
{Number: 3, State: "open", Rule: ruleEntry{ID: "r1"}, Recommendation: "discard"},
163+
}
164+
165+
// Only accept r1 — so alerts 1 and 3 are authorized, alert 2 is not
166+
plan := buildApplyPlan(assessed, applyOptions{
167+
dryRun: true,
168+
acceptChangeForRules: []string{"r1"},
169+
})
170+
171+
if plan.Summary.DismissCount != 3 {
172+
t.Errorf("dismissCount = %d, want 3 (total proposed)", plan.Summary.DismissCount)
173+
}
174+
if plan.Summary.AuthorizedDismissCount != 2 {
175+
t.Errorf("authorizedDismissCount = %d, want 2", plan.Summary.AuthorizedDismissCount)
176+
}
177+
if plan.Summary.UnauthorizedDismissCount != 1 {
178+
t.Errorf("unauthorizedDismissCount = %d, want 1", plan.Summary.UnauthorizedDismissCount)
179+
}
180+
}
181+
182+
func TestBuildApplyPlan_AllAuthorizedWhenAcceptAll(t *testing.T) {
183+
assessed := []assessedAlert{
184+
{Number: 1, State: "open", Rule: ruleEntry{ID: "r1"}, Recommendation: "discard"},
185+
{Number: 2, State: "open", Rule: ruleEntry{ID: "r2"}, Recommendation: "review"},
186+
}
187+
188+
plan := buildApplyPlan(assessed, applyOptions{
189+
dryRun: true,
190+
acceptAllChanges: true,
191+
})
192+
193+
if plan.Summary.AuthorizedDismissCount != 2 {
194+
t.Errorf("authorizedDismissCount = %d, want 2", plan.Summary.AuthorizedDismissCount)
195+
}
196+
if plan.Summary.UnauthorizedDismissCount != 0 {
197+
t.Errorf("unauthorizedDismissCount = %d, want 0", plan.Summary.UnauthorizedDismissCount)
198+
}
199+
}
200+
201+
func TestBuildApplyPlan_DiscardAutoAuthorizedCountsCorrectly(t *testing.T) {
202+
assessed := []assessedAlert{
203+
{Number: 1, State: "open", Rule: ruleEntry{ID: "r1"}, Recommendation: "discard"},
204+
}
205+
206+
// No rule filter set — discard should be auto-authorized
207+
plan := buildApplyPlan(assessed, applyOptions{dryRun: true})
208+
209+
if plan.Summary.DismissCount != 1 {
210+
t.Errorf("dismissCount = %d, want 1", plan.Summary.DismissCount)
211+
}
212+
if plan.Summary.AuthorizedDismissCount != 1 {
213+
t.Errorf("authorizedDismissCount = %d, want 1", plan.Summary.AuthorizedDismissCount)
214+
}
215+
if plan.Summary.UnauthorizedDismissCount != 0 {
216+
t.Errorf("unauthorizedDismissCount = %d, want 0", plan.Summary.UnauthorizedDismissCount)
217+
}
218+
}

client/cmd/code_scanning_assess.go

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -123,6 +123,16 @@ func assessAlerts(alerts []alertEntry) []assessedAlert {
123123
"potential duplicate of dismissed alert #%d from rule %s",
124124
peer.Number, peer.Rule.ID)
125125
}
126+
127+
// If both alerts are open, mark the higher-numbered one as discard
128+
if a.State == "open" && peer.State == "open" && assessed.Recommendation == "keep" {
129+
if a.Number > peer.Number {
130+
assessed.Recommendation = "discard"
131+
assessed.RecommendReason = fmt.Sprintf(
132+
"duplicate of canonical alert #%d from rule %s at same location",
133+
peer.Number, peer.Rule.ID)
134+
}
135+
}
126136
}
127137
}
128138

client/cmd/code_scanning_assess_test.go

Lines changed: 93 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -155,3 +155,96 @@ func TestBuildAssessReport_ReviewCountSeparateFromKeep(t *testing.T) {
155155
t.Errorf("keepDismissedCount = %d, want 1", assessReport.Summary.KeepDismissed)
156156
}
157157
}
158+
159+
func TestAssessAlerts_DiscardForOverlappingOpenAlerts(t *testing.T) {
160+
// Two open alerts from different rules at the same location.
161+
// The higher-numbered alert should be marked "discard" (lower number is canonical).
162+
alerts := []alertEntry{
163+
{Number: 1, State: "open", Rule: ruleEntry{ID: "js/sql-injection", Severity: "8.8"}, Location: locationEntry{Path: "src/db.js", StartLine: 42}},
164+
{Number: 5, State: "open", Rule: ruleEntry{ID: "js/sql-injection-v2", Severity: "8.8"}, Location: locationEntry{Path: "src/db.js", StartLine: 42}},
165+
}
166+
167+
assessed := assessAlerts(alerts)
168+
169+
var discardCount int
170+
for _, a := range assessed {
171+
if a.Recommendation == "discard" {
172+
discardCount++
173+
if a.Number != 5 {
174+
t.Errorf("expected alert #5 to be discard, got alert #%d", a.Number)
175+
}
176+
}
177+
}
178+
if discardCount != 1 {
179+
t.Errorf("expected 1 discard recommendation, got %d", discardCount)
180+
}
181+
182+
// The canonical alert (lower number) keeps "keep" recommendation
183+
for _, a := range assessed {
184+
if a.Number == 1 && a.Recommendation != "keep" {
185+
t.Errorf("canonical alert #1 recommendation = %q, want keep", a.Recommendation)
186+
}
187+
}
188+
}
189+
190+
func TestAssessAlerts_DiscardPreservesOverlapMetadata(t *testing.T) {
191+
alerts := []alertEntry{
192+
{Number: 1, State: "open", Rule: ruleEntry{ID: "js/sql-injection"}, Location: locationEntry{Path: "src/db.js", StartLine: 42}},
193+
{Number: 5, State: "open", Rule: ruleEntry{ID: "js/sql-injection-v2"}, Location: locationEntry{Path: "src/db.js", StartLine: 42}},
194+
}
195+
196+
assessed := assessAlerts(alerts)
197+
198+
for _, a := range assessed {
199+
if a.Number == 5 {
200+
if len(a.OverlappingAlerts) == 0 {
201+
t.Error("discarded alert #5 should list overlapping alerts")
202+
}
203+
if a.RecommendReason == "" {
204+
t.Error("discarded alert #5 should have a recommend reason")
205+
}
206+
}
207+
}
208+
}
209+
210+
func TestAssessAlerts_DiscardNotAppliedToMixedStateOverlaps(t *testing.T) {
211+
// When an open alert overlaps with a dismissed alert, it should be "review" not "discard".
212+
// Discard only applies when all overlapping alerts are open.
213+
alerts := []alertEntry{
214+
{Number: 1, State: "dismissed", Rule: ruleEntry{ID: "js/sql-injection"}, Location: locationEntry{Path: "src/db.js", StartLine: 42},
215+
DismissedReason: strPtr("false positive")},
216+
{Number: 5, State: "open", Rule: ruleEntry{ID: "js/sql-injection-v2"}, Location: locationEntry{Path: "src/db.js", StartLine: 42}},
217+
}
218+
219+
assessed := assessAlerts(alerts)
220+
221+
for _, a := range assessed {
222+
if a.Number == 5 {
223+
if a.Recommendation == "discard" {
224+
t.Error("alert #5 overlapping with dismissed alert should be 'review', not 'discard'")
225+
}
226+
if a.Recommendation != "review" {
227+
t.Errorf("alert #5 recommendation = %q, want review", a.Recommendation)
228+
}
229+
}
230+
}
231+
}
232+
233+
func TestBuildAssessReport_DiscardCount(t *testing.T) {
234+
// Two open alerts at same location → one should be discarded
235+
alerts := []alertEntry{
236+
{Number: 1, State: "open", Rule: ruleEntry{ID: "js/sql-injection"}, Location: locationEntry{Path: "src/db.js", StartLine: 42}},
237+
{Number: 5, State: "open", Rule: ruleEntry{ID: "js/sql-injection-v2"}, Location: locationEntry{Path: "src/db.js", StartLine: 42}},
238+
}
239+
240+
report := buildReport("test/repo", nil, alerts)
241+
assessed := assessAlerts(alerts)
242+
assessReport := buildAssessReport(report, assessed)
243+
244+
if assessReport.Summary.DiscardCount != 1 {
245+
t.Errorf("discardCount = %d, want 1", assessReport.Summary.DiscardCount)
246+
}
247+
if assessReport.Summary.KeepCount != 1 {
248+
t.Errorf("keepCount = %d, want 1 (canonical alert kept)", assessReport.Summary.KeepCount)
249+
}
250+
}

client/cmd/code_scanning_report.go

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -194,6 +194,10 @@ func runReport(cmd *cobra.Command, _ []string) error {
194194
return err
195195
}
196196

197+
if err := validatePerPage(reportFlags.perPage); err != nil {
198+
return err
199+
}
200+
197201
client, err := gh.NewClient()
198202
if err != nil {
199203
return err

client/cmd/helpers.go

Lines changed: 17 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,10 +6,26 @@ import (
66
)
77

88
// parseRepo splits an "owner/repo" string into owner and repo components.
9+
// It rejects path traversal sequences and extra path separators.
910
func parseRepo(nwo string) (string, string, error) {
1011
parts := strings.SplitN(nwo, "/", 2)
1112
if len(parts) != 2 || parts[0] == "" || parts[1] == "" {
1213
return "", "", fmt.Errorf("invalid repo format %q: expected owner/repo", nwo)
1314
}
14-
return parts[0], parts[1], nil
15+
owner, repo := parts[0], parts[1]
16+
if strings.Contains(owner, "..") || strings.Contains(repo, "..") {
17+
return "", "", fmt.Errorf("invalid repo format %q: path traversal not allowed", nwo)
18+
}
19+
if strings.Contains(repo, "/") {
20+
return "", "", fmt.Errorf("invalid repo format %q: expected owner/repo", nwo)
21+
}
22+
return owner, repo, nil
23+
}
24+
25+
// validatePerPage checks that a per-page value is within the GitHub API limit.
26+
func validatePerPage(perPage int) error {
27+
if perPage < 1 || perPage > 100 {
28+
return fmt.Errorf("invalid per-page value %d: must be between 1 and 100", perPage)
29+
}
30+
return nil
1531
}

client/cmd/helpers_test.go

Lines changed: 65 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,9 @@
11
package cmd
22

3-
import "testing"
3+
import (
4+
"fmt"
5+
"testing"
6+
)
47

58
func TestParseRepo_Valid(t *testing.T) {
69
owner, repo, err := parseRepo("example-owner/example-repo")
@@ -30,6 +33,67 @@ func TestParseRepo_Invalid(t *testing.T) {
3033
}
3134
}
3235

36+
func TestParseRepo_RejectsPathTraversal(t *testing.T) {
37+
tests := []struct {
38+
input string
39+
desc string
40+
}{
41+
{"owner/../../etc", "dot-dot in repo"},
42+
{"../owner/repo", "dot-dot in owner"},
43+
{"owner/../repo", "dot-dot in owner with slash"},
44+
{"owner/repo/../../path", "dot-dot beyond repo"},
45+
{"owner/repo/../secret", "dot-dot in repo component"},
46+
}
47+
for _, tt := range tests {
48+
t.Run(tt.desc, func(t *testing.T) {
49+
_, _, err := parseRepo(tt.input)
50+
if err == nil {
51+
t.Errorf("parseRepo(%q) should reject path traversal", tt.input)
52+
}
53+
})
54+
}
55+
}
56+
57+
func TestParseRepo_RejectsPathSeparators(t *testing.T) {
58+
tests := []struct {
59+
input string
60+
desc string
61+
}{
62+
{"owner/sub/repo", "extra slash in repo"},
63+
}
64+
for _, tt := range tests {
65+
t.Run(tt.desc, func(t *testing.T) {
66+
_, _, err := parseRepo(tt.input)
67+
if err == nil {
68+
t.Errorf("parseRepo(%q) should reject extra path separators", tt.input)
69+
}
70+
})
71+
}
72+
}
73+
74+
func TestValidatePerPage(t *testing.T) {
75+
tests := []struct {
76+
value int
77+
wantErr bool
78+
}{
79+
{1, false},
80+
{50, false},
81+
{100, false},
82+
{0, true},
83+
{-1, true},
84+
{101, true},
85+
{200, true},
86+
}
87+
for _, tt := range tests {
88+
t.Run(fmt.Sprintf("perPage=%d", tt.value), func(t *testing.T) {
89+
err := validatePerPage(tt.value)
90+
if (err != nil) != tt.wantErr {
91+
t.Errorf("validatePerPage(%d) error = %v, wantErr %v", tt.value, err, tt.wantErr)
92+
}
93+
})
94+
}
95+
}
96+
3397
// strPtr returns a pointer to s.
3498
func strPtr(s string) *string {
3599
return &s

0 commit comments

Comments
 (0)