diff --git a/cmd/preq/preq.go b/cmd/preq/preq.go index 001619d..b830936 100644 --- a/cmd/preq/preq.go +++ b/cmd/preq/preq.go @@ -14,6 +14,7 @@ import ( ) var vars = kong.Vars{ + "actionHelp": ux.HelpAction, "disabledHelp": ux.HelpDisabled, "generateHelp": ux.HelpGenerate, "cronHelp": ux.HelpCron, diff --git a/internal/pkg/cli/cli.go b/internal/pkg/cli/cli.go index 546bb06..e89e7a0 100644 --- a/internal/pkg/cli/cli.go +++ b/internal/pkg/cli/cli.go @@ -12,6 +12,7 @@ import ( "github.com/prequel-dev/preq/internal/pkg/engine" "github.com/prequel-dev/preq/internal/pkg/resolve" "github.com/prequel-dev/preq/internal/pkg/rules" + "github.com/prequel-dev/preq/internal/pkg/runbook" "github.com/prequel-dev/preq/internal/pkg/timez" "github.com/prequel-dev/preq/internal/pkg/utils" "github.com/prequel-dev/preq/internal/pkg/ux" @@ -20,6 +21,7 @@ import ( ) var Options struct { + Action string `short:"a" help:"${actionHelp}"` Disabled bool `short:"d" help:"${disabledHelp}"` Generate bool `short:"g" help:"${generateHelp}"` Cron bool `short:"j" help:"${cronHelp}"` @@ -263,26 +265,20 @@ LOOP: log.Debug().Msg("No CREs found") return nil - case c.Notification.Type == ux.NotificationSlack: + case Options.Action != "": + log.Debug().Str("path", Options.Action).Msg("Running action") - log.Debug().Msgf("Posting Slack notification to %s", c.Notification.Webhook) - - if err = report.PostSlackDetection(ctx, c.Notification.Webhook, Options.Name); err != nil { - log.Error().Err(err).Msg("Failed to post Slack notification") + report, err := report.CreateReport() + if err != nil { + log.Error().Err(err).Msg("Failed to create report") ux.RulesError(err) return err } - if !Options.Quiet { - - // Print reports to stdout when notifications are enabled - if err = report.PrintReport(); err != nil { - log.Error().Err(err).Msg("Failed to print report") - ux.RulesError(err) - return err - } - - fmt.Fprintf(os.Stdout, "\nSent Slack notification\n") + if err := runbook.Runbook(ctx, Options.Action, report); err != nil { + log.Error().Err(err).Msg("Failed to run action") + ux.RulesError(err) + return err } case Options.Name == ux.OutputStdout: diff --git a/internal/pkg/config/config.go b/internal/pkg/config/config.go index ebe32b2..9cb7c9c 100644 --- a/internal/pkg/config/config.go +++ b/internal/pkg/config/config.go @@ -175,21 +175,15 @@ var ( windowConfig = `window: %s` ) -type NotificationWebhook struct { - Type string `yaml:"type"` - Webhook string `yaml:"webhook"` -} - type Config struct { - TimestampRegexes []Regex `yaml:"timestamps"` - Rules Rules `yaml:"rules"` - UpdateFrequency *time.Duration `yaml:"updateFrequency"` - RulesVersion string `yaml:"rulesVersion"` - AcceptUpdates bool `yaml:"acceptUpdates"` - DataSources string `yaml:"dataSources"` - Notification NotificationWebhook `yaml:"notification"` - Window time.Duration `yaml:"window"` - Skip int `yaml:"skip"` + TimestampRegexes []Regex `yaml:"timestamps"` + Rules Rules `yaml:"rules"` + UpdateFrequency *time.Duration `yaml:"updateFrequency"` + RulesVersion string `yaml:"rulesVersion"` + AcceptUpdates bool `yaml:"acceptUpdates"` + DataSources string `yaml:"dataSources"` + Window time.Duration `yaml:"window"` + Skip int `yaml:"skip"` } type Rules struct { diff --git a/internal/pkg/runbook/exec.go b/internal/pkg/runbook/exec.go new file mode 100644 index 0000000..9673da4 --- /dev/null +++ b/internal/pkg/runbook/exec.go @@ -0,0 +1,51 @@ +package runbook + +import ( + "bytes" + "context" + "encoding/json" + "errors" + "os" + "os/exec" + "text/template" +) + +type execConfig struct { + Path string `yaml:"path"` + Args []string `yaml:"args"` +} + +type execAction struct { + cfg execConfig +} + +func newExecAction(cfg execConfig) (Action, error) { + if cfg.Path == "" { + return nil, errors.New("exec.path is required") + } + return &execAction{cfg: cfg}, nil +} + +func (e *execAction) Execute(ctx context.Context, cre map[string]any) error { + args := make([]string, len(e.cfg.Args)) + for i, a := range e.cfg.Args { + tmpl, err := template.New("arg").Funcs(funcMap()).Parse(a) + if err != nil { + return err + } + if err := executeTemplate(&args[i], tmpl, cre); err != nil { + return err + } + } + + raw, err := json.Marshal(cre) + if err != nil { + return err + } + + cmd := exec.CommandContext(ctx, e.cfg.Path, args...) + cmd.Stdin = bytes.NewReader(raw) + cmd.Stdout = os.Stdout + cmd.Stderr = os.Stderr + return cmd.Run() +} diff --git a/internal/pkg/runbook/jira.go b/internal/pkg/runbook/jira.go new file mode 100644 index 0000000..2fbad11 --- /dev/null +++ b/internal/pkg/runbook/jira.go @@ -0,0 +1,121 @@ +package runbook + +import ( + "bytes" + "context" + "encoding/json" + "errors" + "fmt" + "io" + "net/http" + "os" + "text/template" + "time" +) + +type jiraConfig struct { + WebhookURL string `yaml:"webhook_url"` + Secret string `yaml:"secret"` // optional + SecretEnv string `yaml:"secret_env"` // optional + SummaryTemplate string `yaml:"summary_template"` + DescriptionTemplate string `yaml:"description_template"` + ProjectKey string `yaml:"project_key"` // e.g. "PREQ" +} + +type jiraAction struct { + cfg jiraConfig + summaryTmpl *template.Template + descTmpl *template.Template + httpc *http.Client +} + +func newJiraAction(cfg jiraConfig) (Action, error) { + if cfg.WebhookURL == "" { + return nil, errors.New("jira.webhook_url is required") + } + if cfg.SummaryTemplate == "" { + return nil, errors.New("jira.summary_template is required") + } + if cfg.ProjectKey == "" { + return nil, errors.New("jira.project_key is required when using REST API mode") + } + st, err := template.New("jira-summary").Funcs(funcMap()).Parse(cfg.SummaryTemplate) + if err != nil { + return nil, err + } + dt, err := template.New("jira-desc").Funcs(funcMap()).Parse(cfg.DescriptionTemplate) + if err != nil { + return nil, err + } + + if cfg.Secret == "" && cfg.SecretEnv != "" { + cfg.Secret = os.Getenv(cfg.SecretEnv) + } + // optional: hard‑fail if both were empty + if cfg.Secret == "" { + return nil, errors.New("jira secret missing; set either 'secret' or 'secret_env'") + } + + return &jiraAction{ + cfg: cfg, + summaryTmpl: st, + descTmpl: dt, + httpc: &http.Client{ + Timeout: 5 * time.Second, + }, + }, nil +} + +func (j *jiraAction) Execute(ctx context.Context, cre map[string]any) error { + var summary, desc string + if err := executeTemplate(&summary, j.summaryTmpl, cre); err != nil { + return err + } + if err := executeTemplate(&desc, j.descTmpl, cre); err != nil { + return err + } + payload := map[string]any{ + "project": map[string]any{"key": j.cfg.ProjectKey}, + "summary": summary, + "description": adfParagraph(desc), + "issuetype": map[string]any{"name": "Bug"}, + } + body, _ := json.Marshal(payload) + req, err := http.NewRequestWithContext(ctx, http.MethodPost, j.cfg.WebhookURL, + bytes.NewReader(body)) + if err != nil { + return fmt.Errorf("jira post: %w", err) + } + req.Header.Set("Content-Type", "application/json") + if j.cfg.Secret != "" { + req.Header.Set("X-Automation-Webhook-Token", j.cfg.Secret) + } + resp, err := j.httpc.Do(req) + if err != nil { + return fmt.Errorf("jira post: %w", err) + } + defer resp.Body.Close() + if resp.StatusCode >= 300 { + respBody, _ := io.ReadAll(resp.Body) + return fmt.Errorf("jira post failed: %s – %s", resp.Status, respBody) + } + return nil +} + +func adfParagraph(txt string) map[string]any { + return map[string]any{ + "type": "doc", + "version": 1, + "content": []any{ + map[string]any{ + "type": "paragraph", + "content": []any{ + map[string]any{ + "type": "text", + "text": txt, + }, + }, + }, + }, + } +} diff --git a/internal/pkg/runbook/runbook.go b/internal/pkg/runbook/runbook.go new file mode 100644 index 0000000..4e742fa --- /dev/null +++ b/internal/pkg/runbook/runbook.go @@ -0,0 +1,222 @@ +package runbook + +import ( + "bytes" + "context" + "fmt" + "os" + "reflect" + "regexp" + "text/template" + + "github.com/prequel-dev/preq/internal/pkg/ux" + "github.com/rs/zerolog/log" + "gopkg.in/yaml.v3" +) + +/* +actions: + - type: slack + regex: "CRE-2025-00*" + slack: + webhook_url: https://hooks.slack.com/services/... + message_template: | + *preq detection*: [{{ field .cre "Id" }}] {{ field .cre "Title" }} + + {{ (index .hits 0).Timestamp }}: {{ (index .hits 0).Entry }} + - type: exec + regex: "CRE-2025-0025" + exec: + path: ./action.sh + args: + - '{{ field .cre "Id" }}' + - '{{ len .hits }}' + - type: jira + regex: "CRE-2025-0025" + jira: + project_key: KAN + webhook_url: https://prequel-team.atlassian.net/rest/api/3/issue + secret_env: JIRA_TOKEN + summary_template: | + *preq detection*: [{{ field .cre "Id" }}] {{ field .cre "Title" }} + description_template: | + {{ (index .hits 0).Timestamp }}: {{ (index .hits 0).Entry }} +*/ + +const ( + ActionTypeSlack = "slack" + ActionTypeJira = "jira" + ActionTypeExec = "exec" +) + +type Action interface { + Execute(ctx context.Context, cre map[string]any) error +} + +type configFile struct { + Actions []actionConfig `yaml:"actions"` +} + +type actionConfig struct { + Type string `yaml:"type"` + Regex string `yaml:"regex,omitempty"` + + Slack *slackConfig `yaml:"slack,omitempty"` + Jira *jiraConfig `yaml:"jira,omitempty"` + Exec *execConfig `yaml:"exec,omitempty"` +} + +func extractCreId(ev map[string]any) string { + if cre, ok := ev["cre"]; ok { + // map variant + if m, ok := cre.(map[string]any); ok { + if id, ok := m["id"].(string); ok { + return id + } + if id, ok := m["ID"].(string); ok { + return id + } + } + // struct variant + v := reflect.ValueOf(cre) + if v.Kind() == reflect.Pointer { + v = v.Elem() + } + if v.IsValid() && v.Kind() == reflect.Struct { + f := v.FieldByName("ID") + if f.IsValid() && f.Kind() == reflect.String { + return f.String() + } + } + } + // fallback to top-level id + if id, ok := ev["id"].(string); ok { + return id + } + return "" +} + +// ----- decorator that runs the action only when CRE ID matches --------------- +type filteredAction struct { + pattern *regexp.Regexp + inner Action +} + +func (f *filteredAction) Execute(ctx context.Context, ev map[string]any) error { + if f.pattern == nil { // no filter → always run + return f.inner.Execute(ctx, ev) + } + if id := extractCreId(ev); id != "" && f.pattern.MatchString(id) { + return f.inner.Execute(ctx, ev) // match → run + } + return nil // no match → silently skip +} + +func buildActions(cfgPath string) ([]Action, error) { + raw, err := os.ReadFile(cfgPath) + if err != nil { + return nil, err + } + var file configFile + if err := yaml.Unmarshal(raw, &file); err != nil { + return nil, err + } + + actions := make([]Action, 0, len(file.Actions)) + for i, c := range file.Actions { + var a Action + switch c.Type { + case ActionTypeSlack: + if c.Slack == nil { + return nil, fmt.Errorf("missing slack section for action #%d", i) + } + a, err = newSlackAction(*c.Slack) + case ActionTypeJira: + if c.Jira == nil { + return nil, fmt.Errorf("missing jira section for action #%d", i) + } + a, err = newJiraAction(*c.Jira) + case ActionTypeExec: + if c.Exec == nil { + return nil, fmt.Errorf("missing exec section for action #%d", i) + } + a, err = newExecAction(*c.Exec) + default: + err = fmt.Errorf("unknown action type %q (index %d)", c.Type, i) + } + if err != nil { + return nil, err + } + + if c.Regex != "" { + re, err := regexp.Compile(c.Regex) + if err != nil { + return nil, fmt.Errorf("invalid cre_id_regex for action #%d: %w", i, err) + } + a = &filteredAction{pattern: re, inner: a} + } + actions = append(actions, a) + } + return actions, nil +} + +// template helper function to extract fields from CRE reports +func funcMap() template.FuncMap { + return template.FuncMap{ + // field works with map[string]any OR struct / *struct + "field": func(obj any, name string) any { + if obj == nil { + log.Error().Msg("field: obj is nil") + return nil + } + // map + if m, ok := obj.(map[string]any); ok { + log.Info().Msgf("field: obj is map[string]any, name: %s", name) + return m[name] + } + // struct via reflection + v := reflect.ValueOf(obj) + if v.Kind() == reflect.Pointer { + log.Info().Msg("field: obj is pointer") + v = v.Elem() + } + if v.IsValid() && v.Kind() == reflect.Struct { + log.Info().Msgf("field: obj is struct, name: %s", name) + f := v.FieldByName(name) + if f.IsValid() { + log.Info().Msgf("field: obj is struct, name: %s, value: %v", name, f.Interface()) + return f.Interface() + } + } + log.Error().Msgf("field: unknown type: %T", obj) + return nil // unknown + }, + } +} + +func executeTemplate(out *string, tmpl *template.Template, data any) error { + var buf bytes.Buffer + if err := tmpl.Execute(&buf, data); err != nil { + return err + } + *out = buf.String() + return nil +} + +func Runbook(ctx context.Context, cfgPath string, report ux.ReportDocT) error { + + actions, err := buildActions(cfgPath) + if err != nil { + return err + } + + for _, a := range actions { + for _, cre := range report { + if err := a.Execute(ctx, cre); err != nil { + return err + } + } + } + + return nil +} diff --git a/internal/pkg/runbook/slack.go b/internal/pkg/runbook/slack.go new file mode 100644 index 0000000..bf1371e --- /dev/null +++ b/internal/pkg/runbook/slack.go @@ -0,0 +1,72 @@ +package runbook + +import ( + "bytes" + "context" + "encoding/json" + "errors" + "fmt" + "io" + "net/http" + "text/template" + "time" +) + +type slackConfig struct { + WebhookURL string `yaml:"webhook_url"` + MessageTemplate string `yaml:"message_template"` +} + +type slackAction struct { + cfg slackConfig + tmpl *template.Template + httpc *http.Client +} + +func newSlackAction(cfg slackConfig) (Action, error) { + if cfg.WebhookURL == "" { + return nil, errors.New("slack.webhook_url is required") + } + if cfg.MessageTemplate == "" { + return nil, errors.New("slack.message_template is required") + } + t, err := template.New("slack").Funcs(funcMap()).Parse(cfg.MessageTemplate) + if err != nil { + return nil, err + } + + return &slackAction{ + cfg: cfg, + tmpl: t, + httpc: &http.Client{ + Timeout: 5 * time.Second, + }, + }, nil +} + +func (s *slackAction) Execute(ctx context.Context, cre map[string]any) error { + var msg string + if err := executeTemplate(&msg, s.tmpl, cre); err != nil { + return err + } + payload := struct { + Text string `json:"text"` + }{Text: msg} + body, _ := json.Marshal(payload) + req, err := http.NewRequestWithContext(ctx, http.MethodPost, s.cfg.WebhookURL, + bytes.NewReader(body)) + if err != nil { + return fmt.Errorf("slack post: %w", err) + } + req.Header.Set("Content-Type", "application/json") + resp, err := s.httpc.Do(req) + if err != nil { + return fmt.Errorf("slack post: %w", err) + } + defer resp.Body.Close() + if resp.StatusCode >= 300 { + respBody, _ := io.ReadAll(resp.Body) + return fmt.Errorf("slack post failed: %s – %s", resp.Status, respBody) + } + return nil +} diff --git a/internal/pkg/ux/report.go b/internal/pkg/ux/report.go index c4055c7..11b2df9 100644 --- a/internal/pkg/ux/report.go +++ b/internal/pkg/ux/report.go @@ -1,18 +1,14 @@ package ux import ( - "bytes" - "context" "encoding/json" "errors" "fmt" - "net/http" "os" "sort" "sync" "time" - "github.com/avast/retry-go/v4" "github.com/prequel-dev/preq/internal/pkg/matchz" "github.com/prequel-dev/prequel-compiler/pkg/parser" @@ -39,14 +35,8 @@ const ( reportFmt = "preq-report-%d.json" ) -const ( - NotificationSlack = "slack" -) - var ( sevWidth = max(len(sevCritical), len(sevHigh), len(sevMedium), len(sevLow), len(sevInfo)) - retries = uint(3) - delay = time.Second * 5 ) type ReportT struct { @@ -308,68 +298,3 @@ func (r *ReportT) createReport() (ReportDocT, error) { return out, nil } - -func (r *ReportT) PostSlackDetection(ctx context.Context, url string, notificationContext string) error { - return r.postSlackDetection(ctx, url, notificationContext) -} - -func (r *ReportT) postSlackDetection(ctx context.Context, url, notificationContext string) error { - - var ( - notification string - msg = make(map[string]any) - jsonData []byte - err error - ) - - notification = notificationContext - - for creId := range r.CreHits { - sev, err := getSeverity(r.Rules[creId].Cre.Severity) - if err != nil { - log.Error().Err(err).Msg("Failed to get severity") - continue - } - notification += fmt.Sprintf("%s (%s), ", creId, sev.severity) - } - - // remove the last comma - notification = notification[:len(notification)-2] - msg["text"] = notification - - jsonData, err = json.Marshal(msg) - if err != nil { - return err - } - - httpRequest, err := http.NewRequest("POST", url, bytes.NewBuffer(jsonData)) - if err != nil { - return err - } - - httpRequest.Header.Set("Accept", "application/json") - - client := &http.Client{} - - return retry.Do( - func() error { - - resp, err := client.Do(httpRequest) - if err != nil { - log.Error().Err(err).Msg("Fail client.Do()") - return err - } - defer resp.Body.Close() - - return nil - }, - retry.Attempts(retries), - retry.Delay(delay), - retry.Context(ctx), - retry.OnRetry(func(u uint, err error) { - log.Error().Err(err).Uint("retry", u).Msg("Retry token poll error") - }), - retry.DelayType(retry.BackOffDelay), - retry.LastErrorOnly(true), - ) -} diff --git a/internal/pkg/ux/ux.go b/internal/pkg/ux/ux.go index 884ecad..203b082 100644 --- a/internal/pkg/ux/ux.go +++ b/internal/pkg/ux/ux.go @@ -90,6 +90,7 @@ Happy hunting!` ) var ( + HelpAction = "Path to an automated action or runbook config file" HelpCron = "Generate Kubernetes cronjob template" HelpDisabled = "Do not run community CREs" HelpGenerate = "Generate data sources template"