diff --git a/.github/workflows/pypi.yml b/.github/workflows/pypi.yml index f6ff864..0f512d5 100644 --- a/.github/workflows/pypi.yml +++ b/.github/workflows/pypi.yml @@ -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: diff --git a/CHANGELOG.md b/CHANGELOG.md index 75a4ca8..c1f5344 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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_.musllinux_1_2_` (instead of `musllinux_1_2_` 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 ` as an alias for the `kingfisher access-map ` 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] diff --git a/docs-site/docs/blog/posts/2026-05-04-real-time-secret-alerts-webhooks.md b/docs-site/docs/blog/posts/2026-05-04-real-time-secret-alerts-webhooks.md new file mode 100644 index 0000000..1b0c489 --- /dev/null +++ b/docs-site/docs/blog/posts/2026-05-04-real-time-secret-alerts-webhooks.md @@ -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`. + + + +## 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 `` + 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: + +- 0–25 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 — (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). diff --git a/docs-site/docs/changelog.md b/docs-site/docs/changelog.md index 557fa19..caf7a93 100644 --- a/docs-site/docs/changelog.md +++ b/docs-site/docs/changelog.md @@ -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_.musllinux_1_2_` (instead of `musllinux_1_2_` 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 ` as an alias for the `kingfisher access-map ` 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. diff --git a/docs/ALERTS.md b/docs/ALERTS.md index 32e13aa..80651c7 100644 --- a/docs/ALERTS.md +++ b/docs/ALERTS.md @@ -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. diff --git a/docs/CONFIG.md b/docs/CONFIG.md index fb17e71..4877dde 100644 --- a/docs/CONFIG.md +++ b/docs/CONFIG.md @@ -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: diff --git a/pypi/hatch_build.py b/pypi/hatch_build.py index d1e07e4..8495d3c 100644 --- a/pypi/hatch_build.py +++ b/pypi/hatch_build.py @@ -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 " + "instead of `python -m build` directly." + ) + build_data["tag"] = wheel_tag + build_data["pure_python"] = False diff --git a/src/alerts/discord.rs b/src/alerts/discord.rs index 7d58f5d..0304e0f 100644 --- a/src/alerts/discord.rs +++ b/src/alerts/discord.rs @@ -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( "".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`")); + } } diff --git a/src/alerts/generic.rs b/src/alerts/generic.rs index 4daa2d7..433ec14 100644 --- a/src/alerts/generic.rs +++ b/src/alerts/generic.rs @@ -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 = 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::>(), + "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::>(), - "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"); + } } diff --git a/src/alerts/googlechat.rs b/src/alerts/googlechat.rs index 549e071..4b5d665 100644 --- a/src/alerts/googlechat.rs +++ b/src/alerts/googlechat.rs @@ -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( "".to_string() }; detail.push_str(&format!( - "• {} at {}:{}{} (validation: {})
", - f.rule.id, f.finding.path, f.finding.line, snippet, f.finding.validation.status, + "• {} at {}:{}{} (validation: {}) — fp:{}
", + 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!( + "{} findings — per-finding detail suppressed (summary mode). See full report for specifics.", + 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 kingfisher.aws.1 + // 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")); + } } diff --git a/src/alerts/mattermost.rs b/src/alerts/mattermost.rs index 013aedc..8724291 100644 --- a/src/alerts/mattermost.rs +++ b/src/alerts/mattermost.rs @@ -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( "".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`")); + } } diff --git a/src/alerts/mod.rs b/src/alerts/mod.rs index 5d912a4..5566c2c 100644 --- a/src/alerts/mod.rs +++ b/src/alerts/mod.rs @@ -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, + /// 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, + /// Pivot link, copied from the per-sink configuration. `None` → no link + /// is rendered. + #[serde(skip_serializing_if = "Option::is_none")] + pub report_url: Option, + /// 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. + } } diff --git a/src/alerts/slack.rs b/src/alerts/slack.rs index 41eaa66..2bcbc31 100644 --- a/src/alerts/slack.rs +++ b/src/alerts/slack.rs @@ -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 = Vec::with_capacity(take); for f in findings.iter().take(take) { @@ -65,12 +65,13 @@ pub fn build_payload( "".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"); + } } diff --git a/src/alerts/teams.rs b/src/alerts/teams.rs index 40550c7..c695bbe 100644 --- a/src/alerts/teams.rs +++ b/src/alerts/teams.rs @@ -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( "".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`")); + } } diff --git a/src/cli/commands/scan.rs b/src/cli/commands/scan.rs index 6985f9f..721cbed 100644 --- a/src/cli/commands/scan.rs +++ b/src/cli/commands/scan.rs @@ -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, + + /// 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, pub min_confidence: Option, pub include_secret: Option, + pub report_url: Option, + pub detail: Option, } /// Confidence levels for findings diff --git a/src/cli/config.rs b/src/cli/config.rs index 0727815..a90a194 100644 --- a/src/cli/config.rs +++ b/src/cli/config.rs @@ -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, #[serde(default)] pub include_secret: Option, + /// 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, + /// Per-webhook override of the global `--alert-detail` mode. + #[serde(default)] + pub detail: Option, } #[derive(Debug, Copy, Clone, Serialize, Deserialize)] diff --git a/src/direct_validate.rs b/src/direct_validate.rs index b40cd28..9e377f9 100644 --- a/src/direct_validate.rs +++ b/src/direct_validate.rs @@ -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, diff --git a/src/main.rs b/src/main.rs index ecac0f7..00ec9ce 100644 --- a/src/main.rs +++ b/src/main.rs @@ -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 { + fn join_brief(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::>().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::>().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 { }; 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(), } } diff --git a/src/reporter.rs b/src/reporter.rs index 8631473..0b6b280 100644 --- a/src/reporter.rs +++ b/src/reporter.rs @@ -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(), } } diff --git a/src/reporter/json_format.rs b/src/reporter/json_format.rs index 7a7cf89..b650b90 100644 --- a/src/reporter/json_format.rs +++ b/src/reporter/json_format.rs @@ -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(), } } diff --git a/tests/int_allowlist.rs b/tests/int_allowlist.rs index 9217d94..9ddd1d3 100644 --- a/tests/int_allowlist.rs +++ b/tests/int_allowlist.rs @@ -191,6 +191,14 @@ fn run_skiplist(skip_regex: Vec, skip_skipword: Vec) -> Result, skip_skipword: Vec) -> Result 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))); diff --git a/tests/int_dedup.rs b/tests/int_dedup.rs index ef4a059..fdc7270 100644 --- a/tests/int_dedup.rs +++ b/tests/int_dedup.rs @@ -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 ───────────────────────────────────────────── diff --git a/tests/int_github.rs b/tests/int_github.rs index 09f85ae..ca938c2 100644 --- a/tests/int_github.rs +++ b/tests/int_github.rs @@ -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))); diff --git a/tests/int_gitlab.rs b/tests/int_gitlab.rs index b2cfead..d39572e 100644 --- a/tests/int_gitlab.rs +++ b/tests/int_gitlab.rs @@ -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))); diff --git a/tests/int_postman.rs b/tests/int_postman.rs index f9fe833..2909f6c 100644 --- a/tests/int_postman.rs +++ b/tests/int_postman.rs @@ -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)?; diff --git a/tests/int_redact.rs b/tests/int_redact.rs index a7111c7..9a7867e 100644 --- a/tests/int_redact.rs +++ b/tests/int_redact.rs @@ -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)?; diff --git a/tests/int_slack.rs b/tests/int_slack.rs index 02dbaa6..083a2b7 100644 --- a/tests/int_slack.rs +++ b/tests/int_slack.rs @@ -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))); diff --git a/tests/int_teams.rs b/tests/int_teams.rs index 7e45cd0..6996c77 100644 --- a/tests/int_teams.rs +++ b/tests/int_teams.rs @@ -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)?; diff --git a/tests/int_validation_cache.rs b/tests/int_validation_cache.rs index f456c1c..2977283 100644 --- a/tests/int_validation_cache.rs +++ b/tests/int_validation_cache.rs @@ -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(); diff --git a/tests/int_vulnerable_files.rs b/tests/int_vulnerable_files.rs index 9b83b68..491b3eb 100644 --- a/tests/int_vulnerable_files.rs +++ b/tests/int_vulnerable_files.rs @@ -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)));