forked from mirrors/kingfisher
webhook support and kingfisher configuration yaml support
This commit is contained in:
parent
a4cf3990a5
commit
0e1fe0cede
15 changed files with 726 additions and 30 deletions
129
crates/kingfisher-rules/data/rules/onelogin.yml
Normal file
129
crates/kingfisher-rules/data/rules/onelogin.yml
Normal 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
|
||||
|
|
@ -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).
|
||||
|
|
|
|||
|
|
@ -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).
|
||||
|
|
|
|||
|
|
@ -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};
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
161
src/alerts/discord.rs
Normal 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
155
src/alerts/googlechat.rs
Normal 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
160
src/alerts/mattermost.rs
Normal 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");
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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>,
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue