Skip to content

Commit 032a652

Browse files
authored
Strip control characters from forge-sourced text before printing (#74)
Issue/PR titles, bodies, comments, labels, and other forge-sourced strings could contain ANSI escape sequences or OSC commands that manipulate the terminal when displayed. Adds output.Sanitize() to strip C0 control bytes (except tab and newline) and applies it to all display paths in the CLI.
1 parent 7f8d2f0 commit 032a652

6 files changed

Lines changed: 69 additions & 32 deletions

File tree

internal/cli/issue.go

Lines changed: 12 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -65,33 +65,33 @@ func issueViewCmd() *cobra.Command {
6565
return p.PrintJSON(issue)
6666
}
6767

68-
_, _ = fmt.Fprintf(os.Stdout, "#%d %s\n", issue.Number, issue.Title)
68+
_, _ = fmt.Fprintf(os.Stdout, "#%d %s\n", issue.Number, output.Sanitize(issue.Title))
6969
_, _ = fmt.Fprintf(os.Stdout, "State: %s\n", issue.State)
70-
_, _ = fmt.Fprintf(os.Stdout, "Author: %s\n", issue.Author.Login)
70+
_, _ = fmt.Fprintf(os.Stdout, "Author: %s\n", output.Sanitize(issue.Author.Login))
7171

7272
if len(issue.Assignees) > 0 {
7373
names := make([]string, len(issue.Assignees))
7474
for i, a := range issue.Assignees {
75-
names[i] = a.Login
75+
names[i] = output.Sanitize(a.Login)
7676
}
7777
_, _ = fmt.Fprintf(os.Stdout, "Assign: %s\n", strings.Join(names, ", "))
7878
}
7979

8080
if len(issue.Labels) > 0 {
8181
names := make([]string, len(issue.Labels))
8282
for i, l := range issue.Labels {
83-
names[i] = l.Name
83+
names[i] = output.Sanitize(l.Name)
8484
}
8585
_, _ = fmt.Fprintf(os.Stdout, "Labels: %s\n", strings.Join(names, ", "))
8686
}
8787

8888
if issue.Milestone != nil {
89-
_, _ = fmt.Fprintf(os.Stdout, "Mile: %s\n", issue.Milestone.Title)
89+
_, _ = fmt.Fprintf(os.Stdout, "Mile: %s\n", output.Sanitize(issue.Milestone.Title))
9090
}
9191

9292
if issue.Body != "" {
9393
_, _ = fmt.Fprintln(os.Stdout)
94-
_, _ = fmt.Fprintln(os.Stdout, issue.Body)
94+
_, _ = fmt.Fprintln(os.Stdout, output.Sanitize(issue.Body))
9595
}
9696

9797
if flagComments {
@@ -101,8 +101,8 @@ func issueViewCmd() *cobra.Command {
101101
}
102102
for _, c := range comments {
103103
_, _ = fmt.Fprintln(os.Stdout)
104-
_, _ = fmt.Fprintf(os.Stdout, "--- %s ---\n", c.Author.Login)
105-
_, _ = fmt.Fprintln(os.Stdout, c.Body)
104+
_, _ = fmt.Fprintf(os.Stdout, "--- %s ---\n", output.Sanitize(c.Author.Login))
105+
_, _ = fmt.Fprintln(os.Stdout, output.Sanitize(c.Body))
106106
}
107107
}
108108

@@ -157,7 +157,7 @@ func issueListCmd() *cobra.Command {
157157
if p.Format == output.Plain {
158158
lines := make([]string, len(issues))
159159
for i, iss := range issues {
160-
lines[i] = fmt.Sprintf("%d\t%s", iss.Number, iss.Title)
160+
lines[i] = fmt.Sprintf("%d\t%s", iss.Number, output.Sanitize(iss.Title))
161161
}
162162
p.PrintPlain(lines)
163163
return nil
@@ -168,16 +168,16 @@ func issueListCmd() *cobra.Command {
168168
for i, iss := range issues {
169169
labels := make([]string, len(iss.Labels))
170170
for j, l := range iss.Labels {
171-
labels[j] = l.Name
171+
labels[j] = output.Sanitize(l.Name)
172172
}
173-
title := iss.Title
173+
title := output.Sanitize(iss.Title)
174174
if len(title) > maxTitleLength {
175175
title = title[:truncatedTitleLen] + "..."
176176
}
177177
rows[i] = []string{
178178
strconv.Itoa(iss.Number),
179179
title,
180-
iss.Author.Login,
180+
output.Sanitize(iss.Author.Login),
181181
strings.Join(labels, ", "),
182182
iss.UpdatedAt.Format("2006-01-02"),
183183
}

internal/cli/pr.go

Lines changed: 11 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -75,8 +75,8 @@ func prViewCmd() *cobra.Command {
7575
}
7676
for _, c := range comments {
7777
_, _ = fmt.Fprintln(os.Stdout)
78-
_, _ = fmt.Fprintf(os.Stdout, "--- %s ---\n", c.Author.Login)
79-
_, _ = fmt.Fprintln(os.Stdout, c.Body)
78+
_, _ = fmt.Fprintf(os.Stdout, "--- %s ---\n", output.Sanitize(c.Author.Login))
79+
_, _ = fmt.Fprintln(os.Stdout, output.Sanitize(c.Body))
8080
}
8181
}
8282

@@ -89,9 +89,9 @@ func prViewCmd() *cobra.Command {
8989
}
9090

9191
func printPRDetails(pr *forges.PullRequest) {
92-
_, _ = fmt.Fprintf(os.Stdout, "#%d %s\n", pr.Number, pr.Title)
92+
_, _ = fmt.Fprintf(os.Stdout, "#%d %s\n", pr.Number, output.Sanitize(pr.Title))
9393
_, _ = fmt.Fprintf(os.Stdout, "State: %s\n", pr.State)
94-
_, _ = fmt.Fprintf(os.Stdout, "Author: %s\n", pr.Author.Login)
94+
_, _ = fmt.Fprintf(os.Stdout, "Author: %s\n", output.Sanitize(pr.Author.Login))
9595
_, _ = fmt.Fprintf(os.Stdout, "Branch: %s -> %s\n", pr.Head, pr.Base)
9696

9797
if pr.Draft {
@@ -101,21 +101,21 @@ func printPRDetails(pr *forges.PullRequest) {
101101
if len(pr.Reviewers) > 0 {
102102
names := make([]string, len(pr.Reviewers))
103103
for i, r := range pr.Reviewers {
104-
names[i] = r.Login
104+
names[i] = output.Sanitize(r.Login)
105105
}
106106
_, _ = fmt.Fprintf(os.Stdout, "Review: %s\n", strings.Join(names, ", "))
107107
}
108108

109109
if len(pr.Labels) > 0 {
110110
names := make([]string, len(pr.Labels))
111111
for i, l := range pr.Labels {
112-
names[i] = l.Name
112+
names[i] = output.Sanitize(l.Name)
113113
}
114114
_, _ = fmt.Fprintf(os.Stdout, "Labels: %s\n", strings.Join(names, ", "))
115115
}
116116

117117
if pr.Milestone != nil {
118-
_, _ = fmt.Fprintf(os.Stdout, "Mile: %s\n", pr.Milestone.Title)
118+
_, _ = fmt.Fprintf(os.Stdout, "Mile: %s\n", output.Sanitize(pr.Milestone.Title))
119119
}
120120

121121
if pr.Additions > 0 || pr.Deletions > 0 {
@@ -124,7 +124,7 @@ func printPRDetails(pr *forges.PullRequest) {
124124

125125
if pr.Body != "" {
126126
_, _ = fmt.Fprintln(os.Stdout)
127-
_, _ = fmt.Fprintln(os.Stdout, pr.Body)
127+
_, _ = fmt.Fprintln(os.Stdout, output.Sanitize(pr.Body))
128128
}
129129
}
130130

@@ -173,7 +173,7 @@ func prListCmd() *cobra.Command {
173173
if p.Format == output.Plain {
174174
lines := make([]string, len(prs))
175175
for i, pr := range prs {
176-
lines[i] = fmt.Sprintf("%d\t%s", pr.Number, pr.Title)
176+
lines[i] = fmt.Sprintf("%d\t%s", pr.Number, output.Sanitize(pr.Title))
177177
}
178178
p.PrintPlain(lines)
179179
return nil
@@ -182,14 +182,14 @@ func prListCmd() *cobra.Command {
182182
headers := []string{"#", "TITLE", "AUTHOR", "HEAD", "UPDATED"}
183183
rows := make([][]string, len(prs))
184184
for i, pr := range prs {
185-
title := pr.Title
185+
title := output.Sanitize(pr.Title)
186186
if len(title) > maxPRTitleLength {
187187
title = title[:maxPRTitleLength-3] + "..."
188188
}
189189
rows[i] = []string{
190190
strconv.Itoa(pr.Number),
191191
title,
192-
pr.Author.Login,
192+
output.Sanitize(pr.Author.Login),
193193
pr.Head,
194194
pr.UpdatedAt.Format("2006-01-02"),
195195
}

internal/cli/release.go

Lines changed: 7 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -74,8 +74,8 @@ func releaseListCmd() *cobra.Command {
7474
published = r.PublishedAt.Format("2006-01-02")
7575
}
7676
rows[i] = []string{
77-
r.TagName,
78-
r.Title,
77+
output.Sanitize(r.TagName),
78+
output.Sanitize(r.Title),
7979
fmt.Sprintf("%v", r.Draft),
8080
fmt.Sprintf("%v", r.Prerelease),
8181
published,
@@ -113,7 +113,7 @@ func releaseViewCmd() *cobra.Command {
113113
return p.PrintJSON(release)
114114
}
115115

116-
_, _ = fmt.Fprintf(os.Stdout, "%s %s\n", release.TagName, release.Title)
116+
_, _ = fmt.Fprintf(os.Stdout, "%s %s\n", output.Sanitize(release.TagName), output.Sanitize(release.Title))
117117
if release.Draft {
118118
_, _ = fmt.Fprintln(os.Stdout, "Draft: true")
119119
}
@@ -131,13 +131,13 @@ func releaseViewCmd() *cobra.Command {
131131
_, _ = fmt.Fprintln(os.Stdout)
132132
_, _ = fmt.Fprintln(os.Stdout, "Assets:")
133133
for _, a := range release.Assets {
134-
_, _ = fmt.Fprintf(os.Stdout, " %s (%d bytes)\n", a.Name, a.Size)
134+
_, _ = fmt.Fprintf(os.Stdout, " %s (%d bytes)\n", output.Sanitize(a.Name), a.Size)
135135
}
136136
}
137137

138138
if release.Body != "" {
139139
_, _ = fmt.Fprintln(os.Stdout)
140-
_, _ = fmt.Fprintln(os.Stdout, release.Body)
140+
_, _ = fmt.Fprintln(os.Stdout, output.Sanitize(release.Body))
141141
}
142142

143143
return nil
@@ -194,7 +194,7 @@ func releaseCreateCmd() *cobra.Command {
194194
return p.PrintJSON(release)
195195
}
196196

197-
_, _ = fmt.Fprintf(os.Stdout, "%s %s\n", release.TagName, release.Title)
197+
_, _ = fmt.Fprintf(os.Stdout, "%s %s\n", output.Sanitize(release.TagName), output.Sanitize(release.Title))
198198
if release.HTMLURL != "" {
199199
_, _ = fmt.Fprintln(os.Stdout, release.HTMLURL)
200200
}
@@ -264,7 +264,7 @@ func releaseEditCmd() *cobra.Command {
264264
return p.PrintJSON(release)
265265
}
266266

267-
_, _ = fmt.Fprintf(os.Stdout, "%s %s\n", release.TagName, release.Title)
267+
_, _ = fmt.Fprintf(os.Stdout, "%s %s\n", output.Sanitize(release.TagName), output.Sanitize(release.Title))
268268
return nil
269269
},
270270
}

internal/cli/repo.go

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -73,9 +73,9 @@ func repoViewCmd() *cobra.Command {
7373
return p.PrintJSON(r)
7474
}
7575

76-
_, _ = fmt.Fprintf(os.Stdout, "%s\n", r.FullName)
76+
_, _ = fmt.Fprintf(os.Stdout, "%s\n", output.Sanitize(r.FullName))
7777
if r.Description != "" {
78-
_, _ = fmt.Fprintf(os.Stdout, "%s\n", r.Description)
78+
_, _ = fmt.Fprintf(os.Stdout, "%s\n", output.Sanitize(r.Description))
7979
}
8080
_, _ = fmt.Fprintln(os.Stdout)
8181

internal/output/output.go

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ import (
66
"io"
77
"strings"
88
"text/tabwriter"
9+
"unicode"
910
)
1011

1112
// Format specifies how to render output.
@@ -61,3 +62,15 @@ func (p *Printer) PrintPlain(lines []string) {
6162
_, _ = fmt.Fprintln(p.Out, line)
6263
}
6364
}
65+
66+
// Sanitize strips C0 control characters (except tab and newline) from s.
67+
// This prevents ANSI escape sequences and OSC commands in forge-sourced
68+
// text from manipulating the terminal.
69+
func Sanitize(s string) string {
70+
return strings.Map(func(r rune) rune {
71+
if r != '\t' && r != '\n' && unicode.IsControl(r) {
72+
return -1
73+
}
74+
return r
75+
}, s)
76+
}

internal/output/output_test.go

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -77,3 +77,27 @@ func TestPrintPlain(t *testing.T) {
7777
t.Error("expected line2 in output")
7878
}
7979
}
80+
81+
func TestSanitize(t *testing.T) {
82+
tests := []struct {
83+
name string
84+
input string
85+
want string
86+
}{
87+
{"plain text", "hello world", "hello world"},
88+
{"preserves tabs", "col1\tcol2", "col1\tcol2"},
89+
{"preserves newlines", "line1\nline2", "line1\nline2"},
90+
{"strips ESC", "normal\x1b[31mred\x1b[0m", "normal[31mred[0m"},
91+
{"strips BEL", "title\x07", "title"},
92+
{"strips OSC", "\x1b]0;pwned\x07", "]0;pwned"},
93+
{"strips null", "a\x00b", "ab"},
94+
}
95+
for _, tt := range tests {
96+
t.Run(tt.name, func(t *testing.T) {
97+
got := Sanitize(tt.input)
98+
if got != tt.want {
99+
t.Errorf("Sanitize(%q) = %q, want %q", tt.input, got, tt.want)
100+
}
101+
})
102+
}
103+
}

0 commit comments

Comments
 (0)