Skip to content

Commit 6ce330c

Browse files
authored
Merge pull request #108 from flashcatcloud/fix/incident-comment-shell-safe
fix(incident): take the comment from a file and verify it landed, instead of shell-quoting it and assuming
2 parents 819f9a6 + edb87e7 commit 6ce330c

10 files changed

Lines changed: 913 additions & 63 deletions

File tree

internal/cli/alert.go

Lines changed: 99 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -19,8 +19,7 @@ func newAlertCmd() *cobra.Command {
1919
cmd.AddCommand(newAlertGetCmd())
2020
cmd.AddCommand(newAlertEventsCmd())
2121
cmd.AddCommand(newAlertTimelineCmd())
22-
// merge is registered via the generated layer (positional alert-ids fold to
23-
// alert_ids). Flag-name change: --incident (curated) → --incident-id (generated).
22+
cmd.AddCommand(newAlertMergeCmd())
2423
return cmd
2524
}
2625

@@ -280,6 +279,104 @@ func newAlertTimelineCmd() *cobra.Command {
280279
return cmd
281280
}
282281

282+
// newAlertMergeCmd curates the generated alert-write-merge command solely to
283+
// replace its inline --comment string with --comment-file, closing the same
284+
// shell-interpolation exposure that motivated incident comment's
285+
// --comment-file (see newIncidentCommentCmd): an LLM-authored merge comment
286+
// containing backticks, $(...), or quotes must never be typed into a shell
287+
// argument. Everything else (positional alert IDs, --alert-ids, --incident-id,
288+
// --owner-id, --title, --data) mirrors the generated command byte-for-byte via
289+
// the same genAssembleBody/genBindBody request-building helpers, so this is a
290+
// pure flag-source swap, not a behavior change. Registering it here (curated
291+
// commands are added before registerGenerated runs, see root.go) makes
292+
// genAddLeaf skip attaching the generated "merge" leaf under this same name.
293+
func newAlertMergeCmd() *cobra.Command {
294+
var dataJSON string
295+
var fAlertIDs []string
296+
var fCommentFile string
297+
var fIncidentID string
298+
var fOwnerID int64
299+
var fTitle string
300+
301+
cmd := &cobra.Command{
302+
Use: "merge <alert-id> [<id2>...]",
303+
Short: "Merge alerts into an incident",
304+
Long: `Merge alerts into an incident.
305+
306+
Associate one or more alerts with an existing incident. If a source alert previously belonged to a different incident and that incident becomes empty after the merge, it will be automatically closed.
307+
308+
An optional comment on the merge action is read verbatim from --comment-file
309+
(or from stdin, when the value is "-"); it is never parsed by a shell, so
310+
backticks, $(...), and quotes inside it reach the API exactly as written.
311+
312+
API: POST /alert/merge (alert-write-merge)
313+
314+
Request fields:
315+
--alert-ids []string (required) — Alert IDs to merge.
316+
--comment-file string — Path to a file containing an optional comment on the merge action (- reads stdin).
317+
--incident-id string (required) — Target incident ID.
318+
--owner-id int — Optional new owner for the target incident.
319+
--title string — Optional new title for the target incident.
320+
`,
321+
Args: requireBodyFieldOrArgs("alert_ids", "alert-ids"),
322+
Example: ` flashduty alert merge <alert-id1> <alert-id2> --incident-id <incident-id> --comment-file ./comment.txt`,
323+
RunE: func(cmd *cobra.Command, args []string) error {
324+
return runCommand(cmd, args, func(ctx *RunContext) error {
325+
body, err := genAssembleBody(dataJSON, func(body map[string]any) error {
326+
if err := genFoldPositional(args, body, "alert_ids", "slice"); err != nil {
327+
return err
328+
}
329+
if cmd.Flags().Changed("alert-ids") {
330+
body["alert_ids"] = fAlertIDs
331+
}
332+
if cmd.Flags().Changed("comment-file") {
333+
comment, err := resolveCommentFile(fCommentFile)
334+
if err != nil {
335+
return err
336+
}
337+
body["comment"] = comment
338+
}
339+
if cmd.Flags().Changed("incident-id") {
340+
body["incident_id"] = fIncidentID
341+
}
342+
if cmd.Flags().Changed("owner-id") {
343+
body["owner_id"] = fOwnerID
344+
}
345+
if cmd.Flags().Changed("title") {
346+
body["title"] = fTitle
347+
}
348+
return nil
349+
})
350+
if err != nil {
351+
return err
352+
}
353+
req := new(flashduty.AlertMergeRequest)
354+
if err := genBindBody(body, req); err != nil {
355+
return err
356+
}
357+
resp, err := ctx.Client.Alerts.WriteMerge(cmdContext(ctx.Cmd), req)
358+
if err != nil {
359+
return err
360+
}
361+
if resp != nil && len(resp.Raw) > 0 {
362+
return ctx.WriteRaw(resp.Raw)
363+
}
364+
ctx.WriteResult("OK: POST /alert/merge")
365+
return nil
366+
})
367+
},
368+
}
369+
370+
cmd.Flags().StringSliceVar(&fAlertIDs, "alert-ids", nil, "Alert IDs to merge. (required)")
371+
cmd.Flags().StringVar(&fCommentFile, "comment-file", "", "Path to a file containing an optional comment on the merge action (- reads stdin)")
372+
cmd.Flags().StringVar(&fIncidentID, "incident-id", "", "Target incident ID. (required)")
373+
cmd.Flags().Int64Var(&fOwnerID, "owner-id", 0, "Optional new owner for the target incident.")
374+
cmd.Flags().StringVar(&fTitle, "title", "", "Optional new title for the target incident.")
375+
cmd.Flags().StringVar(&dataJSON, "data", "", "Full request body as JSON; positional arguments and typed flags override its fields. Accepts inline JSON, or - to read stdin.")
376+
377+
return cmd
378+
}
379+
283380
// resolveAlertFeedOperators resolves the actor (creator) person IDs of
284381
// alert-feed items to display names via /person/infos, replicating the
285382
// operator-name enrichment the legacy SDK did server-side. Best-effort: a

internal/cli/alert_test.go

Lines changed: 71 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
package cli
22

33
import (
4+
"strings"
45
"testing"
56
)
67

@@ -60,3 +61,73 @@ func TestCommandAlertListNoStatusFilterOmitsIsActive(t *testing.T) {
6061
t.Errorf("ever_muted should be omitted without --muted, got %#v", stub.lastBody["ever_muted"])
6162
}
6263
}
64+
65+
// TestCommandAlertMergeCommentFileReachesWireByteForByte guards the same
66+
// shell-interpolation fix applied to incident comment (see
67+
// TestCommandIncidentCommentPreservesShellMetacharactersByteForByte): alert
68+
// merge's comment must come from --comment-file, never an inline shell
69+
// argument, so backticks, $(...), and quotes inside an LLM-authored comment
70+
// reach the API exactly as written.
71+
func TestCommandAlertMergeCommentFileReachesWireByteForByte(t *testing.T) {
72+
saveAndResetGlobals(t)
73+
stub := newGFStub(t)
74+
75+
malicious := "Root cause: restart via `kubectl rollout restart deploy/api`.\n" +
76+
"Then ran $(rm -rf /tmp/scratch) to clean up staging state.\n" +
77+
"Quotes: \"double\" and 'single' and it's mine.\n"
78+
commentFile := writeCommentFile(t, malicious)
79+
80+
out, err := execCommand("alert", "merge", "alert-1", "alert-2",
81+
"--incident-id", "inc-1", "--comment-file", commentFile)
82+
if err != nil {
83+
t.Fatalf("[alert-merge-comment-file] unexpected error: %v", err)
84+
}
85+
if stub.lastPath != "/alert/merge" {
86+
t.Fatalf("[alert-merge-comment-file] expected /alert/merge, got %q", stub.lastPath)
87+
}
88+
if stub.lastBody["comment"] != malicious {
89+
t.Fatalf("[alert-merge-comment-file] comment reached the API mangled:\nwant: %q\n got: %q", malicious, stub.lastBody["comment"])
90+
}
91+
if got, want := strings.Join(stringsField(stub.lastBody, "alert_ids"), ","), "alert-1,alert-2"; got != want {
92+
t.Fatalf("[alert-merge-comment-file] expected alert_ids %q, got %q", want, got)
93+
}
94+
if stub.lastBody["incident_id"] != "inc-1" {
95+
t.Fatalf("[alert-merge-comment-file] expected incident_id %q, got %#v", "inc-1", stub.lastBody["incident_id"])
96+
}
97+
if !strings.Contains(out, "OK: POST /alert/merge") {
98+
t.Fatalf("[alert-merge-comment-file] unexpected output:\n%s", out)
99+
}
100+
}
101+
102+
// TestCommandAlertMergeWithoutCommentFileOmitsComment guards that the merge
103+
// comment stays optional now that it is sourced from a file: not passing
104+
// --comment-file must not send an empty "comment" field.
105+
func TestCommandAlertMergeWithoutCommentFileOmitsComment(t *testing.T) {
106+
saveAndResetGlobals(t)
107+
stub := newGFStub(t)
108+
109+
if _, err := execCommand("alert", "merge", "alert-1", "--incident-id", "inc-1"); err != nil {
110+
t.Fatalf("[alert-merge-no-comment] unexpected error: %v", err)
111+
}
112+
if _, ok := stub.lastBody["comment"]; ok {
113+
t.Fatalf("[alert-merge-no-comment] comment should be omitted, got %#v", stub.lastBody["comment"])
114+
}
115+
}
116+
117+
// TestCommandAlertMergeEmptyCommentFileGivesCleanError guards routing alert
118+
// merge's optional --comment-file through the same resolveCommentFile helper
119+
// incident comment uses: an explicit but empty --comment-file value must fail
120+
// with resolveCommentFile's clean "must not be empty" message, not the raw
121+
// os.ReadFile("") error.
122+
func TestCommandAlertMergeEmptyCommentFileGivesCleanError(t *testing.T) {
123+
saveAndResetGlobals(t)
124+
newGFStub(t)
125+
126+
_, err := execCommand("alert", "merge", "alert-1", "--incident-id", "inc-1", "--comment-file", "")
127+
if err == nil {
128+
t.Fatal("[alert-merge-empty-comment-file] expected an error, got nil")
129+
}
130+
if !strings.Contains(err.Error(), "--comment-file must not be empty") {
131+
t.Fatalf("[alert-merge-empty-comment-file] expected the clean resolveCommentFile message, got: %v", err)
132+
}
133+
}

internal/cli/automation.go

Lines changed: 1 addition & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,6 @@ package cli
22

33
import (
44
"fmt"
5-
"io"
65
"os"
76
"strconv"
87
"strings"
@@ -527,15 +526,7 @@ func resolveAutomationPrompt(cmd *cobra.Command, prompt, promptFile string) (str
527526
if promptFile == "" {
528527
return "", fmt.Errorf("--prompt-file must not be empty")
529528
}
530-
var (
531-
b []byte
532-
err error
533-
)
534-
if promptFile == "-" {
535-
b, err = io.ReadAll(stdinReader)
536-
} else {
537-
b, err = os.ReadFile(promptFile)
538-
}
529+
b, err := readPathOrStdin(promptFile)
539530
if err != nil {
540531
return "", fmt.Errorf("failed to read prompt file: %w", err)
541532
}

0 commit comments

Comments
 (0)