preparing for v1.99.0

This commit is contained in:
Mick Grove 2026-05-04 13:26:11 -07:00
commit f6e05f0211
31 changed files with 1090 additions and 49 deletions

View file

@ -113,12 +113,12 @@ jobs:
scripts/build-pypi-wheel.sh \
--binary "$linux_x64" \
--version "$version" \
--plat-name musllinux_1_2_x86_64
--plat-name manylinux_2_17_x86_64.musllinux_1_2_x86_64
scripts/build-pypi-wheel.sh \
--binary "$linux_arm64" \
--version "$version" \
--plat-name musllinux_1_2_aarch64
--plat-name manylinux_2_17_aarch64.musllinux_1_2_aarch64
scripts/build-pypi-wheel.sh \
--binary "$mac_x64" \
@ -140,6 +140,18 @@ jobs:
--version "$version" \
--plat-name win_arm64
- name: Verify all wheels are platform-specific
shell: bash
run: |
set -euo pipefail
if ls dist-pypi/*-py3-none-any.whl >/dev/null 2>&1; then
echo "::error::Refusing to publish: pure-Python wheel found in dist-pypi/." >&2
ls -la dist-pypi/ >&2
exit 1
fi
echo "Wheels to publish:"
ls -la dist-pypi/
- name: Publish to PyPI (Trusted Publishing)
uses: pypa/gh-action-pypi-publish@cef221092ed1bacb1cc03d23a2d87d1d172e277b # release/v1
with:

View file

@ -3,10 +3,16 @@
All notable changes to this project will be documented in this file.
## [v1.99.0]
- Fixed [#371](https://github.com/mongodb/kingfisher/issues/371): `pip install kingfisher-bin` on glibc Linux distros (Ubuntu, Debian, RHEL, Fedora, …) installed a macOS Mach-O binary and failed with `OSError: [Errno 8] Exec format error`. Linux wheels are now tagged `manylinux_2_17_<arch>.musllinux_1_2_<arch>` (instead of `musllinux_1_2_<arch>` only), so pip accepts them on both glibc-2.17+ and musl distros. The `pypi/hatch_build.py` hook now hard-fails when `KINGFISHER_PYPI_WHEEL_TAG` is unset, and the publish workflow refuses to upload any `py3-none-any.whl`, so the v1.92.0-era pure-Python wheel cannot recur.
- `--self-update` (alias `--update`) on a scan or other command now **re-execs into the freshly installed binary** so the current invocation completes with the new code and the latest detection rules. Previously the on-disk binary was replaced but the running process kept using the old in-memory version, requiring a second invocation to pick up the changes. On Unix this is a true `exec()` (same PID); on Windows the new binary is spawned and the parent exits with its status code. The explicit `kingfisher self-update` subcommand still updates and exits without re-execing. Self-update now also covers Windows arm64 (the asset was already published; the runtime cfg map gained the missing arm). See `docs/ADVANCED.md`*Update Checks*.
- `--include-contributors` now respects `--github-repo-type` when enumerating contributor-owned repositories: by default contributor forks are excluded (matching the existing `Source` default), previously they were always included regardless of the flag. Added a new `--github-repo-type all` option to opt into the prior behavior of scanning both source and fork repos for contributors, organizations, and users.
- **Access Map:** Pinecone API keys (validated `kingfisher.pinecone.1`): caller resources via `GET /indexes` (with serverless cloud/region or pod environment metadata, deletion-protection state) and `GET /collections`; standalone `kingfisher access-map pinecone` (alias `pinecone.io`).
- Added `--blast-radius` as an alias for `--access-map` on `kingfisher scan`, and `kingfisher blast-radius <provider>` as an alias for the `kingfisher access-map <provider>` subcommand, so the user-facing "blast radius" concept matches the CLI invocation.
- **Webhook alerting — Discord, Mattermost, and Google Chat targets:** `--alert-format` now accepts `discord` (color-coded embeds), `mattermost` (Slack-compatible attachments), and `googlechat` (`cardsV2` cards). Discord and Google Chat URLs are auto-inferred from the webhook host; Mattermost requires `--alert-format mattermost` since it is always self-hosted. All five chat targets (Slack, Teams, Discord, Mattermost, Google Chat) plus the Generic JSON sink can be combined in a single run via repeated `--alert-webhook` flags or `alerts.webhooks` entries in `kingfisher.yaml`.
- **Webhook alerting — `--alert-detail` mode:** new `--alert-detail auto|summary|detail` flag controls per-finding verbosity. `auto` (default) renders inline findings for ≤ 25 filtered results and drops to a summary card for larger scans so high-volume runs do not flood the channel. `summary` always suppresses per-finding blocks; `detail` always renders them. Per-webhook overrides are available via `detail:` in `kingfisher.yaml`.
- **Webhook alerting — `--alert-report-url` pivot link:** pass a CI run URL (or set `KINGFISHER_ALERT_REPORT_URL`) to embed a one-click "Full report →" link in every chat payload. In GitHub Actions, pair with `github.server_url/${{ github.repository }}/actions/runs/${{ github.run_id }}` to land the responder directly in the SARIF view for that run.
- **Webhook alerting — fingerprints in chat payloads:** every finding rendered in detail mode now includes its stable `fingerprint` ID (e.g. `fp:1635470773610661884`), matching the value emitted in JSON/JSONL/SARIF/baseline outputs. SOAR playbooks and SIEM rules can use these IDs to dedupe across runs without a separate correlation step.
- **Webhook alerting — scan target in all alert modes:** the "Target" line in chat payloads now correctly reflects the actual scan target for all input modes (GitHub org/user, GitLab group, Bitbucket workspace, S3/GCS bucket, Docker image, Jira/Confluence, Slack, Teams, Postman, etc.), not just local path scans.
- **Access Map UI redesign** in the report viewer: identities are now grouped into collapsible per-provider sections (admin-bearing providers first); permissions are classified by severity (admin / privilege escalation / risky / read-only) with color-coded badges and rollup chips on each card header; the expanded card body renders permissions **once per group** with a "These permissions apply to all N resources above" banner instead of repeating the same 50+ badges per resource; duplicate-named identities (e.g., multiple MongoDB `admin` tokens) now display a discriminator subtitle (`identity_id · access_type`) so they're tellable apart; new "Critical only" toolbar toggle (persisted in `localStorage`) hides read-only permissions and zero-risk identities; the stats bar gained an admin-permission count. Imported TruffleHog/Gitleaks reports keep the previous flat rendering as a backwards-compatible fallback. Underlying JSON now includes `permissions_by_severity` and an `identity.context` discriminator on each `AccessMapEntry`.
## [v1.98.0]

View file

@ -0,0 +1,244 @@
---
date: 2026-05-04
title: "Real-time Secret Alerts: Webhooks for Slack, Teams, Discord, Mattermost, and Google Chat"
description: >
Kingfisher now POSTs scan results straight to your team's chat the moment a
scan completes — Slack, Microsoft Teams, Discord, Mattermost, Google Chat,
or any HTTPS endpoint. With per-finding fingerprints, a pivot link to the
full report, and an auto-summary mode that keeps high-volume scans from
spamming the channel.
categories:
- Features
tags:
- alerts
- webhooks
- slack
- teams
- discord
- mattermost
- google-chat
- integrations
---
# Real-time Secret Alerts: Webhooks for Slack, Teams, Discord, Mattermost, and Google Chat
A scanner that finds secrets in CI is only useful if a human sees the result
in time to act on it. The default outcome — a JSON file in an artifact bucket
that nobody opens until the next incident — is roughly the same as not
running the scanner at all.
Kingfisher now closes that gap with **first-class webhook alerting** for the
five major team chat platforms plus a generic JSON sink, all configurable from
a single CLI flag or a project-local `kingfisher.yaml`.
<!-- more -->
## What's new
- **Five chat targets**: Slack (Block Kit), Microsoft Teams (MessageCard),
Discord (color-coded embeds), Mattermost (Slack-compatible attachments),
and Google Chat (cardsV2). Plus a generic JSON envelope for SIEM ingestion.
- **Auto-detail mode**: when a scan finds more than 25 secrets, the chat
payload automatically drops the per-finding block and points the operator
at the full report instead.
- **Report URL pivot**: every payload can carry a "Full report →" link to
the canonical artifact (CI run, S3 object, SARIF in Code Scanning).
- **Fingerprints in every finding**: stable per-finding IDs round-trip into
chat payloads so SIEM/SOAR tooling can dedupe across runs.
- **Secret redaction by default**: snippets are replaced with `<redacted>`
unless you explicitly opt in. Chat retention is uneven and screenshots are
forever — secrets do not belong in a chat audit trail.
- **YAML configuration**: declare your webhooks once in `kingfisher.yaml`
and check it into the repo. Per-webhook overrides for format, severity
filter, detail mode, and report URL.
## The 30-second quick start
If your webhook URL points at a recognizable host (`hooks.slack.com`,
`outlook.office.com`, `discord.com`, `chat.googleapis.com`), you don't need
to specify a format — Kingfisher infers it:
```bash
kingfisher scan ./repo \
--alert-webhook "$SLACK_SECURITY_WEBHOOK"
```
That's it. Run a scan, get a card in `#security-alerts` with the count, the
top rules, the first ten findings, and the Kingfisher version. URLs are
treated as secrets — they're redacted in any log line Kingfisher emits.
## Mix and match destinations in one run
`--alert-webhook` is repeatable, and each destination can have its own
format. A common pattern is a quiet SOC channel paired with a SIEM ingest
endpoint:
```bash
kingfisher scan ./repo \
--alert-webhook "$SLACK_SOC_WEBHOOK" \
--alert-webhook "$TEAMS_AUDIT_WEBHOOK" \
--alert-webhook "https://siem.example.com/ingest" \
--alert-format generic # only applies to the third one; first two are inferred
```
Mattermost is the one exception to auto-inference: because it's always
self-hosted, there is no canonical hostname to detect. Pass the format
explicitly:
```bash
kingfisher scan ./repo \
--alert-webhook "https://mattermost.example.com/hooks/abc123" \
--alert-format mattermost
```
## Auto-detail keeps high-volume scans readable
The biggest UX failure of "send everything to chat" is what happens when the
scan finds 200 secrets in a freshly-onboarded legacy repo. Truncating to ten
is worse than useless — the operator has no idea what they're missing.
`--alert-detail auto` (the default) handles this gracefully:
- 025 filtered findings → render the per-finding block inline.
- 26+ findings → drop the per-finding block, surface the count, and point
the operator at the full report.
You can force the mode if you want consistent behavior:
```bash
# SOC-style summary card every time, regardless of count
kingfisher scan ./repo \
--alert-webhook "$SLACK_SOC_WEBHOOK" \
--alert-detail summary \
--alert-report-url "$GITHUB_RUN_URL"
# Bug-bounty-style raw detail, even on big runs
kingfisher scan ./repo \
--alert-webhook "$DISCORD_RECON_WEBHOOK" \
--alert-detail detail
```
## A pivot link is the missing link
Pair `--alert-report-url` with whatever produces the full artifact and the
chat alert becomes a one-click triage handoff. In GitHub Actions:
```yaml
- name: Run Kingfisher
env:
KINGFISHER_ALERT_REPORT_URL: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}
run: |
kingfisher scan ./ \
--alert-webhook "${{ secrets.SLACK_SECURITY_WEBHOOK }}" \
--format sarif --output kingfisher.sarif
- uses: github/codeql-action/upload-sarif@v3
with:
sarif_file: kingfisher.sarif
```
Now your Slack alert reads "**16 secrets found in repo X — Full report →**"
and one click drops the responder into the SARIF view in GitHub Code
Scanning, scoped to that exact run.
`KINGFISHER_ALERT_REPORT_URL` works as an env-var fallback if the flag isn't
passed, which is convenient for orchestrators that already export run URLs
into the environment.
## Fingerprints make dedupe trivial
Every finding in chat-detail mode and every record in the Generic JSON
payload carries a stable `fingerprint` — the same one Kingfisher emits in
its baseline file and SARIF report. Concretely:
```
• kingfisher.aws.1 at src/foo.rs:42 — <redacted> (validation: Active Credential) — fp:1635470773610661884
```
That ID is deterministic across runs. Hook it up to your dedupe layer of
choice:
- A SOAR playbook can suppress repeats during the lifetime of an open ticket.
- A SIEM rule can correlate the chat alert with the matching record in the
scheduled SARIF ingest.
- A Slack workflow can thread alerts by fingerprint so a single offending
secret gets one thread, not 50 separate pings.
## Declarative setup with `kingfisher.yaml`
Long CLI invocations get awkward in CI. Drop a `kingfisher.yaml` next to the
repo root and Kingfisher auto-discovers it:
```yaml
alerts:
webhooks:
- url: https://hooks.slack.com/services/T0/B0/AAA
format: slack
on: findings
min_confidence: high
detail: detail
- url: https://outlook.office.com/webhook/XXX
format: teams
on: always
min_confidence: medium
detail: summary
report_url: https://github.com/org/repo/actions/runs/4242
- url: https://siem.example.com/ingest
format: generic
on: always
min_confidence: low
filters:
skip_words: ["EXAMPLE", "PLACEHOLDER"]
exclude: ["vendor/", "**/node_modules/**"]
```
CLI flags and config-file webhooks are concatenated, and per-webhook
overrides let you split delivery: a *detail* card to the on-call channel for
high-confidence findings, a *summary* card to a broader channel that pages
on every run, and a generic JSON feed to your SIEM with no confidence floor
at all.
## Why this matters
Three audiences win here, and they want different things from the same tool.
**Blue teams** want a low-noise notification — "something fired, look at the
report" — with enough metadata to triage without leaving chat. Auto-summary
mode plus a report-URL pivot is exactly that workflow.
**SOC and detection-engineering teams** want machine-readable events keyed
by fingerprint so their SIEM can dedupe across runs and correlate with
existing incident tickets. The Generic JSON envelope plus stable
fingerprints handles that without you needing a separate exporter.
**Bug bounty researchers and red teamers** want raw per-finding output in
real time, often into a personal Discord channel. Default `auto` mode plus
explicit `--alert-detail detail` covers that with the same flag surface.
The combined result is that **Kingfisher's output now reaches the human who
needs to see it, in the format they expect, on the platform they already
live in** — without you having to write a single line of glue code or stand
up a notification microservice.
And because URLs are redacted in every log line and secrets are redacted in
every payload by default, you get all of that without making your chat the
next leak vector.
## Get started
```bash
# Install — see the README for other platforms
brew install kingfisher
# Scan and alert
kingfisher scan ./repo \
--alert-webhook "$SLACK_SECURITY_WEBHOOK" \
--alert-report-url "$GITHUB_RUN_URL"
```
For the full schema and per-platform payload examples, see
[`docs/ALERTS.md`](https://github.com/mongodb/kingfisher/blob/main/docs/ALERTS.md)
and [`docs/CONFIG.md`](https://github.com/mongodb/kingfisher/blob/main/docs/CONFIG.md).
If a destination you'd like to alert to isn't on the list, open an issue at
[mongodb/kingfisher](https://github.com/mongodb/kingfisher/issues).

View file

@ -8,10 +8,16 @@ description: "Kingfisher release history: new features, rules, bug fixes, and im
All notable changes to this project will be documented in this file.
## [unreleased v1.99.0]
- Fixed [#371](https://github.com/mongodb/kingfisher/issues/371): `pip install kingfisher-bin` on glibc Linux distros (Ubuntu, Debian, RHEL, Fedora, …) installed a macOS Mach-O binary and failed with `OSError: [Errno 8] Exec format error`. Linux wheels are now tagged `manylinux_2_17_<arch>.musllinux_1_2_<arch>` (instead of `musllinux_1_2_<arch>` only), so pip accepts them on both glibc-2.17+ and musl distros. The `pypi/hatch_build.py` hook now hard-fails when `KINGFISHER_PYPI_WHEEL_TAG` is unset, and the publish workflow refuses to upload any `py3-none-any.whl`, so the v1.92.0-era pure-Python wheel cannot recur.
- `--self-update` (alias `--update`) on a scan or other command now **re-execs into the freshly installed binary** so the current invocation completes with the new code and the latest detection rules. Previously the on-disk binary was replaced but the running process kept using the old in-memory version, requiring a second invocation to pick up the changes. On Unix this is a true `exec()` (same PID); on Windows the new binary is spawned and the parent exits with its status code. The explicit `kingfisher self-update` subcommand still updates and exits without re-execing. Self-update now also covers Windows arm64 (the asset was already published; the runtime cfg map gained the missing arm). See `docs/ADVANCED.md`*Update Checks*.
- `--include-contributors` now respects `--github-repo-type` when enumerating contributor-owned repositories: by default contributor forks are excluded (matching the existing `Source` default), previously they were always included regardless of the flag. Added a new `--github-repo-type all` option to opt into the prior behavior of scanning both source and fork repos for contributors, organizations, and users.
- **Access Map:** Pinecone API keys (validated `kingfisher.pinecone.1`): caller resources via `GET /indexes` (with serverless cloud/region or pod environment metadata, deletion-protection state) and `GET /collections`; standalone `kingfisher access-map pinecone` (alias `pinecone.io`).
- Added `--blast-radius` as an alias for `--access-map` on `kingfisher scan`, and `kingfisher blast-radius <provider>` as an alias for the `kingfisher access-map <provider>` subcommand, so the user-facing "blast radius" concept matches the CLI invocation.
- **Webhook alerting — Discord, Mattermost, and Google Chat targets:** `--alert-format` now accepts `discord` (color-coded embeds), `mattermost` (Slack-compatible attachments), and `googlechat` (`cardsV2` cards). Discord and Google Chat URLs are auto-inferred from the webhook host; Mattermost requires `--alert-format mattermost` since it is always self-hosted. All five chat targets (Slack, Teams, Discord, Mattermost, Google Chat) plus the Generic JSON sink can be combined in a single run via repeated `--alert-webhook` flags or `alerts.webhooks` entries in `kingfisher.yaml`.
- **Webhook alerting — `--alert-detail` mode:** new `--alert-detail auto|summary|detail` flag controls per-finding verbosity. `auto` (default) renders inline findings for ≤ 25 filtered results and drops to a summary card for larger scans so high-volume runs do not flood the channel. `summary` always suppresses per-finding blocks; `detail` always renders them. Per-webhook overrides are available via `detail:` in `kingfisher.yaml`.
- **Webhook alerting — `--alert-report-url` pivot link:** pass a CI run URL (or set `KINGFISHER_ALERT_REPORT_URL`) to embed a one-click "Full report →" link in every chat payload. In GitHub Actions, pair with `github.server_url/${{ github.repository }}/actions/runs/${{ github.run_id }}` to land the responder directly in the SARIF view for that run.
- **Webhook alerting — fingerprints in chat payloads:** every finding rendered in detail mode now includes its stable `fingerprint` ID (e.g. `fp:1635470773610661884`), matching the value emitted in JSON/JSONL/SARIF/baseline outputs. SOAR playbooks and SIEM rules can use these IDs to dedupe across runs without a separate correlation step.
- **Webhook alerting — scan target in all alert modes:** the "Target" line in chat payloads now correctly reflects the actual scan target for all input modes (GitHub org/user, GitLab group, Bitbucket workspace, S3/GCS bucket, Docker image, Jira/Confluence, Slack, Teams, Postman, etc.), not just local path scans.
## [v1.98.0]
- Added first-class **Postman** scanning target: new `kingfisher scan postman` subcommand (and equivalent `--postman-*` flags) fetches workspaces, collections, and environments via the Postman API and scans them for hard-coded credentials in request `auth` blocks, pre-request/test scripts, saved example responses, and — notably — `secret`-typed environment variables, which the API returns in plaintext despite the UI mask. Selectors: `--workspace`, `--collection`, `--environment`, `--all`, with optional `--include-mocks-monitors` and `--api-url` for self-hosted endpoints. Authenticates via `KF_POSTMAN_TOKEN` (or `POSTMAN_API_KEY`) sent as `X-Api-Key`; honors `X-RateLimit-RetryAfter` on 429s. Findings link back to `https://go.postman.co/...` URLs in reports.

View file

@ -53,11 +53,48 @@ always self-hosted), so it is **never** inferred — pass
| `--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. |
| `--alert-report-url URL` | *(none)* | Pivot link rendered in every payload — typically a CI run URL or report-artifact URL. Reads `KINGFISHER_ALERT_REPORT_URL` env var as a fallback. |
| `--alert-detail summary\|detail\|auto` | `auto` | How much per-finding detail to render. `auto` switches to `summary` once the per-sink filtered finding count exceeds 25. |
Webhook URLs are sensitive: the host/path/query are redacted in logs. Pass them
via environment variables (`$SLACK_SECURITY_WEBHOOK`) or CI secrets, never
inline in committed files.
## Detail modes
Chat is a notification surface, not a report viewer. `--alert-detail` controls
how much per-finding detail Kingfisher tries to cram into a single message:
- **`detail`** — header + summary stats + up to 10 findings inline + report link.
Best for low-volume runs where the reviewer wants triage info in chat.
- **`summary`** — header + summary stats + report link, *no* per-finding lines.
Best for high-volume runs and SOC/SIEM ingestion where chat just needs to
page someone with a count.
- **`auto`** (default) — `detail` when filtered findings ≤ 25, otherwise
`summary`. Avoids the "10 shown, 190 omitted" anti-pattern on large repos.
Pair `summary` (or `auto` at scale) with `--alert-report-url` so the operator
has a one-click pivot to the full report:
```bash
kingfisher scan ./repo \
--alert-webhook "$SLACK_SECURITY_WEBHOOK" \
--alert-report-url "$GITHUB_RUN_URL" \
--alert-detail auto \
--format json --output ./kingfisher-report.json
```
## Per-finding fingerprints
Every finding line in `detail` mode (and every record in the Generic JSON
payload) carries a stable `fingerprint`. Downstream automation (SIEM/SOAR,
Jira webhooks, custom dedupe) can use it to:
- Suppress repeat alerts when the same secret reappears in subsequent runs.
- Correlate the chat alert with the matching `kingfisher.fingerprint` in the
baseline file or the SARIF report.
- Build per-finding triage threads / tickets keyed by fingerprint.
## Payload shapes
### Slack (Block Kit)
@ -144,6 +181,14 @@ alerts:
- url: https://chat.googleapis.com/v1/spaces/AAA/messages?key=k&token=t
format: googlechat
on: always
report_url: https://github.com/org/repo/actions/runs/4242 # per-webhook pivot link
detail: summary # blue-team mode for this sink
```
`report_url` and `detail` can be set globally via `--alert-report-url` and
`--alert-detail`, or overridden per-webhook in YAML. Per-webhook overrides
let you, for example, send a *summary* card with a CI link to a busy team
channel while still sending *detail* + per-finding fingerprints to a quieter
SOC channel.
See [`docs/CONFIG.md`](CONFIG.md) for the full config schema.

View file

@ -21,6 +21,8 @@ alerts:
on: findings # findings | always
min_confidence: medium # low | medium | high
include_secret: false # default false
report_url: https://ci.example/run/42 # optional pivot link rendered in payload
detail: auto # summary | detail | auto (default auto)
filters:
skip_words:

View file

@ -7,6 +7,11 @@ from hatchling.builders.hooks.plugin.interface import BuildHookInterface
class CustomBuildHook(BuildHookInterface):
def initialize(self, version: str, build_data: Dict[str, Any]) -> None:
wheel_tag = os.environ.get("KINGFISHER_PYPI_WHEEL_TAG")
if wheel_tag:
build_data["tag"] = wheel_tag
build_data["pure_python"] = False
if not wheel_tag:
raise RuntimeError(
"KINGFISHER_PYPI_WHEEL_TAG is required. "
"Run scripts/build-pypi-wheel.sh --plat-name <tag> "
"instead of `python -m build` directly."
)
build_data["tag"] = wheel_tag
build_data["pure_python"] = False

View file

@ -7,7 +7,7 @@
use serde_json::{Value, json};
use crate::alerts::AlertSummary;
use crate::alerts::{AlertDetail, AlertSummary};
use crate::reporter::FindingReporterRecord;
const PER_FINDING_LIMIT: usize = 10;
@ -77,7 +77,7 @@ pub fn build_payload(
"footer": { "text": format!("kingfisher v{}", summary.kingfisher_version) },
});
if !findings.is_empty() {
if !findings.is_empty() && summary.detail == AlertDetail::Detail {
let take = findings.len().min(PER_FINDING_LIMIT);
let mut detail = String::new();
for f in findings.iter().take(take) {
@ -87,14 +87,38 @@ pub fn build_payload(
"<redacted>".to_string()
};
detail.push_str(&format!(
"• `{}` at `{}:{}` — `{}` (validation: {})\n",
f.rule.id, f.finding.path, f.finding.line, snippet, f.finding.validation.status,
"• `{}` at `{}:{}` — `{}` (validation: {}) — fp:`{}`\n",
f.rule.id,
f.finding.path,
f.finding.line,
snippet,
f.finding.validation.status,
f.finding.fingerprint,
));
}
if findings.len() > take {
detail.push_str(&format!("{} more findings omitted", findings.len() - take));
}
embed["description"] = Value::String(truncate(&detail, DESCRIPTION_SOFT_LIMIT));
} else if summary.detail == AlertDetail::Summary && summary.filtered_total > 0 {
embed["description"] = Value::String(format!(
"_{} findings — per-finding detail suppressed (summary mode). See full report for specifics._",
summary.filtered_total
));
}
// Render the report URL as a clickable embed link in the title (Discord
// does not have a dedicated "actions" surface on webhook embeds).
if let Some(url) = &summary.report_url {
embed["url"] = Value::String(url.clone());
// Append a fields entry too — embed `url` only renders if the title
// is short enough; the field guarantees the link is visible.
let fields_arr = embed["fields"].as_array_mut().expect("fields is an array");
fields_arr.push(json!({
"name": "Full report",
"value": format!("[Open]({})", url),
"inline": false,
}));
}
json!({ "embeds": [embed] })
@ -125,6 +149,9 @@ mod tests {
by_rule: vec![],
kingfisher_version: "test".to_string(),
target: None,
report_url: None,
detail: crate::alerts::AlertDetail::Detail,
filtered_total: total,
}
}
@ -158,4 +185,36 @@ mod tests {
let p = build_payload(&summary(0, 0), &[], false);
assert!(p["embeds"][0].get("description").is_none());
}
#[test]
fn report_url_renders_as_field_and_embed_url() {
let mut s = summary(0, 0);
s.report_url = Some("https://ci.example/run/9".to_string());
let p = build_payload(&s, &[], false);
assert_eq!(p["embeds"][0]["url"], "https://ci.example/run/9");
let serialized = serde_json::to_string(&p).unwrap();
assert!(serialized.contains("Full report"));
}
#[test]
fn summary_mode_emits_suppression_notice() {
let mut s = summary(50, 5);
s.detail = crate::alerts::AlertDetail::Summary;
s.filtered_total = 50;
let rec = crate::alerts::make_test_record("kingfisher.aws.1", "fp-x");
let p = build_payload(&s, &[&rec], false);
let desc = p["embeds"][0]["description"].as_str().unwrap();
assert!(desc.contains("per-finding detail suppressed"));
assert!(!desc.contains("kingfisher.aws.1"));
}
#[test]
fn detail_mode_includes_fingerprint() {
let mut s = summary(1, 1);
s.filtered_total = 1;
let rec = crate::alerts::make_test_record("kingfisher.aws.1", "fp-d-99");
let p = build_payload(&s, &[&rec], false);
let desc = p["embeds"][0]["description"].as_str().unwrap();
assert!(desc.contains("fp:`fp-d-99`"));
}
}

View file

@ -6,7 +6,7 @@
use serde_json::{Value, json};
use crate::alerts::AlertSummary;
use crate::alerts::{AlertDetail, AlertSummary};
use crate::reporter::FindingReporterRecord;
const PER_FINDING_LIMIT: usize = 200;
@ -17,7 +17,14 @@ pub fn build_payload(
findings: &[&FindingReporterRecord],
include_secret: bool,
) -> Value {
let take = findings.len().min(PER_FINDING_LIMIT);
// In Summary mode the operator wants summary stats only — useful for
// SIEMs that just want to count events but don't ingest per-finding
// detail from the alert pipe (they pull the SARIF/JSON report directly).
let take = if summary.detail == AlertDetail::Summary {
0
} else {
findings.len().min(PER_FINDING_LIMIT)
};
let included: Vec<Value> = findings
.iter()
.take(take)
@ -32,17 +39,22 @@ pub fn build_payload(
})
.collect();
let mut summary_obj = json!({
"total": summary.total,
"active": summary.active,
"inactive": summary.inactive,
"unknown": summary.unknown,
"by_rule": summary.by_rule.iter().map(|(r, c)| json!({"rule_id": r, "count": c})).collect::<Vec<_>>(),
"target": summary.target,
});
if let Some(url) = &summary.report_url {
summary_obj["report_url"] = Value::String(url.clone());
}
json!({
"schema_version": SCHEMA_VERSION,
"kingfisher_version": summary.kingfisher_version,
"summary": {
"total": summary.total,
"active": summary.active,
"inactive": summary.inactive,
"unknown": summary.unknown,
"by_rule": summary.by_rule.iter().map(|(r, c)| json!({"rule_id": r, "count": c})).collect::<Vec<_>>(),
"target": summary.target,
},
"summary": summary_obj,
"findings": included,
"findings_omitted": findings.len().saturating_sub(take),
})
@ -61,6 +73,9 @@ mod tests {
by_rule: vec![],
kingfisher_version: "test".to_string(),
target: None,
report_url: None,
detail: crate::alerts::AlertDetail::Detail,
filtered_total: 0,
}
}
@ -76,4 +91,33 @@ mod tests {
assert_eq!(p["findings"].as_array().unwrap().len(), 0);
assert_eq!(p["findings_omitted"], 0);
}
#[test]
fn report_url_appears_in_summary_block() {
let mut s = empty_summary();
s.report_url = Some("https://ci.example/run/7".to_string());
let p = build_payload(&s, &[], false);
assert_eq!(p["summary"]["report_url"], "https://ci.example/run/7");
}
#[test]
fn summary_mode_drops_findings_array() {
let mut s = empty_summary();
s.detail = crate::alerts::AlertDetail::Summary;
s.filtered_total = 3;
let rec = crate::alerts::make_test_record("kingfisher.aws.1", "fp-abc");
let p = build_payload(&s, &[&rec, &rec, &rec], false);
// In summary mode the findings array is empty, but findings_omitted
// accurately reflects what was dropped so SIEMs still see the count.
assert_eq!(p["findings"].as_array().unwrap().len(), 0);
assert_eq!(p["findings_omitted"], 3);
}
#[test]
fn fingerprint_round_trips_in_detail_mode() {
let s = empty_summary();
let rec = crate::alerts::make_test_record("kingfisher.aws.1", "fp-roundtrip");
let p = build_payload(&s, &[&rec], false);
assert_eq!(p["findings"][0]["finding"]["fingerprint"], "fp-roundtrip");
}
}

View file

@ -9,7 +9,7 @@
use serde_json::{Value, json};
use crate::alerts::AlertSummary;
use crate::alerts::{AlertDetail, AlertSummary};
use crate::reporter::FindingReporterRecord;
const PER_FINDING_LIMIT: usize = 10;
@ -60,7 +60,7 @@ pub fn build_payload(
"widgets": summary_widgets,
})];
if !findings.is_empty() {
if !findings.is_empty() && summary.detail == AlertDetail::Detail {
let take = findings.len().min(PER_FINDING_LIMIT);
let mut detail = String::new();
for f in findings.iter().take(take) {
@ -70,8 +70,13 @@ pub fn build_payload(
"<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,
"• <b>{}</b> at <code>{}:{}</code> — <code>{}</code> (validation: {}) — fp:<code>{}</code><br>",
f.rule.id,
f.finding.path,
f.finding.line,
snippet,
f.finding.validation.status,
f.finding.fingerprint,
));
}
if findings.len() > take {
@ -81,6 +86,29 @@ pub fn build_payload(
"header": "Findings",
"widgets": [{ "textParagraph": { "text": detail } }],
}));
} else if summary.detail == AlertDetail::Summary && summary.filtered_total > 0 {
sections.push(json!({
"header": "Findings",
"widgets": [{ "textParagraph": { "text": format!(
"<i>{} findings — per-finding detail suppressed (summary mode). See full report for specifics.</i>",
summary.filtered_total
) }}],
}));
}
if let Some(url) = &summary.report_url {
// `buttonList` widget gives a tappable "Full report" button below the
// card body — Google Chat's idiomatic way to render a pivot link.
sections.push(json!({
"widgets": [{
"buttonList": {
"buttons": [{
"text": "Full report",
"onClick": { "openLink": { "url": url } }
}]
}
}]
}));
}
json!({
@ -122,6 +150,9 @@ mod tests {
by_rule: vec![],
kingfisher_version: "test".to_string(),
target: None,
report_url: None,
detail: crate::alerts::AlertDetail::Detail,
filtered_total: total,
}
}
@ -152,4 +183,39 @@ mod tests {
let p = build_payload(&summary(0, 0), &[], false);
assert_eq!(p["cardsV2"][0]["card"]["header"]["subtitle"], "kingfisher vtest");
}
#[test]
fn report_url_renders_as_button() {
let mut s = summary(0, 0);
s.report_url = Some("https://ci.example/run/11".to_string());
let p = build_payload(&s, &[], false);
let serialized = serde_json::to_string(&p).unwrap();
assert!(serialized.contains("https://ci.example/run/11"));
assert!(serialized.contains("Full report"));
assert!(serialized.contains("buttonList"));
}
#[test]
fn summary_mode_emits_suppression_notice() {
let mut s = summary(60, 0);
s.detail = crate::alerts::AlertDetail::Summary;
s.filtered_total = 60;
let rec = crate::alerts::make_test_record("kingfisher.aws.1", "fp-z");
let p = build_payload(&s, &[&rec], false);
let serialized = serde_json::to_string(&p).unwrap();
assert!(serialized.contains("per-finding detail suppressed"));
// Rule id present in HTML-encoded form like <b>kingfisher.aws.1</b>
// would mean detail mode leaked through; assert absence.
assert!(!serialized.contains("kingfisher.aws.1"));
}
#[test]
fn detail_mode_includes_fingerprint() {
let mut s = summary(1, 1);
s.filtered_total = 1;
let rec = crate::alerts::make_test_record("kingfisher.aws.1", "fp-gc-13");
let p = build_payload(&s, &[&rec], false);
let serialized = serde_json::to_string(&p).unwrap();
assert!(serialized.contains("fp-gc-13"));
}
}

View file

@ -13,7 +13,7 @@
use serde_json::{Value, json};
use crate::alerts::AlertSummary;
use crate::alerts::{AlertDetail, AlertSummary};
use crate::reporter::FindingReporterRecord;
const PER_FINDING_LIMIT: usize = 10;
@ -73,7 +73,7 @@ pub fn build_payload(
"footer": format!("kingfisher v{}", summary.kingfisher_version),
});
if !findings.is_empty() {
if !findings.is_empty() && summary.detail == AlertDetail::Detail {
let take = findings.len().min(PER_FINDING_LIMIT);
let mut details = String::new();
for f in findings.iter().take(take) {
@ -83,14 +83,37 @@ pub fn build_payload(
"<redacted>".to_string()
};
details.push_str(&format!(
"- **{}** at `{}:{}` — `{}` (validation: {})\n",
f.rule.id, f.finding.path, f.finding.line, snippet, f.finding.validation.status,
"- **{}** at `{}:{}` — `{}` (validation: {}) — fp:`{}`\n",
f.rule.id,
f.finding.path,
f.finding.line,
snippet,
f.finding.validation.status,
f.finding.fingerprint,
));
}
if findings.len() > take {
details.push_str(&format!("_…{} more findings omitted_\n", findings.len() - take));
}
attachment["text"] = Value::String(details);
} else if summary.detail == AlertDetail::Summary && summary.filtered_total > 0 {
attachment["text"] = Value::String(format!(
"_{} findings — per-finding detail suppressed (summary mode). See full report for specifics._",
summary.filtered_total
));
}
if let Some(url) = &summary.report_url {
// Mattermost renders `attachments[].title_link` as a clickable title.
// Setting both `title_link` and a fallback field makes the link
// visible regardless of how a given client/version renders.
attachment["title_link"] = Value::String(url.clone());
let fields_arr = attachment["fields"].as_array_mut().expect("fields is an array");
fields_arr.push(json!({
"short": false,
"title": "Full report",
"value": format!("[Open]({})", url),
}));
}
json!({
@ -124,6 +147,9 @@ mod tests {
by_rule: vec![],
kingfisher_version: "test".to_string(),
target: None,
report_url: None,
detail: crate::alerts::AlertDetail::Detail,
filtered_total: total,
}
}
@ -157,4 +183,34 @@ mod tests {
let p = build_payload(&summary(0, 0), &[], false);
assert_eq!(p["attachments"][0]["footer"], "kingfisher vtest");
}
#[test]
fn report_url_renders_as_title_link() {
let mut s = summary(0, 0);
s.report_url = Some("https://ci.example/run/3".to_string());
let p = build_payload(&s, &[], false);
assert_eq!(p["attachments"][0]["title_link"], "https://ci.example/run/3");
}
#[test]
fn summary_mode_emits_suppression_notice() {
let mut s = summary(40, 0);
s.detail = crate::alerts::AlertDetail::Summary;
s.filtered_total = 40;
let rec = crate::alerts::make_test_record("kingfisher.aws.1", "fp-y");
let p = build_payload(&s, &[&rec], false);
let text = p["attachments"][0]["text"].as_str().unwrap();
assert!(text.contains("per-finding detail suppressed"));
assert!(!text.contains("kingfisher.aws.1"));
}
#[test]
fn detail_mode_includes_fingerprint() {
let mut s = summary(1, 1);
s.filtered_total = 1;
let rec = crate::alerts::make_test_record("kingfisher.aws.1", "fp-mm-7");
let p = build_payload(&s, &[&rec], false);
let text = p["attachments"][0]["text"].as_str().unwrap();
assert!(text.contains("fp:`fp-mm-7`"));
}
}

View file

@ -39,6 +39,29 @@ impl Default for AlertOn {
}
}
/// How much per-finding detail to include in alert payloads.
///
/// `Auto` switches to `Summary` once the per-sink filtered finding count
/// exceeds [`AUTO_DETAIL_THRESHOLD`] — at that volume, chat detail blocks add
/// noise without being actionable, and the operator should be pivoting to the
/// full report (see `--alert-report-url`).
#[derive(Copy, Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize, clap::ValueEnum)]
#[serde(rename_all = "lowercase")]
#[clap(rename_all = "lowercase")]
pub enum AlertDetail {
/// Headline + top-rules + report link only. No per-finding lines.
Summary,
/// Headline + top-rules + per-finding lines (capped at 10).
Detail,
/// `Detail` if filtered findings ≤ [`AUTO_DETAIL_THRESHOLD`], else `Summary`.
#[default]
Auto,
}
/// Auto-mode threshold: if a sink's filtered finding count exceeds this, the
/// payload drops the per-finding block and points at the full report instead.
pub const AUTO_DETAIL_THRESHOLD: usize = 25;
/// Webhook payload format / target.
#[derive(Copy, Clone, Debug, PartialEq, Eq, Serialize, Deserialize, clap::ValueEnum)]
#[serde(rename_all = "lowercase")]
@ -86,9 +109,21 @@ pub struct AlertSink {
pub on: AlertOn,
pub min_confidence: ConfidenceLevel,
pub include_secret: bool,
/// Pivot link rendered in the payload — typically the URL of the full
/// report artifact (CI run, S3 object, SARIF in Code Scanning, etc).
/// `None` omits the link from the payload.
pub report_url: Option<String>,
/// How much per-finding detail to include. `Auto` is resolved against the
/// per-sink filtered finding count at dispatch time before the payload
/// builder runs, so each `build_payload` only sees `Summary` or `Detail`.
pub detail: AlertDetail,
}
/// Summary numbers we surface to every sink, regardless of format.
///
/// Per-sink fields (`report_url`, `detail`, `filtered_total`) are populated by
/// `dispatch` immediately before the payload builder runs. They are
/// intentionally not part of `from_findings` because they are sink-specific.
#[derive(Clone, Debug, Serialize)]
pub struct AlertSummary {
pub total: usize,
@ -98,6 +133,16 @@ pub struct AlertSummary {
pub by_rule: Vec<(String, usize)>,
pub kingfisher_version: String,
pub target: Option<String>,
/// Pivot link, copied from the per-sink configuration. `None` → no link
/// is rendered.
#[serde(skip_serializing_if = "Option::is_none")]
pub report_url: Option<String>,
/// Resolved detail level (`Summary` or `Detail`, never `Auto`).
pub detail: AlertDetail,
/// Count of findings the per-sink min-confidence filter let through. May
/// be smaller than `total` when the sink raises `min_confidence` above the
/// scan default.
pub filtered_total: usize,
}
impl AlertSummary {
@ -127,6 +172,11 @@ impl AlertSummary {
by_rule,
kingfisher_version: env!("CARGO_PKG_VERSION").to_string(),
target,
report_url: None,
// Placeholder; `dispatch` overwrites this per-sink with a resolved
// value (`Summary` or `Detail`) before calling `build_payload`.
detail: AlertDetail::Detail,
filtered_total: findings.len(),
}
}
}
@ -176,18 +226,18 @@ pub async fn dispatch(
}
};
let summary = AlertSummary::from_findings(findings, target);
let base_summary = AlertSummary::from_findings(findings, target);
debug!(
"alert dispatch: total={} active={} inactive={} unknown={} sinks={}",
summary.total,
summary.active,
summary.inactive,
summary.unknown,
base_summary.total,
base_summary.active,
base_summary.inactive,
base_summary.unknown,
sinks.len()
);
for sink in sinks {
if matches!(sink.on, AlertOn::Findings) && summary.total == 0 {
if matches!(sink.on, AlertOn::Findings) && base_summary.total == 0 {
debug!(
"alert dispatch: skipping {} (on=findings, no findings)",
redact_webhook(&sink.url)
@ -199,6 +249,23 @@ pub async fn dispatch(
.filter(|f| matches_min_confidence(&f.finding.confidence, sink.min_confidence))
.collect();
// Per-sink summary: clone the base, overlay sink-specific fields, and
// resolve `Auto` based on this sink's filtered count.
let resolved_detail = match sink.detail {
AlertDetail::Auto => {
if filtered.len() > AUTO_DETAIL_THRESHOLD {
AlertDetail::Summary
} else {
AlertDetail::Detail
}
}
other => other,
};
let mut summary = base_summary.clone();
summary.report_url = sink.report_url.clone();
summary.detail = resolved_detail;
summary.filtered_total = filtered.len();
let payload = match sink.format {
AlertFormat::Slack => slack::build_payload(&summary, &filtered, sink.include_secret),
AlertFormat::Teams => teams::build_payload(&summary, &filtered, sink.include_secret),
@ -256,6 +323,40 @@ async fn post(client: &Client, url: &str, payload: &serde_json::Value) -> Result
Ok(())
}
/// Shared test helper: build a fully-formed `FindingReporterRecord` so payload
/// builders can be unit-tested against per-finding rendering (fingerprint,
/// snippet redaction, summary-mode suppression). Test-only; not for runtime
/// callers.
#[cfg(test)]
pub(crate) fn make_test_record(
rule_id: &str,
fingerprint: &str,
) -> crate::reporter::FindingReporterRecord {
use crate::reporter::{FindingRecordData, FindingReporterRecord, RuleMetadata, ValidationInfo};
FindingReporterRecord {
rule: RuleMetadata { name: rule_id.to_string(), id: rule_id.to_string() },
finding: FindingRecordData {
snippet: "AKIAEXAMPLE_REDACTED_TOKEN_12345".to_string(),
fingerprint: fingerprint.to_string(),
confidence: "Medium".to_string(),
entropy: "4.5".to_string(),
validation: ValidationInfo {
status: "Active Credential".to_string(),
response: String::new(),
},
language: "rust".to_string(),
line: 42,
column_start: 10,
column_end: 50,
path: "src/foo.rs".to_string(),
encoding: None,
git_metadata: None,
validate_command: None,
revoke_command: None,
},
}
}
#[cfg(test)]
mod tests {
use super::*;
@ -329,4 +430,13 @@ mod tests {
AlertFormat::Generic
);
}
#[test]
fn auto_detail_threshold_is_inclusive_at_25() {
// Boundary regression: filtered.len() == THRESHOLD must stay in
// Detail mode; > THRESHOLD must escalate to Summary.
assert_eq!(AUTO_DETAIL_THRESHOLD, 25);
// The resolution itself lives inside `dispatch`; this test pins the
// constant so any future tuning is intentional.
}
}

View file

@ -2,7 +2,7 @@
use serde_json::{Value, json};
use crate::alerts::AlertSummary;
use crate::alerts::{AlertDetail, AlertSummary};
use crate::reporter::FindingReporterRecord;
const PER_FINDING_LIMIT: usize = 10;
@ -55,7 +55,7 @@ pub fn build_payload(
}));
}
if !findings.is_empty() {
if !findings.is_empty() && summary.detail == AlertDetail::Detail {
let take = findings.len().min(PER_FINDING_LIMIT);
let mut detail_lines: Vec<String> = Vec::with_capacity(take);
for f in findings.iter().take(take) {
@ -65,12 +65,13 @@ pub fn build_payload(
"<redacted>".to_string()
};
detail_lines.push(format!(
"• `{}` at `{}:{}` — {} (validation: {})",
"• `{}` at `{}:{}` — {} (validation: {}) — fp:`{}`",
escape_mrkdwn(&f.rule.id),
escape_mrkdwn(&f.finding.path),
f.finding.line,
snippet,
escape_mrkdwn(&f.finding.validation.status)
escape_mrkdwn(&f.finding.validation.status),
escape_mrkdwn(&f.finding.fingerprint),
));
}
if findings.len() > take {
@ -80,6 +81,30 @@ pub fn build_payload(
"type": "section",
"text": { "type": "mrkdwn", "text": detail_lines.join("\n") }
}));
} else if summary.detail == AlertDetail::Summary && summary.filtered_total > 0 {
// Summary-mode: explicitly tell the operator the per-finding block was
// dropped on purpose, so they pivot to the report instead of assuming
// the alert is incomplete.
blocks.push(json!({
"type": "section",
"text": {
"type": "mrkdwn",
"text": format!(
"_{} findings — per-finding detail suppressed (summary mode). See full report for specifics._",
summary.filtered_total
)
}
}));
}
if let Some(url) = &summary.report_url {
blocks.push(json!({
"type": "section",
"text": {
"type": "mrkdwn",
"text": format!("<{}|Full report →>", escape_mrkdwn(url))
}
}));
}
blocks.push(json!({
@ -119,6 +144,9 @@ mod tests {
by_rule: vec![],
kingfisher_version: "test".to_string(),
target: None,
report_url: None,
detail: crate::alerts::AlertDetail::Detail,
filtered_total: 0,
}
}
@ -140,10 +168,52 @@ mod tests {
by_rule: vec![("kingfisher.aws.1".into(), 1)],
kingfisher_version: "test".to_string(),
target: None,
report_url: None,
detail: crate::alerts::AlertDetail::Detail,
filtered_total: 1,
};
let p = build_payload(&summary, &[], false);
let header = p["blocks"][0]["text"]["text"].as_str().unwrap();
assert!(header.contains("1 finding"));
assert!(!header.contains("findings"), "should be singular");
}
#[test]
fn report_url_renders_link_block() {
let mut s = empty_summary();
s.report_url = Some("https://ci.example/run/42".to_string());
let p = build_payload(&s, &[], false);
let serialized = serde_json::to_string(&p).unwrap();
assert!(serialized.contains("https://ci.example/run/42"));
assert!(serialized.contains("Full report"));
}
#[test]
fn summary_mode_suppresses_findings_with_notice() {
let mut s = empty_summary();
s.detail = crate::alerts::AlertDetail::Summary;
s.filtered_total = 50;
let rec = crate::alerts::make_test_record("kingfisher.aws.1", "fp-123");
let p = build_payload(&s, &[&rec], false);
let serialized = serde_json::to_string(&p).unwrap();
// Per-finding rule id must NOT appear in summary mode.
assert!(
!serialized.contains("kingfisher.aws.1"),
"summary mode must not render the per-finding rule id"
);
// The suppression notice must appear so the operator knows why.
assert!(serialized.contains("per-finding detail suppressed"));
assert!(serialized.contains("50 findings"));
}
#[test]
fn detail_mode_includes_fingerprint() {
let mut s = empty_summary();
s.total = 1;
s.filtered_total = 1;
let rec = crate::alerts::make_test_record("kingfisher.aws.1", "fp-abc-123");
let p = build_payload(&s, &[&rec], false);
let serialized = serde_json::to_string(&p).unwrap();
assert!(serialized.contains("fp:`fp-abc-123`"), "fingerprint must appear in detail block");
}
}

View file

@ -7,7 +7,7 @@
use serde_json::{Value, json};
use crate::alerts::AlertSummary;
use crate::alerts::{AlertDetail, AlertSummary};
use crate::reporter::FindingReporterRecord;
const PER_FINDING_LIMIT: usize = 10;
@ -50,7 +50,7 @@ pub fn build_payload(
"markdown": true,
})];
if !findings.is_empty() {
if !findings.is_empty() && summary.detail == AlertDetail::Detail {
let take = findings.len().min(PER_FINDING_LIMIT);
let mut details = String::new();
for f in findings.iter().take(take) {
@ -60,8 +60,13 @@ pub fn build_payload(
"<redacted>".to_string()
};
details.push_str(&format!(
"- **{}** at `{}:{}` — `{}` (validation: {})\n",
f.rule.id, f.finding.path, f.finding.line, snippet, f.finding.validation.status
"- **{}** at `{}:{}` — `{}` (validation: {}) — fp:`{}`\n",
f.rule.id,
f.finding.path,
f.finding.line,
snippet,
f.finding.validation.status,
f.finding.fingerprint,
));
}
if findings.len() > take {
@ -71,16 +76,35 @@ pub fn build_payload(
"title": "Findings",
"text": details,
}));
} else if summary.detail == AlertDetail::Summary && summary.filtered_total > 0 {
sections.push(json!({
"title": "Findings",
"text": format!(
"_{} findings — per-finding detail suppressed (summary mode). See full report for specifics._",
summary.filtered_total
),
}));
}
json!({
let mut card = json!({
"@type": "MessageCard",
"@context": "https://schema.org/extensions",
"summary": title,
"themeColor": theme_color,
"title": title,
"sections": sections,
})
});
if let Some(url) = &summary.report_url {
// Teams renders an `OpenUri` action as a button on the card.
card["potentialAction"] = json!([{
"@type": "OpenUri",
"name": "Full report",
"targets": [{ "os": "default", "uri": url }],
}]);
}
card
}
fn plural(n: usize) -> &'static str {
@ -108,6 +132,9 @@ mod tests {
by_rule: vec![],
kingfisher_version: "test".to_string(),
target: None,
report_url: None,
detail: crate::alerts::AlertDetail::Detail,
filtered_total: total,
}
}
@ -128,4 +155,35 @@ mod tests {
let p = build_payload(&summary(2, 0), &[], false);
assert_eq!(p["themeColor"], "F39C12");
}
#[test]
fn report_url_adds_open_uri_action() {
let mut s = summary(1, 0);
s.report_url = Some("https://ci.example/run/77".to_string());
let p = build_payload(&s, &[], false);
assert_eq!(p["potentialAction"][0]["@type"], "OpenUri");
assert_eq!(p["potentialAction"][0]["targets"][0]["uri"], "https://ci.example/run/77");
}
#[test]
fn summary_mode_emits_suppression_notice() {
let mut s = summary(40, 0);
s.detail = crate::alerts::AlertDetail::Summary;
s.filtered_total = 40;
let rec = crate::alerts::make_test_record("kingfisher.aws.1", "fp-t");
let p = build_payload(&s, &[&rec], false);
let serialized = serde_json::to_string(&p).unwrap();
assert!(serialized.contains("per-finding detail suppressed"));
assert!(!serialized.contains("kingfisher.aws.1"));
}
#[test]
fn detail_mode_includes_fingerprint() {
let mut s = summary(1, 1);
s.filtered_total = 1;
let rec = crate::alerts::make_test_record("kingfisher.aws.1", "fp-teams-5");
let p = build_payload(&s, &[&rec], false);
let serialized = serde_json::to_string(&p).unwrap();
assert!(serialized.contains("fp:`fp-teams-5`"));
}
}

View file

@ -254,6 +254,27 @@ pub struct ScanArgs {
#[arg(global = true, long = "alert-include-secret", default_value_t = false)]
pub alert_include_secret: bool,
/// Pivot link rendered in the payload — typically the URL of the full
/// scan report (CI run, S3 object, SARIF in Code Scanning, etc.). When
/// present, every alert payload includes a "Full report" link, which is
/// the right place to send operators who hit the truncated finding cap.
/// Falls back to env var `KINGFISHER_ALERT_REPORT_URL` if unset.
#[arg(
global = true,
long = "alert-report-url",
value_name = "URL",
env = "KINGFISHER_ALERT_REPORT_URL"
)]
pub alert_report_url: Option<String>,
/// How much per-finding detail to include in alert payloads. `auto`
/// (default) shows up to 10 findings inline, but switches to a
/// summary-only payload once the per-sink filtered finding count exceeds
/// 25 — at that volume, chat detail blocks add noise and the operator
/// should be pivoting to the full report instead.
#[arg(global = true, long = "alert-detail", value_name = "MODE", default_value = "auto")]
pub alert_detail: crate::alerts::AlertDetail,
/// Per-webhook overrides loaded from `kingfisher.yaml`. Indexed in lockstep
/// with `alert_webhook` for the trailing config-sourced URLs. Not parsed
/// from the CLI; populated by `apply_config` in main.rs.
@ -270,6 +291,8 @@ pub struct ConfigWebhookOverride {
pub on: Option<crate::alerts::AlertOn>,
pub min_confidence: Option<ConfidenceLevel>,
pub include_secret: Option<bool>,
pub report_url: Option<String>,
pub detail: Option<crate::alerts::AlertDetail>,
}
/// Confidence levels for findings

View file

@ -12,6 +12,8 @@
//! on: findings # findings | always
//! min_confidence: medium # low | medium | high
//! include_secret: false
//! report_url: https://github.com/org/repo/actions/runs/123 # optional pivot link
//! detail: auto # summary | detail | auto
//! filters:
//! skip_words: ["EXAMPLE", "TEST"]
//! skip_regex: ['^DUMMY_']
@ -25,7 +27,7 @@
use anyhow::{Context, Result};
use serde::{Deserialize, Serialize};
use crate::alerts::{AlertFormat, AlertOn};
use crate::alerts::{AlertDetail, AlertFormat, AlertOn};
use crate::cli::commands::scan::ConfidenceLevel;
/// File name auto-discovered when the user does not pass `--config`.
@ -59,6 +61,14 @@ pub struct WebhookConfig {
pub min_confidence: Option<ConfigConfidence>,
#[serde(default)]
pub include_secret: Option<bool>,
/// Per-webhook override of the global `--alert-report-url`. Useful when
/// chat sinks should carry a pivot link but a SIEM-bound generic webhook
/// shouldn't.
#[serde(default)]
pub report_url: Option<String>,
/// Per-webhook override of the global `--alert-detail` mode.
#[serde(default)]
pub detail: Option<AlertDetail>,
}
#[derive(Debug, Copy, Clone, Serialize, Deserialize)]

View file

@ -1086,6 +1086,8 @@ pub(crate) fn create_minimal_scan_args() -> crate::cli::commands::scan::ScanArgs
alert_on: crate::alerts::AlertOn::Findings,
alert_min_confidence: ConfidenceLevel::Medium,
alert_include_secret: false,
alert_report_url: None,
alert_detail: crate::alerts::AlertDetail::Auto,
config_webhook_overrides: Vec::new(),
validation_timeout: 10,
validation_retries: 1,

View file

@ -340,11 +340,102 @@ fn apply_config(
on: w.on,
min_confidence: w.min_confidence.map(Into::into),
include_secret: w.include_secret,
report_url: w.report_url.clone(),
detail: w.detail,
},
);
}
}
/// Build a human-readable description of what the scan is targeting, for use
/// in alert payloads (`Target:` header). The previous implementation looked
/// only at the first local path, so scans that used git URLs, GitHub orgs,
/// S3 buckets, etc. produced an empty target line. This walks the input
/// specifiers in priority order and returns the first one that's set.
fn describe_scan_target(args: &InputSpecifierArgs) -> Option<String> {
fn join_brief<T: std::fmt::Display>(items: &[T], label: &str) -> String {
match items.len() {
0 => String::new(),
1 => items[0].to_string(),
n if n <= 3 => items.iter().map(|i| i.to_string()).collect::<Vec<_>>().join(", "),
n => format!("{} {label}", n),
}
}
// Local paths — the most common scan target.
if !args.path_inputs.is_empty() {
let s = if args.path_inputs.len() == 1 {
args.path_inputs[0].display().to_string()
} else if args.path_inputs.len() <= 3 {
args.path_inputs.iter().map(|p| p.display().to_string()).collect::<Vec<_>>().join(", ")
} else {
format!("{} paths", args.path_inputs.len())
};
return Some(s);
}
if !args.git_url.is_empty() {
return Some(join_brief(&args.git_url, "git URLs"));
}
if !args.github_user.is_empty() {
return Some(format!("github user: {}", join_brief(&args.github_user, "github users")));
}
if !args.github_organization.is_empty() {
return Some(format!(
"github org: {}",
join_brief(&args.github_organization, "github orgs")
));
}
if args.all_github_organizations {
return Some("all GitHub organizations".to_string());
}
if !args.gitlab_user.is_empty() {
return Some(format!("gitlab user: {}", join_brief(&args.gitlab_user, "gitlab users")));
}
if !args.gitlab_group.is_empty() {
return Some(format!("gitlab group: {}", join_brief(&args.gitlab_group, "gitlab groups")));
}
if !args.huggingface_user.is_empty() || !args.huggingface_organization.is_empty() {
return Some("huggingface".to_string());
}
if !args.gitea_user.is_empty() || !args.gitea_organization.is_empty() {
return Some("gitea".to_string());
}
if !args.bitbucket_user.is_empty() || !args.bitbucket_workspace.is_empty() {
return Some("bitbucket".to_string());
}
if !args.azure_organization.is_empty() {
return Some(format!("azure: {}", join_brief(&args.azure_organization, "azure orgs")));
}
if let Some(b) = &args.s3_bucket {
return Some(format!("s3://{}{}", b, args.s3_prefix.as_deref().unwrap_or("")));
}
if let Some(b) = &args.gcs_bucket {
return Some(format!("gs://{}{}", b, args.gcs_prefix.as_deref().unwrap_or("")));
}
if !args.docker_image.is_empty() {
return Some(format!("docker: {}", join_brief(&args.docker_image, "images")));
}
if let Some(u) = &args.jira_url {
return Some(format!("jira: {}", u));
}
if let Some(u) = &args.confluence_url {
return Some(format!("confluence: {}", u));
}
if args.slack_query.is_some() {
return Some("slack search".to_string());
}
if args.teams_query.is_some() {
return Some("teams search".to_string());
}
if !args.postman_workspaces.is_empty()
|| !args.postman_collections.is_empty()
|| args.postman_all
{
return Some("postman".to_string());
}
None
}
/// Build the resolved list of alert sinks from CLI flags + config overrides.
/// `scan_args.config_webhook_overrides` aligns with the trailing entries of
/// `scan_args.alert_webhook` (those that came from `kingfisher.yaml`); CLI URLs
@ -373,6 +464,11 @@ fn build_alert_sinks(
on: override_.on.unwrap_or(scan_args.alert_on),
min_confidence: override_.min_confidence.unwrap_or(scan_args.alert_min_confidence),
include_secret: override_.include_secret.unwrap_or(scan_args.alert_include_secret),
report_url: override_
.report_url
.clone()
.or_else(|| scan_args.alert_report_url.clone()),
detail: override_.detail.unwrap_or(scan_args.alert_detail),
}
})
.collect()
@ -554,11 +650,8 @@ async fn async_main(args: CommandLineArgs) -> Result<AsyncMainOutcome> {
};
match alert_reporter.build_finding_records(&scan_args) {
Ok(records) => {
let target = scan_args
.input_specifier_args
.path_inputs
.first()
.map(|p| p.display().to_string());
let target =
describe_scan_target(&scan_args.input_specifier_args);
let sinks = build_alert_sinks(&scan_args);
kingfisher::alerts::dispatch(&sinks, &records, target).await;
}
@ -879,6 +972,8 @@ fn create_default_scan_args() -> cli::commands::scan::ScanArgs {
alert_on: kingfisher::alerts::AlertOn::Findings,
alert_min_confidence: kingfisher::cli::commands::scan::ConfidenceLevel::Medium,
alert_include_secret: false,
alert_report_url: None,
alert_detail: kingfisher::alerts::AlertDetail::Auto,
config_webhook_overrides: Vec::new(),
}
}

View file

@ -1871,6 +1871,8 @@ mod tests {
alert_on: crate::alerts::AlertOn::Findings,
alert_min_confidence: cli::commands::scan::ConfidenceLevel::Medium,
alert_include_secret: false,
alert_report_url: None,
alert_detail: crate::alerts::AlertDetail::Auto,
config_webhook_overrides: Vec::new(),
}
}

View file

@ -237,6 +237,8 @@ mod tests {
alert_on: crate::alerts::AlertOn::Findings,
alert_min_confidence: cli::commands::scan::ConfidenceLevel::Medium,
alert_include_secret: false,
alert_report_url: None,
alert_detail: crate::alerts::AlertDetail::Auto,
config_webhook_overrides: Vec::new(),
}
}

View file

@ -191,6 +191,14 @@ fn run_skiplist(skip_regex: Vec<String>, skip_skipword: Vec<String>) -> Result<u
validation_timeout: 10,
full_validation_response: false,
max_validation_response_length: 2048,
alert_webhook: Vec::new(),
alert_format: None,
alert_on: kingfisher::alerts::AlertOn::Findings,
alert_min_confidence: ConfidenceLevel::Medium,
alert_include_secret: false,
alert_report_url: None,
alert_detail: kingfisher::alerts::AlertDetail::Auto,
config_webhook_overrides: Vec::new(),
};
let global_args = GlobalArgs {
@ -206,6 +214,7 @@ fn run_skiplist(skip_regex: Vec<String>, skip_skipword: Vec<String>) -> Result<u
allow_internal_ips: false,
endpoint: Vec::new(),
endpoint_config: None,
config: None,
};
let loaded = RuleLoader::from_rule_specifiers(&scan_args.rules).load(&scan_args)?;

View file

@ -176,6 +176,14 @@ fn test_bitbucket_remote_scan() -> Result<()> {
validation_timeout: 10,
full_validation_response: false,
max_validation_response_length: 2048,
alert_webhook: Vec::new(),
alert_format: None,
alert_on: kingfisher::alerts::AlertOn::Findings,
alert_min_confidence: ConfidenceLevel::Medium,
alert_include_secret: false,
alert_report_url: None,
alert_detail: kingfisher::alerts::AlertDetail::Auto,
config_webhook_overrides: Vec::new(),
};
let global_args = GlobalArgs {
@ -191,6 +199,7 @@ fn test_bitbucket_remote_scan() -> Result<()> {
allow_internal_ips: false,
endpoint: Vec::new(),
endpoint_config: None,
config: None,
};
let datastore = Arc::new(Mutex::new(FindingsStore::new(clone_dir)));

View file

@ -196,6 +196,14 @@ rules:
validation_timeout: 10,
full_validation_response: false,
max_validation_response_length: 2048,
alert_webhook: Vec::new(),
alert_format: None,
alert_on: kingfisher::alerts::AlertOn::Findings,
alert_min_confidence: ConfidenceLevel::Medium,
alert_include_secret: false,
alert_report_url: None,
alert_detail: kingfisher::alerts::AlertDetail::Auto,
config_webhook_overrides: Vec::new(),
};
let global_args = GlobalArgs {
@ -211,6 +219,7 @@ rules:
allow_internal_ips: false,
endpoint: Vec::new(),
endpoint_config: None,
config: None,
};
// ── load rules once ─────────────────────────────────────────────

View file

@ -183,6 +183,14 @@ fn test_github_remote_scan() -> Result<()> {
validation_timeout: 10,
full_validation_response: false,
max_validation_response_length: 2048,
alert_webhook: Vec::new(),
alert_format: None,
alert_on: kingfisher::alerts::AlertOn::Findings,
alert_min_confidence: ConfidenceLevel::Medium,
alert_include_secret: false,
alert_report_url: None,
alert_detail: kingfisher::alerts::AlertDetail::Auto,
config_webhook_overrides: Vec::new(),
};
// Create global arguments
let global_args = GlobalArgs {
@ -198,6 +206,7 @@ fn test_github_remote_scan() -> Result<()> {
allow_internal_ips: false,
endpoint: Vec::new(),
endpoint_config: None,
config: None,
};
// Create in-memory datastore
let datastore = Arc::new(Mutex::new(FindingsStore::new(clone_dir)));

View file

@ -181,6 +181,14 @@ fn test_gitlab_remote_scan() -> Result<()> {
validation_timeout: 10,
full_validation_response: false,
max_validation_response_length: 2048,
alert_webhook: Vec::new(),
alert_format: None,
alert_on: kingfisher::alerts::AlertOn::Findings,
alert_min_confidence: ConfidenceLevel::Medium,
alert_include_secret: false,
alert_report_url: None,
alert_detail: kingfisher::alerts::AlertDetail::Auto,
config_webhook_overrides: Vec::new(),
};
let global_args = GlobalArgs {
@ -196,6 +204,7 @@ fn test_gitlab_remote_scan() -> Result<()> {
allow_internal_ips: false,
endpoint: Vec::new(),
endpoint_config: None,
config: None,
};
let datastore = Arc::new(Mutex::new(FindingsStore::new(clone_dir)));
@ -366,6 +375,14 @@ fn test_gitlab_remote_scan_no_history() -> Result<()> {
validation_timeout: 10,
full_validation_response: false,
max_validation_response_length: 2048,
alert_webhook: Vec::new(),
alert_format: None,
alert_on: kingfisher::alerts::AlertOn::Findings,
alert_min_confidence: ConfidenceLevel::Medium,
alert_include_secret: false,
alert_report_url: None,
alert_detail: kingfisher::alerts::AlertDetail::Auto,
config_webhook_overrides: Vec::new(),
};
let global_args = GlobalArgs {
@ -381,6 +398,7 @@ fn test_gitlab_remote_scan_no_history() -> Result<()> {
allow_internal_ips: false,
endpoint: Vec::new(),
endpoint_config: None,
config: None,
};
let datastore = Arc::new(Mutex::new(FindingsStore::new(clone_dir)));

View file

@ -253,6 +253,14 @@ async fn test_scan_postman_all() -> Result<()> {
validation_timeout: 10,
full_validation_response: false,
max_validation_response_length: 2048,
alert_webhook: Vec::new(),
alert_format: None,
alert_on: kingfisher::alerts::AlertOn::Findings,
alert_min_confidence: ConfidenceLevel::Medium,
alert_include_secret: false,
alert_report_url: None,
alert_detail: kingfisher::alerts::AlertDetail::Auto,
config_webhook_overrides: Vec::new(),
};
let global_args = GlobalArgs {
@ -268,6 +276,7 @@ async fn test_scan_postman_all() -> Result<()> {
allow_internal_ips: false,
endpoint: Vec::new(),
endpoint_config: None,
config: None,
};
let loaded = RuleLoader::from_rule_specifiers(&scan_args.rules).load(&scan_args)?;

View file

@ -159,6 +159,14 @@ async fn test_redact_hashes_finding_values() -> Result<()> {
validation_timeout: 10,
full_validation_response: false,
max_validation_response_length: 2048,
alert_webhook: Vec::new(),
alert_format: None,
alert_on: kingfisher::alerts::AlertOn::Findings,
alert_min_confidence: ConfidenceLevel::Medium,
alert_include_secret: false,
alert_report_url: None,
alert_detail: kingfisher::alerts::AlertDetail::Auto,
config_webhook_overrides: Vec::new(),
};
let global_args = GlobalArgs {
@ -174,6 +182,7 @@ async fn test_redact_hashes_finding_values() -> Result<()> {
allow_internal_ips: false,
endpoint: Vec::new(),
endpoint_config: None,
config: None,
};
let loaded = RuleLoader::from_rule_specifiers(&scan_args.rules).load(&scan_args)?;

View file

@ -164,6 +164,14 @@ impl TestContext {
validation_timeout: 10,
full_validation_response: false,
max_validation_response_length: 2048,
alert_webhook: Vec::new(),
alert_format: None,
alert_on: kingfisher::alerts::AlertOn::Findings,
alert_min_confidence: ConfidenceLevel::Medium,
alert_include_secret: false,
alert_report_url: None,
alert_detail: kingfisher::alerts::AlertDetail::Auto,
config_webhook_overrides: Vec::new(),
};
let loaded = RuleLoader::from_rule_specifiers(&scan_args.rules).load(&scan_args)?;
@ -332,6 +340,14 @@ async fn test_scan_slack_messages() -> Result<()> {
validation_timeout: 10,
full_validation_response: false,
max_validation_response_length: 2048,
alert_webhook: Vec::new(),
alert_format: None,
alert_on: kingfisher::alerts::AlertOn::Findings,
alert_min_confidence: ConfidenceLevel::Medium,
alert_include_secret: false,
alert_report_url: None,
alert_detail: kingfisher::alerts::AlertDetail::Auto,
config_webhook_overrides: Vec::new(),
};
let global_args = GlobalArgs {
@ -347,6 +363,7 @@ async fn test_scan_slack_messages() -> Result<()> {
allow_internal_ips: false,
endpoint: Vec::new(),
endpoint_config: None,
config: None,
};
let datastore = Arc::new(Mutex::new(FindingsStore::new(clone_dir)));

View file

@ -200,6 +200,14 @@ async fn test_scan_teams_messages() -> Result<()> {
validation_timeout: 10,
full_validation_response: false,
max_validation_response_length: 2048,
alert_webhook: Vec::new(),
alert_format: None,
alert_on: kingfisher::alerts::AlertOn::Findings,
alert_min_confidence: ConfidenceLevel::Medium,
alert_include_secret: false,
alert_report_url: None,
alert_detail: kingfisher::alerts::AlertDetail::Auto,
config_webhook_overrides: Vec::new(),
};
let global_args = GlobalArgs {
@ -215,6 +223,7 @@ async fn test_scan_teams_messages() -> Result<()> {
allow_internal_ips: false,
endpoint: Vec::new(),
endpoint_config: None,
config: None,
};
let loaded = RuleLoader::from_rule_specifiers(&scan_args.rules).load(&scan_args)?;

View file

@ -239,6 +239,14 @@ async fn test_validation_cache_and_depvars() -> Result<()> {
validation_timeout: 10,
full_validation_response: false,
max_validation_response_length: 2048,
alert_webhook: Vec::new(),
alert_format: None,
alert_on: kingfisher::alerts::AlertOn::Findings,
alert_min_confidence: ConfidenceLevel::Medium,
alert_include_secret: false,
alert_report_url: None,
alert_detail: kingfisher::alerts::AlertDetail::Auto,
config_webhook_overrides: Vec::new(),
};
/* --------------------------------------------------------- *
@ -272,6 +280,7 @@ async fn test_validation_cache_and_depvars() -> Result<()> {
allow_internal_ips: true,
endpoint: Vec::new(),
endpoint_config: None,
config: None,
};
let update_status = UpdateStatus::default();

View file

@ -182,6 +182,14 @@ impl TestContext {
validation_timeout: 10,
full_validation_response: false,
max_validation_response_length: 2048,
alert_webhook: Vec::new(),
alert_format: None,
alert_on: kingfisher::alerts::AlertOn::Findings,
alert_min_confidence: ConfidenceLevel::Medium,
alert_include_secret: false,
alert_report_url: None,
alert_detail: kingfisher::alerts::AlertDetail::Auto,
config_webhook_overrides: Vec::new(),
};
let loaded = RuleLoader::from_rule_specifiers(&scan_args.rules)
@ -336,6 +344,14 @@ impl TestContext {
validation_timeout: 10,
full_validation_response: false,
max_validation_response_length: 2048,
alert_webhook: Vec::new(),
alert_format: None,
alert_on: kingfisher::alerts::AlertOn::Findings,
alert_min_confidence: ConfidenceLevel::Medium,
alert_include_secret: false,
alert_report_url: None,
alert_detail: kingfisher::alerts::AlertDetail::Auto,
config_webhook_overrides: Vec::new(),
};
let global_args = GlobalArgs {
@ -351,6 +367,7 @@ impl TestContext {
allow_internal_ips: false,
endpoint: Vec::new(),
endpoint_config: None,
config: None,
};
let datastore = Arc::new(Mutex::new(FindingsStore::new(clone_dir)));