From 0e1fe0cedeaa25dacfee53bc58c23dbda1cde63b Mon Sep 17 00:00:00 2001 From: Mick Grove Date: Sun, 3 May 2026 23:10:45 -0700 Subject: [PATCH] webhook support and kingfisher configuration yaml support --- .../kingfisher-rules/data/rules/onelogin.yml | 129 ++++++++++++++ ...28-beyond-detection-validate-map-revoke.md | 9 - .../2026-04-28-scan-github-org-for-secrets.md | 9 - docs-site/docs/reference/library.md | 2 +- docs-site/docs/usage/advanced.md | 2 +- docs/ADVANCED.md | 2 +- docs/ALERTS.md | 62 ++++++- docs/CONFIG.md | 2 +- docs/LIBRARY.md | 2 +- src/alerts/discord.rs | 161 ++++++++++++++++++ src/alerts/googlechat.rs | 155 +++++++++++++++++ src/alerts/mattermost.rs | 160 +++++++++++++++++ src/alerts/mod.rs | 54 ++++++ src/cli/commands/scan.rs | 5 +- src/cli/config.rs | 2 +- 15 files changed, 726 insertions(+), 30 deletions(-) create mode 100644 crates/kingfisher-rules/data/rules/onelogin.yml create mode 100644 src/alerts/discord.rs create mode 100644 src/alerts/googlechat.rs create mode 100644 src/alerts/mattermost.rs diff --git a/crates/kingfisher-rules/data/rules/onelogin.yml b/crates/kingfisher-rules/data/rules/onelogin.yml new file mode 100644 index 0000000..ddd977e --- /dev/null +++ b/crates/kingfisher-rules/data/rules/onelogin.yml @@ -0,0 +1,129 @@ +rules: + - name: OneLogin Client ID + id: kingfisher.onelogin.1 + pattern: | + (?xi) + \b + one[\s_.-]*login + (?:.|[\n\r]){0,64}? + (?: + api[\s_.-]*client[\s_.-]*id + | + client[\s_.-]*id + | + api[\s_.-]*id + | + idr + ) + (?:.|[\n\r]){0,20}? + [=:"'\s_-]* + (?:idr[_-])? + ( + [a-z0-9]{64} + ) + \b + pattern_requirements: + min_digits: 4 + min_lowercase: 8 + min_entropy: 3.4 + confidence: medium + visible: false + examples: + - ONELOGIN_CLIENT_ID=rz5dx87tuykcvonk5577picx71wkllmq14tzexx8a8oaeee6yar815drbn3s4umx + - 'onelogin api client id: "idr_rz5dx87tuykcvonk5577picx71wkllmq14tzexx8a8oaeee6yar815drbn3s4umx"' + - "one-login\nclient_id=rz5dx87tuykcvonk5577picx71wkllmq14tzexx8a8oaeee6yar815drbn3s4umx" + references: + - https://developers.onelogin.com/api-docs/1/getting-started/working-with-api-credentials + + - name: OneLogin Client Secret + id: kingfisher.onelogin.2 + pattern: | + (?xi) + \b + one[\s_.-]*login + (?:.|[\n\r]){0,64}? + (?: + api[\s_.-]*client[\s_.-]*secret + | + client[\s_.-]*secret + | + api[\s_.-]*secret + | + secret + ) + (?:.|[\n\r]){0,20}? + [=:"'\s_-]* + (?:secret[_-])? + ( + [a-z0-9]{64} + ) + \b + pattern_requirements: + min_digits: 4 + min_lowercase: 8 + ignore_if_contains: + - example + - placeholder + min_entropy: 3.4 + confidence: medium + examples: + - ONELOGIN_CLIENT_SECRET=51exgkt8tf875584xr5awedi6y48txm0vzdwjzxxgp3119o5rpyubn07gm6a5u2l + - 'onelogin secret: "secret_51exgkt8tf875584xr5awedi6y48txm0vzdwjzxxgp3119o5rpyubn07gm6a5u2l"' + - "one-login\nclient_secret=51exgkt8tf875584xr5awedi6y48txm0vzdwjzxxgp3119o5rpyubn07gm6a5u2l" + depends_on_rule: + - rule_id: kingfisher.onelogin.1 + variable: CLIENT_ID + - rule_id: kingfisher.onelogin.3 + variable: DOMAIN + validation: + type: Http + content: + request: + method: POST + url: https://{{ DOMAIN }}/auth/oauth2/token + headers: + Content-Type: application/json + Authorization: "client_id:{{ CLIENT_ID }}, client_secret:{{ TOKEN }}" + body: | + { + "grant_type": "client_credentials" + } + response_matcher: + - report_response: true + - type: StatusMatch + status: [200] + - type: JsonValid + - type: WordMatch + match_all_words: true + words: + - '"access_token"' + - '"token_type"' + # Revocation not added: OneLogin documents token revocation, but not API + # credential-pair deletion or disablement through a safe public API. + references: + - https://developers.onelogin.com/api-docs/1/getting-started/working-with-api-credentials + - https://developers.onelogin.com/api-authorization/oneLogin-api-define-custom-access-tokens + + - name: OneLogin Tenant Domain + id: kingfisher.onelogin.3 + pattern: | + (?xi) + \b + ( + [a-z0-9] + (?:[a-z0-9-]{0,61}[a-z0-9])? + \.onelogin\.com + ) + \b + min_entropy: 2.0 + confidence: medium + visible: false + examples: + - acme-corp.onelogin.com + - https://tenant-example.onelogin.com/api/2/users + - ONELOGIN_SUBDOMAIN=sampletenant.onelogin.com + categories: + - domain + - onelogin + references: + - https://developers.onelogin.com/api-docs/1/getting-started/dev-overview diff --git a/docs-site/docs/blog/posts/2026-04-28-beyond-detection-validate-map-revoke.md b/docs-site/docs/blog/posts/2026-04-28-beyond-detection-validate-map-revoke.md index 55e23d1..dc87ea7 100644 --- a/docs-site/docs/blog/posts/2026-04-28-beyond-detection-validate-map-revoke.md +++ b/docs-site/docs/blog/posts/2026-04-28-beyond-detection-validate-map-revoke.md @@ -194,14 +194,5 @@ The practical result is that new rules can ship with detection plus post-detection response logic, instead of detection today and validation or revocation on some later roadmap. -## Next up - -- **Catching secrets in pull requests with GitHub Actions** — pre-merge - scanning so leaked credentials never reach `main`. -- **Top leaked credential types we see in the wild** — what validation - telemetry says about the credential leak landscape. -- **Docker image scanning** — pulling and scanning every layer for - embedded secrets. - If there is a provider you want validation or revocation support for, open an issue at [mongodb/kingfisher](https://github.com/mongodb/kingfisher/issues). diff --git a/docs-site/docs/blog/posts/2026-04-28-scan-github-org-for-secrets.md b/docs-site/docs/blog/posts/2026-04-28-scan-github-org-for-secrets.md index 7cf9123..47c4d07 100644 --- a/docs-site/docs/blog/posts/2026-04-28-scan-github-org-for-secrets.md +++ b/docs-site/docs/blog/posts/2026-04-28-scan-github-org-for-secrets.md @@ -309,14 +309,5 @@ strategy: Each shard gets its own runner and its own disk budget, and you can upload one artifact per shard for triage. -## What's next - -- **Catching secrets in pull requests with GitHub Actions** — pre-merge - scanning so leaks never reach `main`. -- **The most common credential types we see leaked in the wild** — what - Kingfisher's validation telemetry says about the credential leak landscape. -- **Docker image scanning** — pulling images directly and scanning every - layer for embedded secrets. - If there is a workflow you want us to cover, open an issue at [mongodb/kingfisher](https://github.com/mongodb/kingfisher/issues). diff --git a/docs-site/docs/reference/library.md b/docs-site/docs/reference/library.md index 35e1da7..03b51a3 100644 --- a/docs-site/docs/reference/library.md +++ b/docs-site/docs/reference/library.md @@ -268,7 +268,7 @@ flowchart TD ### Loading Builtin Rules -Kingfisher currently ships with 942 built-in rules for common secret types: +Kingfisher currently ships with 950 built-in rules for common secret types: ```rust use kingfisher_rules::{get_builtin_rules, Confidence}; diff --git a/docs-site/docs/usage/advanced.md b/docs-site/docs/usage/advanced.md index e34f07d..57ade08 100644 --- a/docs-site/docs/usage/advanced.md +++ b/docs-site/docs/usage/advanced.md @@ -300,7 +300,7 @@ kingfisher scan ./my-project \ ## Custom Rules -Kingfisher currently ships with 942 built-in rules, but you may want to add your own custom rules or modify existing detection to better suit your needs. +Kingfisher currently ships with 950 built-in rules, but you may want to add your own custom rules or modify existing detection to better suit your needs. First, review [RULES.md](../rules/overview.md) to learn how to create custom Kingfisher rules. diff --git a/docs/ADVANCED.md b/docs/ADVANCED.md index a8728c8..9b61feb 100644 --- a/docs/ADVANCED.md +++ b/docs/ADVANCED.md @@ -297,7 +297,7 @@ kingfisher scan ./my-project \ ## Custom Rules -Kingfisher currently ships with 942 built-in rules, but you may want to add your own custom rules or modify existing detection to better suit your needs. +Kingfisher currently ships with 950 built-in rules, but you may want to add your own custom rules or modify existing detection to better suit your needs. First, review [RULES.md](RULES.md) to learn how to create custom Kingfisher rules. diff --git a/docs/ALERTS.md b/docs/ALERTS.md index f09771e..32e13aa 100644 --- a/docs/ALERTS.md +++ b/docs/ALERTS.md @@ -1,8 +1,8 @@ # Alert Webhooks Kingfisher can POST a scan summary (and optionally per-finding details) to one -or more webhooks when a scan completes — Slack, Microsoft Teams, or any HTTPS -endpoint that accepts a JSON POST. +or more webhooks when a scan completes — Slack, Microsoft Teams, Discord, +Mattermost, Google Chat, or any HTTPS endpoint that accepts a JSON POST. Alerting is **best-effort**. A bad webhook produces a `WARN` line on stderr and *never* changes the scan exit code; this avoids breaking CI when paging @@ -20,17 +20,36 @@ kingfisher scan ./repo \ --alert-webhook "$TEAMS_WEBHOOK" \ --alert-webhook "https://siem.example.com/ingest" \ --alert-format generic + +# Discord webhook (auto-detected from discord.com). +kingfisher scan ./repo --alert-webhook "$DISCORD_SECURITY_WEBHOOK" + +# Mattermost (self-hosted — format must be specified explicitly). +kingfisher scan ./repo \ + --alert-webhook "https://mattermost.example.com/hooks/abc123" \ + --alert-format mattermost ``` -The format is inferred from the URL host: `*.slack.com` → `slack`, -`*.office.com` → `teams`, otherwise `generic`. Set `--alert-format` to override. +The format is inferred from the URL host: + +| Host pattern | Inferred format | +|---------------------------------------|-----------------| +| `*.slack.com` | `slack` | +| `*.office.com` / `webhook.office.*` | `teams` | +| `discord.com` / `discordapp.com` | `discord` | +| `chat.googleapis.com` | `googlechat` | +| anything else | `generic` | + +Set `--alert-format` to override. Mattermost has no canonical hostname (it is +always self-hosted), so it is **never** inferred — pass +`--alert-format mattermost` whenever you target a Mattermost server. ## Flags | Flag | Default | Notes | |------|---------|-------| | `--alert-webhook URL` | *(none, repeatable)* | Destination URL; pass once per webhook. | -| `--alert-format slack\|teams\|generic` | inferred | Payload shape. | +| `--alert-format slack\|teams\|generic\|discord\|mattermost\|googlechat` | inferred | Payload shape. | | `--alert-on findings\|always` | `findings` | `always` posts even on a clean run. | | `--alert-min-confidence low\|medium\|high` | `medium` | Findings below this are dropped from the payload. | | `--alert-include-secret` | off | Include the (truncated to ~32 chars) secret value in the payload. | @@ -74,6 +93,30 @@ red if any active. Facts list active/inactive/unknown counts and the top rules. Findings are the same shape as `kingfisher scan --format json` produces, so existing JSON consumers work unchanged. +### Discord (Embed) + +A single embed with a color-coded sidebar — red on any active credential, +amber when findings exist but none are verified active, green on a clean run. +Inline `Active`/`Inactive`/`Unknown` fields, a `Top rules` field, the +per-finding detail in the embed `description` (capped at 10 entries), and a +footer with the Kingfisher version. + +### Mattermost (Slack-compatible attachments) + +Renders as a single attachment with the same red/amber/green sidebar (via the +legacy Slack `attachments[].color` field). Mattermost ≥ 5.x renders this +identically; we deliberately use legacy attachments instead of Block Kit +because Block Kit support in Mattermost is partial. Findings are listed in +the attachment `text` body, capped at 10 entries. + +### Google Chat (cardsV2) + +A modern `cardsV2` card with a "Summary" section (`decoratedText` widgets for +active/inactive/unknown counts and a top-rules paragraph) and a "Findings" +section (capped at 10 entries). Google Chat does not expose a card-color knob +in its public webhook API, so severity is conveyed textually — the title is +prefixed with 🚨 when any active credential is detected. + ## Configuring via `kingfisher.yaml` CLI flags and config-file webhooks are concatenated. Per-webhook overrides live @@ -92,6 +135,15 @@ alerts: on: always min_confidence: medium include_secret: false + - url: https://discord.com/api/webhooks/123/abcdef + format: discord + on: findings + - url: https://mattermost.example.com/hooks/xxx + format: mattermost # required — never auto-inferred + on: findings + - url: https://chat.googleapis.com/v1/spaces/AAA/messages?key=k&token=t + format: googlechat + on: always ``` See [`docs/CONFIG.md`](CONFIG.md) for the full config schema. diff --git a/docs/CONFIG.md b/docs/CONFIG.md index a1e0608..fb17e71 100644 --- a/docs/CONFIG.md +++ b/docs/CONFIG.md @@ -17,7 +17,7 @@ flags so there are no surprising overrides. alerts: webhooks: - url: https://hooks.slack.com/services/T0/B0/AAA # required - format: slack # slack | teams | generic + format: slack # slack | teams | generic | discord | mattermost | googlechat on: findings # findings | always min_confidence: medium # low | medium | high include_secret: false # default false diff --git a/docs/LIBRARY.md b/docs/LIBRARY.md index 11bfd20..ec07a7f 100644 --- a/docs/LIBRARY.md +++ b/docs/LIBRARY.md @@ -265,7 +265,7 @@ flowchart TD ### Loading Builtin Rules -Kingfisher currently ships with 942 built-in rules for common secret types: +Kingfisher currently ships with 950 built-in rules for common secret types: ```rust use kingfisher_rules::{get_builtin_rules, Confidence}; diff --git a/src/alerts/discord.rs b/src/alerts/discord.rs new file mode 100644 index 0000000..7d58f5d --- /dev/null +++ b/src/alerts/discord.rs @@ -0,0 +1,161 @@ +//! Discord incoming-webhook payload (`embeds`). +//! +//! A single embed carries the summary as `fields`, the per-finding detail in +//! the embed `description`, and a footer with the Kingfisher version. The +//! sidebar is color-coded the same way the Teams card is: red on any active +//! credential, amber for unverified findings, green for a clean run. + +use serde_json::{Value, json}; + +use crate::alerts::AlertSummary; +use crate::reporter::FindingReporterRecord; + +const PER_FINDING_LIMIT: usize = 10; + +// Discord embed `description` is capped at 4096 chars and each `fields[].value` +// at 1024. We keep the per-finding block well under both — the section is +// truncated to 1900 chars (leaving room for the trailing "…N more" line) so +// servers running older Discord clients render the embed without truncation. +const DESCRIPTION_SOFT_LIMIT: usize = 1900; + +const COLOR_RED: u32 = 0xC0_39_2B; // active live secrets +const COLOR_AMBER: u32 = 0xF3_9C_12; // findings present, none verified active +const COLOR_GREEN: u32 = 0x27_AE_60; // clean + +pub fn build_payload( + summary: &AlertSummary, + findings: &[&FindingReporterRecord], + include_secret: bool, +) -> Value { + let title = if summary.total == 0 { + "Kingfisher: scan complete — no findings".to_string() + } else { + format!( + "Kingfisher: {} finding{} ({} active, {} inactive, {} unknown)", + summary.total, + plural(summary.total), + summary.active, + summary.inactive, + summary.unknown + ) + }; + + let color = if summary.active > 0 { + COLOR_RED + } else if summary.total > 0 { + COLOR_AMBER + } else { + COLOR_GREEN + }; + + let mut fields: Vec = vec![ + json!({ "name": "Active", "value": summary.active.to_string(), "inline": true }), + json!({ "name": "Inactive", "value": summary.inactive.to_string(), "inline": true }), + json!({ "name": "Unknown", "value": summary.unknown.to_string(), "inline": true }), + ]; + if let Some(t) = &summary.target { + fields.push(json!({ + "name": "Target", + "value": format!("`{}`", truncate(t, 1000)), + "inline": false, + })); + } + if !summary.by_rule.is_empty() { + let lines: Vec = + summary.by_rule.iter().map(|(rule, count)| format!("• `{rule}` — {count}")).collect(); + fields.push(json!({ + "name": "Top rules", + "value": truncate(&lines.join("\n"), 1000), + "inline": false, + })); + } + + let mut embed = json!({ + "title": title, + "color": color, + "fields": fields, + "footer": { "text": format!("kingfisher v{}", summary.kingfisher_version) }, + }); + + if !findings.is_empty() { + let take = findings.len().min(PER_FINDING_LIMIT); + let mut detail = String::new(); + for f in findings.iter().take(take) { + let snippet = if include_secret { + truncate(&f.finding.snippet, 32) + } else { + "".to_string() + }; + detail.push_str(&format!( + "• `{}` at `{}:{}` — `{}` (validation: {})\n", + f.rule.id, f.finding.path, f.finding.line, snippet, f.finding.validation.status, + )); + } + if findings.len() > take { + detail.push_str(&format!("…{} more findings omitted", findings.len() - take)); + } + embed["description"] = Value::String(truncate(&detail, DESCRIPTION_SOFT_LIMIT)); + } + + json!({ "embeds": [embed] }) +} + +fn plural(n: usize) -> &'static str { + if n == 1 { "" } else { "s" } +} + +fn truncate(s: &str, n: usize) -> String { + if s.chars().count() <= n { + return s.to_string(); + } + let prefix: String = s.chars().take(n).collect(); + format!("{prefix}…") +} + +#[cfg(test)] +mod tests { + use super::*; + + fn summary(total: usize, active: usize) -> AlertSummary { + AlertSummary { + total, + active, + inactive: 0, + unknown: 0, + by_rule: vec![], + kingfisher_version: "test".to_string(), + target: None, + } + } + + #[test] + fn color_red_when_active() { + let p = build_payload(&summary(3, 1), &[], false); + assert_eq!(p["embeds"][0]["color"], COLOR_RED); + } + + #[test] + fn color_amber_when_findings_no_active() { + let p = build_payload(&summary(2, 0), &[], false); + assert_eq!(p["embeds"][0]["color"], COLOR_AMBER); + } + + #[test] + fn color_green_when_empty() { + let p = build_payload(&summary(0, 0), &[], false); + assert_eq!(p["embeds"][0]["color"], COLOR_GREEN); + assert_eq!(p["embeds"][0]["title"], "Kingfisher: scan complete — no findings"); + } + + #[test] + fn footer_carries_version() { + let p = build_payload(&summary(0, 0), &[], false); + assert_eq!(p["embeds"][0]["footer"]["text"], "kingfisher vtest"); + } + + #[test] + fn empty_findings_has_no_description() { + let p = build_payload(&summary(0, 0), &[], false); + assert!(p["embeds"][0].get("description").is_none()); + } +} diff --git a/src/alerts/googlechat.rs b/src/alerts/googlechat.rs new file mode 100644 index 0000000..549e071 --- /dev/null +++ b/src/alerts/googlechat.rs @@ -0,0 +1,155 @@ +//! Google Chat incoming-webhook payload (`cardsV2`). +//! +//! Google Chat does not expose a card-color knob in the public webhook API the +//! way Discord/Teams/Mattermost do, so severity is encoded textually in the +//! header title. The card uses two sections: a "Summary" with `decoratedText` +//! widgets for the active/inactive/unknown counts, and a "Findings" section +//! with a `textParagraph` widget. `textParagraph.text` accepts a small +//! markdown subset (`*bold*`, `_italic_`, backtick code spans). + +use serde_json::{Value, json}; + +use crate::alerts::AlertSummary; +use crate::reporter::FindingReporterRecord; + +const PER_FINDING_LIMIT: usize = 10; + +pub fn build_payload( + summary: &AlertSummary, + findings: &[&FindingReporterRecord], + include_secret: bool, +) -> Value { + let title = if summary.total == 0 { + "Kingfisher: scan complete — no findings".to_string() + } else { + let prefix = if summary.active > 0 { "🚨 " } else { "" }; + format!( + "{}Kingfisher: {} finding{} ({} active, {} inactive, {} unknown)", + prefix, + summary.total, + plural(summary.total), + summary.active, + summary.inactive, + summary.unknown + ) + }; + + let mut summary_widgets: Vec = vec![ + json!({ "decoratedText": { "topLabel": "Active", "text": summary.active.to_string() } }), + json!({ "decoratedText": { "topLabel": "Inactive", "text": summary.inactive.to_string() } }), + json!({ "decoratedText": { "topLabel": "Unknown", "text": summary.unknown.to_string() } }), + ]; + if let Some(t) = &summary.target { + summary_widgets.push(json!({ + "decoratedText": { "topLabel": "Target", "text": t } + })); + } + if !summary.by_rule.is_empty() { + let lines: Vec = summary + .by_rule + .iter() + .map(|(rule, count)| format!("• {rule} — {count}")) + .collect(); + summary_widgets.push(json!({ + "textParagraph": { "text": format!("Top rules
{}", lines.join("
")) } + })); + } + + let mut sections: Vec = vec![json!({ + "header": "Summary", + "widgets": summary_widgets, + })]; + + if !findings.is_empty() { + let take = findings.len().min(PER_FINDING_LIMIT); + let mut detail = String::new(); + for f in findings.iter().take(take) { + let snippet = if include_secret { + truncate(&f.finding.snippet, 32) + } else { + "".to_string() + }; + detail.push_str(&format!( + "• {} at {}:{}{} (validation: {})
", + f.rule.id, f.finding.path, f.finding.line, snippet, f.finding.validation.status, + )); + } + if findings.len() > take { + detail.push_str(&format!("…{} more findings omitted", findings.len() - take)); + } + sections.push(json!({ + "header": "Findings", + "widgets": [{ "textParagraph": { "text": detail } }], + })); + } + + json!({ + "cardsV2": [{ + "cardId": "kingfisher-alert", + "card": { + "header": { + "title": title, + "subtitle": format!("kingfisher v{}", summary.kingfisher_version), + }, + "sections": sections, + } + }] + }) +} + +fn plural(n: usize) -> &'static str { + if n == 1 { "" } else { "s" } +} + +fn truncate(s: &str, n: usize) -> String { + if s.chars().count() <= n { + return s.to_string(); + } + let prefix: String = s.chars().take(n).collect(); + format!("{prefix}…") +} + +#[cfg(test)] +mod tests { + use super::*; + + fn summary(total: usize, active: usize) -> AlertSummary { + AlertSummary { + total, + active, + inactive: 0, + unknown: 0, + by_rule: vec![], + kingfisher_version: "test".to_string(), + target: None, + } + } + + #[test] + fn empty_payload_has_no_findings_section() { + let p = build_payload(&summary(0, 0), &[], false); + let sections = p["cardsV2"][0]["card"]["sections"].as_array().unwrap(); + assert_eq!(sections.len(), 1, "expected only the Summary section"); + assert_eq!(sections[0]["header"], "Summary"); + } + + #[test] + fn title_prefixes_emoji_when_active() { + let p = build_payload(&summary(3, 1), &[], false); + let title = p["cardsV2"][0]["card"]["header"]["title"].as_str().unwrap(); + assert!(title.starts_with("🚨"), "active findings should prefix the title with 🚨"); + } + + #[test] + fn title_no_emoji_when_findings_no_active() { + let p = build_payload(&summary(2, 0), &[], false); + let title = p["cardsV2"][0]["card"]["header"]["title"].as_str().unwrap(); + assert!(!title.starts_with("🚨"), "no active findings → no emoji prefix"); + } + + #[test] + fn subtitle_carries_version() { + let p = build_payload(&summary(0, 0), &[], false); + assert_eq!(p["cardsV2"][0]["card"]["header"]["subtitle"], "kingfisher vtest"); + } +} diff --git a/src/alerts/mattermost.rs b/src/alerts/mattermost.rs new file mode 100644 index 0000000..013aedc --- /dev/null +++ b/src/alerts/mattermost.rs @@ -0,0 +1,160 @@ +//! Mattermost incoming-webhook payload (Slack-compatible `attachments`). +//! +//! Mattermost has no canonical hostname (it is always self-hosted), so the +//! `infer_from_url` heuristic cannot distinguish a Mattermost URL from any +//! other generic webhook. Users must pass `--alert-format mattermost` +//! explicitly. +//! +//! The legacy Slack `attachments` schema renders identically across Mattermost +//! server versions ≥ 5.x and gives us the same red/amber/green sidebar that +//! Teams/Discord use. We deliberately do **not** reuse `slack::build_payload` +//! because Slack's Block Kit support in Mattermost is partial — older clients +//! only render the top-level `text` and silently drop blocks. + +use serde_json::{Value, json}; + +use crate::alerts::AlertSummary; +use crate::reporter::FindingReporterRecord; + +const PER_FINDING_LIMIT: usize = 10; + +const COLOR_RED: &str = "#C0392B"; +const COLOR_AMBER: &str = "#F39C12"; +const COLOR_GREEN: &str = "#27AE60"; + +pub fn build_payload( + summary: &AlertSummary, + findings: &[&FindingReporterRecord], + include_secret: bool, +) -> Value { + let header = if summary.total == 0 { + "Kingfisher: scan complete — no findings".to_string() + } else { + format!( + "Kingfisher: {} finding{} ({} active, {} inactive, {} unknown)", + summary.total, + plural(summary.total), + summary.active, + summary.inactive, + summary.unknown + ) + }; + + let color = if summary.active > 0 { + COLOR_RED + } else if summary.total > 0 { + COLOR_AMBER + } else { + COLOR_GREEN + }; + + let mut fields: Vec = vec![ + json!({ "short": true, "title": "Active", "value": summary.active.to_string() }), + json!({ "short": true, "title": "Inactive", "value": summary.inactive.to_string() }), + json!({ "short": true, "title": "Unknown", "value": summary.unknown.to_string() }), + ]; + if let Some(t) = &summary.target { + fields.push(json!({ "short": false, "title": "Target", "value": format!("`{t}`") })); + } + if !summary.by_rule.is_empty() { + let lines: Vec = + summary.by_rule.iter().map(|(rule, count)| format!("• `{rule}` — {count}")).collect(); + fields.push(json!({ + "short": false, + "title": "Top rules", + "value": lines.join("\n"), + })); + } + + let mut attachment = json!({ + "color": color, + "title": header, + "fields": fields, + "footer": format!("kingfisher v{}", summary.kingfisher_version), + }); + + if !findings.is_empty() { + let take = findings.len().min(PER_FINDING_LIMIT); + let mut details = String::new(); + for f in findings.iter().take(take) { + let snippet = if include_secret { + truncate(&f.finding.snippet, 32) + } else { + "".to_string() + }; + details.push_str(&format!( + "- **{}** at `{}:{}` — `{}` (validation: {})\n", + f.rule.id, f.finding.path, f.finding.line, snippet, f.finding.validation.status, + )); + } + if findings.len() > take { + details.push_str(&format!("_…{} more findings omitted_\n", findings.len() - take)); + } + attachment["text"] = Value::String(details); + } + + json!({ + "text": header, + "attachments": [attachment], + }) +} + +fn plural(n: usize) -> &'static str { + if n == 1 { "" } else { "s" } +} + +fn truncate(s: &str, n: usize) -> String { + if s.chars().count() <= n { + return s.to_string(); + } + let prefix: String = s.chars().take(n).collect(); + format!("{prefix}…") +} + +#[cfg(test)] +mod tests { + use super::*; + + fn summary(total: usize, active: usize) -> AlertSummary { + AlertSummary { + total, + active, + inactive: 0, + unknown: 0, + by_rule: vec![], + kingfisher_version: "test".to_string(), + target: None, + } + } + + #[test] + fn color_red_when_active() { + let p = build_payload(&summary(3, 1), &[], false); + assert_eq!(p["attachments"][0]["color"], COLOR_RED); + } + + #[test] + fn color_amber_when_findings_no_active() { + let p = build_payload(&summary(2, 0), &[], false); + assert_eq!(p["attachments"][0]["color"], COLOR_AMBER); + } + + #[test] + fn color_green_when_empty() { + let p = build_payload(&summary(0, 0), &[], false); + assert_eq!(p["attachments"][0]["color"], COLOR_GREEN); + } + + #[test] + fn fallback_text_carries_header() { + let p = build_payload(&summary(0, 0), &[], false); + let text = p["text"].as_str().unwrap(); + assert!(text.contains("no findings")); + } + + #[test] + fn footer_carries_version() { + let p = build_payload(&summary(0, 0), &[], false); + assert_eq!(p["attachments"][0]["footer"], "kingfisher vtest"); + } +} diff --git a/src/alerts/mod.rs b/src/alerts/mod.rs index fedaf50..5d912a4 100644 --- a/src/alerts/mod.rs +++ b/src/alerts/mod.rs @@ -15,7 +15,10 @@ use tracing::{debug, info, warn}; use crate::cli::commands::scan::ConfidenceLevel; use crate::reporter::FindingReporterRecord; +pub mod discord; pub mod generic; +pub mod googlechat; +pub mod mattermost; pub mod slack; pub mod teams; @@ -47,6 +50,12 @@ pub enum AlertFormat { Teams, /// Generic JSON envelope (`{ summary, findings }`). Generic, + /// Discord incoming-webhook (color-coded `embeds`). + Discord, + /// Mattermost incoming-webhook (Slack-compatible `attachments`). + Mattermost, + /// Google Chat incoming-webhook (`cardsV2` payload). + Googlechat, } impl AlertFormat { @@ -59,6 +68,10 @@ impl AlertFormat { Some(h) if h.contains("office.com") || h.contains("webhook.office") => { AlertFormat::Teams } + Some(h) if h.contains("discord.com") || h.contains("discordapp.com") => { + AlertFormat::Discord + } + Some(h) if h.contains("chat.googleapis.com") => AlertFormat::Googlechat, _ => AlertFormat::Generic, } } @@ -192,6 +205,15 @@ pub async fn dispatch( AlertFormat::Generic => { generic::build_payload(&summary, &filtered, sink.include_secret) } + AlertFormat::Discord => { + discord::build_payload(&summary, &filtered, sink.include_secret) + } + AlertFormat::Mattermost => { + mattermost::build_payload(&summary, &filtered, sink.include_secret) + } + AlertFormat::Googlechat => { + googlechat::build_payload(&summary, &filtered, sink.include_secret) + } }; match post(&client, &sink.url, &payload).await { @@ -275,4 +297,36 @@ mod tests { AlertFormat::Generic ); } + + #[test] + fn infer_format_discord() { + assert_eq!( + AlertFormat::infer_from_url("https://discord.com/api/webhooks/123/abc"), + AlertFormat::Discord + ); + assert_eq!( + AlertFormat::infer_from_url("https://discordapp.com/api/webhooks/123/abc"), + AlertFormat::Discord + ); + } + + #[test] + fn infer_format_googlechat() { + assert_eq!( + AlertFormat::infer_from_url( + "https://chat.googleapis.com/v1/spaces/AAA/messages?key=k&token=t" + ), + AlertFormat::Googlechat + ); + } + + #[test] + fn infer_format_mattermost_falls_back_to_generic_without_override() { + // Mattermost is self-hosted with no canonical domain; users must pass + // `--alert-format mattermost` explicitly. Inference falls through. + assert_eq!( + AlertFormat::infer_from_url("https://mattermost.example.com/hooks/abcdef"), + AlertFormat::Generic + ); + } } diff --git a/src/cli/commands/scan.rs b/src/cli/commands/scan.rs index 335bc15..6985f9f 100644 --- a/src/cli/commands/scan.rs +++ b/src/cli/commands/scan.rs @@ -229,7 +229,10 @@ pub struct ScanArgs { pub alert_webhook: Vec, /// Format for `--alert-webhook` payloads. Default is inferred from the URL - /// host (slack.com → slack, *.office.com → teams, otherwise generic). + /// host (slack.com → slack, *.office.com → teams, discord.com → discord, + /// chat.googleapis.com → googlechat, otherwise generic). Mattermost is + /// self-hosted and never inferred — pass `--alert-format mattermost` + /// explicitly. #[arg(global = true, long = "alert-format", value_name = "FORMAT")] pub alert_format: Option, diff --git a/src/cli/config.rs b/src/cli/config.rs index f045a36..0727815 100644 --- a/src/cli/config.rs +++ b/src/cli/config.rs @@ -8,7 +8,7 @@ //! alerts: //! webhooks: //! - url: https://hooks.slack.com/services/... -//! format: slack # slack | teams | generic +//! format: slack # slack | teams | generic | discord | mattermost | googlechat //! on: findings # findings | always //! min_confidence: medium # low | medium | high //! include_secret: false