webhook support and kingfisher configuration yaml support

This commit is contained in:
Mick Grove 2026-05-03 23:10:45 -07:00
commit 0e1fe0cede
15 changed files with 726 additions and 30 deletions

View file

@ -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

View file

@ -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).

View file

@ -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).

View file

@ -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};

View file

@ -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.

View file

@ -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.

View file

@ -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.

View file

@ -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

View file

@ -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};

161
src/alerts/discord.rs Normal file
View file

@ -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<Value> = 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<String> =
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 {
"<redacted>".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());
}
}

155
src/alerts/googlechat.rs Normal file
View file

@ -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<Value> = 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<String> = summary
.by_rule
.iter()
.map(|(rule, count)| format!("• <code>{rule}</code> — {count}"))
.collect();
summary_widgets.push(json!({
"textParagraph": { "text": format!("<b>Top rules</b><br>{}", lines.join("<br>")) }
}));
}
let mut sections: Vec<Value> = 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 {
"<redacted>".to_string()
};
detail.push_str(&format!(
"• <b>{}</b> at <code>{}:{}</code> — <code>{}</code> (validation: {})<br>",
f.rule.id, f.finding.path, f.finding.line, snippet, f.finding.validation.status,
));
}
if findings.len() > take {
detail.push_str(&format!("<i>…{} more findings omitted</i>", 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");
}
}

160
src/alerts/mattermost.rs Normal file
View file

@ -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<Value> = 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<String> =
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 {
"<redacted>".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");
}
}

View file

@ -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
);
}
}

View file

@ -229,7 +229,10 @@ pub struct ScanArgs {
pub alert_webhook: Vec<String>,
/// 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<crate::alerts::AlertFormat>,

View file

@ -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