notify

package
v1.19.1 Latest Latest
Warning

This package is not in the latest version of its module.

Go to latest
Published: May 29, 2026 License: MIT Imports: 20 Imported by: 0

Documentation

Overview

Package notify dispatches compliancekit Findings to operator- configured channels (Slack, Discord, Teams, email, generic webhook, GitHub PR comments, Jira, PagerDuty) per the v0.17 milestone.

Per ADR-006 the binary remains read-only end-to-end: a notification is *generation* — the package POSTs a payload an operator already asked to receive. The same constraints that apply to remediate + ingest apply here: no telemetry, no phone-home, no analytics. Every notification target is operator-configured via env vars and `compliancekit.yaml`. A sink with missing credentials reports Configured()=false and is skipped silently; one failing sink never blocks the others.

Package layout:

internal/notify/notify.go        — this file: Notifier interface,
                                    Registry, Notification + Result
                                    types, severity gate, builder.
internal/notify/config.go        — typed config block (`notify:`
                                    in compliancekit.yaml).
internal/notify/<sink>.go        — one file per sink: slack.go,
                                    discord.go, teams.go, webhook.go,
                                    email.go, github.go, jira.go,
                                    pagerduty.go.
internal/notify/<sink>_test.go   — per-sink httptest contract test.

CLI surface is `compliancekit notify --in=findings.json --config=compliancekit.yaml` (Phase 10).

Index

Constants

This section is empty.

Variables

View Source
var Default = NewRegistry()

Default is the process-wide registry every sink package registers against from init(). The CLI uses Default.Sinks(); tests build isolated registries via NewRegistry.

View Source
var ErrAuth = errors.New("notify: authentication failed")

ErrAuth is returned by sinks on 401/403. The CLI surfaces this with a clear "check your credentials" message.

View Source
var HTTPClient = &http.Client{Timeout: 30 * time.Second}

HTTPClient is the shared http.Client every webhook-based sink uses. Single connection pool + 30s timeout so a misconfigured sink can't hang the dispatch. Tests can substitute their own via the per-sink config struct.

Functions

func Register

func Register(n Notifier)

Register installs a sink into Default.

Types

type BuildOptions

type BuildOptions struct {
	// URLPrefix is prepended to a per-finding deep-link path. Empty
	// string means no URL is rendered. Operators with a hosted
	// evidence pack point this at their dashboard.
	URLPrefix string

	// Project is the project identifier stamped into the title +
	// tags so cross-project notifications stay distinguishable.
	Project string
}

BuildOptions controls Notification rendering.

type Discord

type Discord struct {
	// contains filtered or unexported fields
}

Discord implements Notifier for Discord incoming webhooks.

func NewDiscord

func NewDiscord(cfg DiscordConfig) *Discord

NewDiscord constructs a Discord sink.

func (*Discord) Configured

func (d *Discord) Configured() bool

Configured returns true when the webhook URL is set.

func (*Discord) Name

func (d *Discord) Name() string

Name implements Notifier.

func (*Discord) Send

func (d *Discord) Send(ctx context.Context, notifications []Notification) (Result, error)

Send dispatches the notifications via the webhook. Per-notification failures accumulate; top-level error only when every send failed.

func (*Discord) Threshold

func (d *Discord) Threshold() compliancekit.Severity

Threshold returns the per-sink severity floor.

type DiscordConfig

type DiscordConfig struct {
	WebhookURL    string
	SeverityFloor compliancekit.Severity
	HTTPClient    *http.Client
}

DiscordConfig configures the Discord sink. Discord uses webhook URLs exclusively for outbound notifications; the bot-token API requires a Gateway connection which is out of scope.

Env: DISCORD_WEBHOOK_URL, DISCORD_THRESHOLD.

type Email

type Email struct {
	// contains filtered or unexported fields
}

Email implements Notifier for SMTP delivery.

func NewEmail

func NewEmail(cfg EmailConfig) *Email

NewEmail constructs an Email sink.

func (*Email) Configured

func (e *Email) Configured() bool

Configured returns true when host + from + at least one recipient are present. Username/password are optional; some operators run authenticated relays, others run unauthenticated relays inside trusted networks.

func (*Email) Name

func (e *Email) Name() string

Name implements Notifier.

func (*Email) Send

func (e *Email) Send(_ context.Context, notifications []Notification) (Result, error)

Send dispatches the notifications. One message per notification — keeps subject lines targeted and lets recipients filter per check.

func (*Email) Threshold

func (e *Email) Threshold() compliancekit.Severity

Threshold returns the per-sink severity floor.

type EmailConfig

type EmailConfig struct {
	Host       string
	Port       int
	Username   string
	Password   string
	From       string
	To         []string
	TLSMode    string // starttls | tls | none
	SkipVerify bool   // disable cert verification — test/dev only

	SeverityFloor compliancekit.Severity
	// contains filtered or unexported fields
}

EmailConfig configures the SMTP sink. Supports three TLS modes:

  • "starttls" (default for port 587) — connect plain, upgrade via STARTTLS before AUTH.
  • "tls" (default for port 465) — connect TLS immediately (implicit TLS / "SMTPS").
  • "none" — plaintext, AUTH optional. Only safe inside trusted networks (Postfix on localhost, milter sidecar).

Env: SMTP_HOST, SMTP_PORT, SMTP_USERNAME, SMTP_PASSWORD, SMTP_FROM, SMTP_TO (comma-separated), SMTP_TLS (starttls|tls|none), SMTP_THRESHOLD.

type GitHub

type GitHub struct {
	// contains filtered or unexported fields
}

GitHub implements Notifier for GitHub PR comments.

func NewGitHub

func NewGitHub(cfg GitHubConfig) *GitHub

NewGitHub constructs a GitHub sink.

func (*GitHub) Configured

func (g *GitHub) Configured() bool

Configured returns true when token + repo + PR number are set.

func (*GitHub) Name

func (g *GitHub) Name() string

Name implements Notifier.

func (*GitHub) Send

func (g *GitHub) Send(ctx context.Context, notifications []Notification) (Result, error)

Send posts a single summary comment to the PR. Each notification becomes a markdown bullet listing severity + title + link. The "one comment per dispatch" choice avoids the PR-comment spam that "one comment per finding" would produce on noisy scans.

func (*GitHub) Threshold

func (g *GitHub) Threshold() compliancekit.Severity

Threshold returns the per-sink severity floor.

type GitHubConfig

type GitHubConfig struct {
	Token         string
	Repo          string // owner/name
	PRNumber      int
	APIURL        string // defaults to https://api.github.com
	SeverityFloor compliancekit.Severity
	HTTPClient    *http.Client
}

GitHubConfig configures the GitHub PR comment sink. Posts one summary comment per dispatch (NOT per finding) to avoid PR-comment spam — the comment lists every notification as a markdown bullet.

The token needs `pull_requests:write` (fine-grained) or `repo` (classic). For the GitHub Action use case, GITHUB_TOKEN already has it; for ad-hoc runs the operator generates a PAT.

Env: GITHUB_TOKEN, GITHUB_REPO ("owner/name"), GITHUB_PR_NUMBER, GITHUB_API_URL (override for GHES), GITHUB_THRESHOLD.

type Jira

type Jira struct {
	// contains filtered or unexported fields
}

Jira implements Notifier by adapting the v0.15 tickets.Jira client.

func NewJira

func NewJira(cfg JiraConfig) *Jira

NewJira constructs a Jira sink. Holds a tickets.Jira instance internally so the per-call Send path stays small.

func (*Jira) Configured

func (j *Jira) Configured() bool

Configured proxies to the underlying ticket client.

func (*Jira) Name

func (j *Jira) Name() string

Name implements Notifier.

func (*Jira) Send

func (j *Jira) Send(ctx context.Context, notifications []Notification) (Result, error)

Send creates one Jira issue per notification. Per-notification failures accumulate in Result.Errors; top-level error only when every send failed.

func (*Jira) Threshold

func (j *Jira) Threshold() compliancekit.Severity

Threshold returns the per-sink severity floor.

type JiraConfig

type JiraConfig struct {
	Host       string
	Email      string
	Token      string
	ProjectKey string
	IssueType  string

	SeverityFloor compliancekit.Severity
	HTTPClient    *http.Client
}

JiraConfig configures the Jira notification sink. Reuses the v0.15 internal/remediate/tickets.Jira client wholesale — every Jira API concern (REST v3 endpoint, basic auth, ADF body, severity → priority mapping) is already covered there.

The difference between this sink and `compliancekit remediate --tickets` is intent: remediate files a ticket for every manual- remediation finding once; notify files for any actionable finding crossing the per-sink threshold every time `notify` runs. Dedup (Phase 10) prevents the same finding firing repeatedly.

Env: JIRA_NOTIFY_* envs (separate from JIRA_* used by remediate so an operator can route remediation-tickets and notification- tickets to different projects). Falls back to JIRA_* when the notify-prefixed ones aren't set.

type Notification

type Notification struct {
	// Finding is the upstream finding. Sinks needing fields beyond
	// title/body (severity color, CheckID, framework refs) pull
	// them from here.
	Finding compliancekit.Finding

	// Title is a single-line headline rendered for the sink (e.g.
	// "[CRITICAL] aws-s3-public-access-block on prod-data-bucket").
	Title string

	// Body is the per-notification body in CommonMark (plain markdown).
	// Each sink may convert: Discord + Slack render natively; Teams
	// converts to MessageCard; email wraps in multipart MIME;
	// webhook + PagerDuty pass as-is in a body field.
	Body string

	// URL points operators at where to act: usually the
	// `compliancekit checks show <id>` URL, the GitHub PR, or the
	// finding's evidence-pack URL. Optional — sinks omit when empty.
	URL string

	// Tags are sink-routing hints (e.g. "team:security",
	// "service:checkout"). Sinks that support tagging surface them;
	// sinks that don't (PagerDuty, email subject) ignore.
	Tags []string

	// Fingerprint is the dedup key. Built from
	// Finding.Fingerprint()+sink-name so the same finding fires once
	// per sink, not once per sink × notification window. Populated
	// by the dispatcher (Phase 10), not by the sink itself.
	Fingerprint string
}

Notification is the canonical shape every Notifier consumes. It carries the original Finding plus pre-rendered text (title + body) that sinks adapt to their wire format. Sinks needing rich shapes (PagerDuty event JSON, Slack blocks) build them from Finding + rendered text together.

func BuildNotifications

func BuildNotifications(findings []compliancekit.Finding, opts BuildOptions) []Notification

BuildNotifications converts a Findings slice into the canonical Notification slice every sink consumes. Caller-provided Options control rendering (URL prefix for "checks show", baseline for only-new-findings mode — Phase 10).

Severity gating is applied per-sink inside Dispatch, not here — the same Notification slice may flow to a low-threshold sink (Slack: all-medium-plus) and a high-threshold one (PagerDuty: critical-only). Building once and gating per sink keeps the rendering cost amortized.

type Notifier

type Notifier interface {
	// Name returns a short, unique, kebab-case identifier for the
	// sink. Used in error messages, `compliancekit doctor` output,
	// the per-sink `notify:` config block, and the Result rendering.
	Name() string

	// Configured reports whether the sink has enough config to call
	// its upstream. Missing credentials = not configured = caller
	// skips silently. This is the same pattern as the v0.15 ticket
	// providers.
	Configured() bool

	// Threshold returns the per-sink severity floor below which
	// notifications are dropped. compliancekit.SeverityInfo means "send
	// everything". The CLI / config layer can override per-sink.
	Threshold() compliancekit.Severity

	// Send dispatches the slice of Notification objects already
	// filtered by the global severity gate. Returns a Result tally;
	// transport errors return the second return value but never
	// abort other Notifications in the slice — sinks should partial-
	// succeed and report partial-error counts.
	Send(ctx context.Context, notifications []Notification) (Result, error)
}

Notifier is the contract every channel implements. Implementations must be stateless: the same instance is reused across notify invocations and across goroutines. Per-call state flows through the Notification slice + the context.

type PagerDuty

type PagerDuty struct {
	// contains filtered or unexported fields
}

PagerDuty implements Notifier for PagerDuty Events v2.

func NewPagerDuty

func NewPagerDuty(cfg PagerDutyConfig) *PagerDuty

NewPagerDuty constructs a PagerDuty sink with sensible operational defaults: Critical-only severity, "compliancekit" source.

func (*PagerDuty) Configured

func (p *PagerDuty) Configured() bool

Configured returns true when the integration key is set.

func (*PagerDuty) Name

func (p *PagerDuty) Name() string

Name implements Notifier.

func (*PagerDuty) Send

func (p *PagerDuty) Send(ctx context.Context, notifications []Notification) (Result, error)

Send dispatches one Events v2 enqueue per notification. PagerDuty dedups on dedup_key — we use the notification.Fingerprint so a re-firing finding updates the existing incident instead of opening a new one. Severity → PD severity mapping:

critical → critical
high     → error
medium   → warning
low/info → info

func (*PagerDuty) Threshold

func (p *PagerDuty) Threshold() compliancekit.Severity

Threshold returns the per-sink severity floor.

type PagerDutyConfig

type PagerDutyConfig struct {
	// IntegrationKey is the Events API v2 routing key (32 hex chars).
	// Generated per-service in the PagerDuty UI.
	IntegrationKey string

	// Source identifies the producer in the PagerDuty event (default
	// "compliancekit"). Helps on-call distinguish multiple
	// notifications from the same service.
	Source string

	// EventsURL overrides the canonical https://events.pagerduty.com
	// endpoint. Tests inject a stub here.
	EventsURL string

	SeverityFloor compliancekit.Severity

	HTTPClient *http.Client
}

PagerDutyConfig configures the PagerDuty Events v2 sink. PagerDuty is the canonical operational escalation channel — pages humans at 3 AM — so this sink defaults to a high severity floor (Critical only) to avoid waking on-call for non-actionable medium findings. Operators can lower the threshold if they want broader paging.

Env: PAGERDUTY_INTEGRATION_KEY, PAGERDUTY_THRESHOLD, PAGERDUTY_EVENTS_URL (override for testing).

type Registry

type Registry struct {
	// contains filtered or unexported fields
}

Registry holds every registered Notifier. Sinks self-register via notify.Register(...) from their init(). The CLI side-effect imports each sink subpackage so importing this package is enough to make every shipped sink available.

func NewRegistry

func NewRegistry() *Registry

NewRegistry returns an empty Registry. Tests use this for isolation; production code goes through Default.

func (*Registry) Lookup

func (r *Registry) Lookup(name string) (Notifier, bool)

Lookup returns the sink registered under name + whether it exists.

func (*Registry) Names

func (r *Registry) Names() []string

Names returns the sorted list of registered sink names.

func (*Registry) Register

func (r *Registry) Register(n Notifier)

Register adds n to the registry. Panics on duplicate Name() — duplicate sink names are a programmer error caught at init time, not a runtime condition.

func (*Registry) Sinks

func (r *Registry) Sinks() []Notifier

Sinks returns every registered Notifier, sorted by Name() for stable output. Used by `compliancekit doctor` to enumerate configuration status and by the CLI when no per-sink filter is passed.

type Result

type Result struct {
	Sent     int      // delivered to the upstream successfully
	Skipped  int      // under the sink's threshold OR deduped
	Errors   int      // permanent failures (4xx, parse errors)
	Messages []string // one human-readable line per notification's outcome
}

Result reports what a sink did with the notifications it was sent. Returned per Send call; the dispatcher aggregates across sinks.

func Dispatch

func Dispatch(ctx context.Context, r *Registry, notifications []Notification) (Result, []error)

Dispatch fans the notifications out to every Configured sink in the registry whose Threshold permits them. Errors from individual sinks are reported in the aggregated Result.Messages; a failing sink never blocks others.

Returns the aggregated Result + the slice of per-sink (sinkName, error) pairs for the CLI to format. Per-sink errors are wrapped with the sink name so the operator knows which channel failed.

func (*Result) Add

func (a *Result) Add(b Result)

Add accumulates b into a. Used by the dispatcher to roll up per-sink Results into a single per-run summary.

type Slack

type Slack struct {
	// contains filtered or unexported fields
}

Slack implements Notifier.

func NewSlack

func NewSlack(cfg SlackConfig) *Slack

NewSlack constructs a Slack sink. The returned sink reports Configured()=false when both WebhookURL and (BotToken+Channel) are empty — caller can still pass it to Default safely and the dispatcher will skip it.

func (*Slack) Configured

func (s *Slack) Configured() bool

Configured returns true when either delivery path has credentials.

func (*Slack) Name

func (s *Slack) Name() string

Name implements Notifier.

func (*Slack) Send

func (s *Slack) Send(ctx context.Context, notifications []Notification) (Result, error)

Send dispatches the notifications. Per-notification failures accumulate in Result.Errors + Result.Messages; the call returns nil error unless every send failed (transport / auth).

func (*Slack) Threshold

func (s *Slack) Threshold() compliancekit.Severity

Threshold returns the per-sink severity floor.

type SlackConfig

type SlackConfig struct {
	// WebhookURL is the canonical incoming-webhook URL. Used when
	// the operator only needs notifications to land in one channel.
	WebhookURL string

	// BotToken (xoxb-…) is the API token for chat.postMessage.
	// Used when the operator needs per-channel routing or thread
	// replies. Requires the chat:write scope.
	BotToken string

	// Channel is the destination when using BotToken. Accepts "#name"
	// or a channel ID. Ignored when WebhookURL is set.
	Channel string

	// SeverityFloor is the per-sink threshold. Notifications below
	// this severity are dropped. Defaults to SeverityInfo (everything
	// actionable passes) when zero-value.
	SeverityFloor compliancekit.Severity

	// HTTPClient overrides the package HTTPClient. Tests set this.
	HTTPClient *http.Client
}

SlackConfig configures the Slack sink. Two delivery paths, both supported simultaneously: webhook (no auth header, no channel selection) and bot-token (PostMessage API with per-channel control). Env vars: SLACK_WEBHOOK_URL, SLACK_BOT_TOKEN, SLACK_CHANNEL, SLACK_THRESHOLD (severity string).

type Teams

type Teams struct {
	// contains filtered or unexported fields
}

Teams implements Notifier for Microsoft Teams incoming-webhook connectors (MessageCard payload).

func NewTeams

func NewTeams(cfg TeamsConfig) *Teams

NewTeams constructs a Teams sink.

func (*Teams) Configured

func (t *Teams) Configured() bool

Configured returns true when WebhookURL is set.

func (*Teams) Name

func (t *Teams) Name() string

Name implements Notifier.

func (*Teams) Send

func (t *Teams) Send(ctx context.Context, notifications []Notification) (Result, error)

Send dispatches the notifications. Per-notification failures accumulate; top-level error only when every send failed.

func (*Teams) Threshold

func (t *Teams) Threshold() compliancekit.Severity

Threshold returns the per-sink severity floor.

type TeamsConfig

type TeamsConfig struct {
	WebhookURL    string
	SeverityFloor compliancekit.Severity
	HTTPClient    *http.Client
}

TeamsConfig configures the Microsoft Teams sink. Microsoft has two webhook flavors:

  1. Incoming Webhook connectors — the legacy MessageCard format. Wide deployment; deprecated by Microsoft but still supported.
  2. Workflows-based webhooks — Adaptive Card payload.

We ship MessageCard at v0.17 because the deprecation timeline (October 2026) leaves room to migrate, and most enterprise deployments still use legacy connectors. Adaptive Card support is a v0.18+ enhancement.

Env: TEAMS_WEBHOOK_URL, TEAMS_THRESHOLD.

type Webhook

type Webhook struct {
	// contains filtered or unexported fields
}

Webhook implements Notifier for generic HTTP POST sinks.

func NewWebhook

func NewWebhook(cfg WebhookConfig) *Webhook

NewWebhook constructs a Webhook sink.

func (*Webhook) Configured

func (w *Webhook) Configured() bool

Configured returns true when URL is set.

func (*Webhook) Name

func (w *Webhook) Name() string

Name implements Notifier.

func (*Webhook) Send

func (w *Webhook) Send(ctx context.Context, notifications []Notification) (Result, error)

Send dispatches the notifications. One POST per notification (not a batch) so the receiver can correlate request ↔ finding without having to parse a list.

func (*Webhook) Threshold

func (w *Webhook) Threshold() compliancekit.Severity

Threshold returns the per-sink severity floor.

type WebhookConfig

type WebhookConfig struct {
	URL           string
	Secret        string
	SeverityFloor compliancekit.Severity
	HTTPClient    *http.Client
}

WebhookConfig configures the generic webhook sink. POSTs every notification as JSON to the configured URL; optional HMAC-SHA256 signing header lets the receiver verify authenticity.

Payload shape (stable since v0.17.0, governed by SemVer once v1.0 freezes the API):

{
  "schema":      "compliancekit.notification.v1",
  "fingerprint": "abc123...",
  "title":       "[CRITICAL] aws-s3-public-access-block on prod",
  "body":        "<CommonMark>",
  "url":         "https://...",
  "tags":        ["s3", "data-exposure"],
  "finding":     { ...compliancekit.Finding... }
}

When Secret is set, the request includes an X-CompliancekitSignature header: `sha256=<hex(HMAC-SHA256(secret, body))>`. Same format GitHub webhook receivers expect; receiver code reused across hooks.

Env: COMPLIANCEKIT_WEBHOOK_URL, COMPLIANCEKIT_WEBHOOK_SECRET, COMPLIANCEKIT_WEBHOOK_THRESHOLD.

Jump to

Keyboard shortcuts

? : This menu
/ : Search site
f or F : Jump to
y or Y : Canonical URL