forked from mirrors/kingfisher
preparing for v1.99.0
This commit is contained in:
parent
0e1fe0cede
commit
f6e05f0211
31 changed files with 1090 additions and 49 deletions
16
.github/workflows/pypi.yml
vendored
16
.github/workflows/pypi.yml
vendored
|
|
@ -113,12 +113,12 @@ jobs:
|
||||||
scripts/build-pypi-wheel.sh \
|
scripts/build-pypi-wheel.sh \
|
||||||
--binary "$linux_x64" \
|
--binary "$linux_x64" \
|
||||||
--version "$version" \
|
--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 \
|
scripts/build-pypi-wheel.sh \
|
||||||
--binary "$linux_arm64" \
|
--binary "$linux_arm64" \
|
||||||
--version "$version" \
|
--version "$version" \
|
||||||
--plat-name musllinux_1_2_aarch64
|
--plat-name manylinux_2_17_aarch64.musllinux_1_2_aarch64
|
||||||
|
|
||||||
scripts/build-pypi-wheel.sh \
|
scripts/build-pypi-wheel.sh \
|
||||||
--binary "$mac_x64" \
|
--binary "$mac_x64" \
|
||||||
|
|
@ -140,6 +140,18 @@ jobs:
|
||||||
--version "$version" \
|
--version "$version" \
|
||||||
--plat-name win_arm64
|
--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)
|
- name: Publish to PyPI (Trusted Publishing)
|
||||||
uses: pypa/gh-action-pypi-publish@cef221092ed1bacb1cc03d23a2d87d1d172e277b # release/v1
|
uses: pypa/gh-action-pypi-publish@cef221092ed1bacb1cc03d23a2d87d1d172e277b # release/v1
|
||||||
with:
|
with:
|
||||||
|
|
|
||||||
|
|
@ -3,10 +3,16 @@
|
||||||
All notable changes to this project will be documented in this file.
|
All notable changes to this project will be documented in this file.
|
||||||
|
|
||||||
## [v1.99.0]
|
## [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*.
|
- `--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.
|
- `--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`).
|
- **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.
|
- 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`.
|
- **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]
|
## [v1.98.0]
|
||||||
|
|
|
||||||
|
|
@ -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:
|
||||||
|
|
||||||
|
- 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 — <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).
|
||||||
|
|
@ -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.
|
All notable changes to this project will be documented in this file.
|
||||||
|
|
||||||
## [unreleased v1.99.0]
|
## [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*.
|
- `--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.
|
- `--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`).
|
- **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.
|
- 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]
|
## [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.
|
- 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.
|
||||||
|
|
|
||||||
|
|
@ -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-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-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-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
|
Webhook URLs are sensitive: the host/path/query are redacted in logs. Pass them
|
||||||
via environment variables (`$SLACK_SECURITY_WEBHOOK`) or CI secrets, never
|
via environment variables (`$SLACK_SECURITY_WEBHOOK`) or CI secrets, never
|
||||||
inline in committed files.
|
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
|
## Payload shapes
|
||||||
|
|
||||||
### Slack (Block Kit)
|
### Slack (Block Kit)
|
||||||
|
|
@ -144,6 +181,14 @@ alerts:
|
||||||
- url: https://chat.googleapis.com/v1/spaces/AAA/messages?key=k&token=t
|
- url: https://chat.googleapis.com/v1/spaces/AAA/messages?key=k&token=t
|
||||||
format: googlechat
|
format: googlechat
|
||||||
on: always
|
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.
|
See [`docs/CONFIG.md`](CONFIG.md) for the full config schema.
|
||||||
|
|
|
||||||
|
|
@ -21,6 +21,8 @@ alerts:
|
||||||
on: findings # findings | always
|
on: findings # findings | always
|
||||||
min_confidence: medium # low | medium | high
|
min_confidence: medium # low | medium | high
|
||||||
include_secret: false # default false
|
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:
|
filters:
|
||||||
skip_words:
|
skip_words:
|
||||||
|
|
|
||||||
|
|
@ -7,6 +7,11 @@ from hatchling.builders.hooks.plugin.interface import BuildHookInterface
|
||||||
class CustomBuildHook(BuildHookInterface):
|
class CustomBuildHook(BuildHookInterface):
|
||||||
def initialize(self, version: str, build_data: Dict[str, Any]) -> None:
|
def initialize(self, version: str, build_data: Dict[str, Any]) -> None:
|
||||||
wheel_tag = os.environ.get("KINGFISHER_PYPI_WHEEL_TAG")
|
wheel_tag = os.environ.get("KINGFISHER_PYPI_WHEEL_TAG")
|
||||||
if wheel_tag:
|
if not wheel_tag:
|
||||||
build_data["tag"] = wheel_tag
|
raise RuntimeError(
|
||||||
build_data["pure_python"] = False
|
"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
|
||||||
|
|
|
||||||
|
|
@ -7,7 +7,7 @@
|
||||||
|
|
||||||
use serde_json::{Value, json};
|
use serde_json::{Value, json};
|
||||||
|
|
||||||
use crate::alerts::AlertSummary;
|
use crate::alerts::{AlertDetail, AlertSummary};
|
||||||
use crate::reporter::FindingReporterRecord;
|
use crate::reporter::FindingReporterRecord;
|
||||||
|
|
||||||
const PER_FINDING_LIMIT: usize = 10;
|
const PER_FINDING_LIMIT: usize = 10;
|
||||||
|
|
@ -77,7 +77,7 @@ pub fn build_payload(
|
||||||
"footer": { "text": format!("kingfisher v{}", summary.kingfisher_version) },
|
"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 take = findings.len().min(PER_FINDING_LIMIT);
|
||||||
let mut detail = String::new();
|
let mut detail = String::new();
|
||||||
for f in findings.iter().take(take) {
|
for f in findings.iter().take(take) {
|
||||||
|
|
@ -87,14 +87,38 @@ pub fn build_payload(
|
||||||
"<redacted>".to_string()
|
"<redacted>".to_string()
|
||||||
};
|
};
|
||||||
detail.push_str(&format!(
|
detail.push_str(&format!(
|
||||||
"• `{}` at `{}:{}` — `{}` (validation: {})\n",
|
"• `{}` at `{}:{}` — `{}` (validation: {}) — fp:`{}`\n",
|
||||||
f.rule.id, f.finding.path, f.finding.line, snippet, f.finding.validation.status,
|
f.rule.id,
|
||||||
|
f.finding.path,
|
||||||
|
f.finding.line,
|
||||||
|
snippet,
|
||||||
|
f.finding.validation.status,
|
||||||
|
f.finding.fingerprint,
|
||||||
));
|
));
|
||||||
}
|
}
|
||||||
if findings.len() > take {
|
if findings.len() > take {
|
||||||
detail.push_str(&format!("…{} more findings omitted", findings.len() - take));
|
detail.push_str(&format!("…{} more findings omitted", findings.len() - take));
|
||||||
}
|
}
|
||||||
embed["description"] = Value::String(truncate(&detail, DESCRIPTION_SOFT_LIMIT));
|
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] })
|
json!({ "embeds": [embed] })
|
||||||
|
|
@ -125,6 +149,9 @@ mod tests {
|
||||||
by_rule: vec![],
|
by_rule: vec![],
|
||||||
kingfisher_version: "test".to_string(),
|
kingfisher_version: "test".to_string(),
|
||||||
target: None,
|
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);
|
let p = build_payload(&summary(0, 0), &[], false);
|
||||||
assert!(p["embeds"][0].get("description").is_none());
|
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`"));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -6,7 +6,7 @@
|
||||||
|
|
||||||
use serde_json::{Value, json};
|
use serde_json::{Value, json};
|
||||||
|
|
||||||
use crate::alerts::AlertSummary;
|
use crate::alerts::{AlertDetail, AlertSummary};
|
||||||
use crate::reporter::FindingReporterRecord;
|
use crate::reporter::FindingReporterRecord;
|
||||||
|
|
||||||
const PER_FINDING_LIMIT: usize = 200;
|
const PER_FINDING_LIMIT: usize = 200;
|
||||||
|
|
@ -17,7 +17,14 @@ pub fn build_payload(
|
||||||
findings: &[&FindingReporterRecord],
|
findings: &[&FindingReporterRecord],
|
||||||
include_secret: bool,
|
include_secret: bool,
|
||||||
) -> Value {
|
) -> 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
|
let included: Vec<Value> = findings
|
||||||
.iter()
|
.iter()
|
||||||
.take(take)
|
.take(take)
|
||||||
|
|
@ -32,17 +39,22 @@ pub fn build_payload(
|
||||||
})
|
})
|
||||||
.collect();
|
.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!({
|
json!({
|
||||||
"schema_version": SCHEMA_VERSION,
|
"schema_version": SCHEMA_VERSION,
|
||||||
"kingfisher_version": summary.kingfisher_version,
|
"kingfisher_version": summary.kingfisher_version,
|
||||||
"summary": {
|
"summary": summary_obj,
|
||||||
"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,
|
|
||||||
},
|
|
||||||
"findings": included,
|
"findings": included,
|
||||||
"findings_omitted": findings.len().saturating_sub(take),
|
"findings_omitted": findings.len().saturating_sub(take),
|
||||||
})
|
})
|
||||||
|
|
@ -61,6 +73,9 @@ mod tests {
|
||||||
by_rule: vec![],
|
by_rule: vec![],
|
||||||
kingfisher_version: "test".to_string(),
|
kingfisher_version: "test".to_string(),
|
||||||
target: None,
|
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"].as_array().unwrap().len(), 0);
|
||||||
assert_eq!(p["findings_omitted"], 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");
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -9,7 +9,7 @@
|
||||||
|
|
||||||
use serde_json::{Value, json};
|
use serde_json::{Value, json};
|
||||||
|
|
||||||
use crate::alerts::AlertSummary;
|
use crate::alerts::{AlertDetail, AlertSummary};
|
||||||
use crate::reporter::FindingReporterRecord;
|
use crate::reporter::FindingReporterRecord;
|
||||||
|
|
||||||
const PER_FINDING_LIMIT: usize = 10;
|
const PER_FINDING_LIMIT: usize = 10;
|
||||||
|
|
@ -60,7 +60,7 @@ pub fn build_payload(
|
||||||
"widgets": summary_widgets,
|
"widgets": summary_widgets,
|
||||||
})];
|
})];
|
||||||
|
|
||||||
if !findings.is_empty() {
|
if !findings.is_empty() && summary.detail == AlertDetail::Detail {
|
||||||
let take = findings.len().min(PER_FINDING_LIMIT);
|
let take = findings.len().min(PER_FINDING_LIMIT);
|
||||||
let mut detail = String::new();
|
let mut detail = String::new();
|
||||||
for f in findings.iter().take(take) {
|
for f in findings.iter().take(take) {
|
||||||
|
|
@ -70,8 +70,13 @@ pub fn build_payload(
|
||||||
"<redacted>".to_string()
|
"<redacted>".to_string()
|
||||||
};
|
};
|
||||||
detail.push_str(&format!(
|
detail.push_str(&format!(
|
||||||
"• <b>{}</b> at <code>{}:{}</code> — <code>{}</code> (validation: {})<br>",
|
"• <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.rule.id,
|
||||||
|
f.finding.path,
|
||||||
|
f.finding.line,
|
||||||
|
snippet,
|
||||||
|
f.finding.validation.status,
|
||||||
|
f.finding.fingerprint,
|
||||||
));
|
));
|
||||||
}
|
}
|
||||||
if findings.len() > take {
|
if findings.len() > take {
|
||||||
|
|
@ -81,6 +86,29 @@ pub fn build_payload(
|
||||||
"header": "Findings",
|
"header": "Findings",
|
||||||
"widgets": [{ "textParagraph": { "text": detail } }],
|
"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!({
|
json!({
|
||||||
|
|
@ -122,6 +150,9 @@ mod tests {
|
||||||
by_rule: vec![],
|
by_rule: vec![],
|
||||||
kingfisher_version: "test".to_string(),
|
kingfisher_version: "test".to_string(),
|
||||||
target: None,
|
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);
|
let p = build_payload(&summary(0, 0), &[], false);
|
||||||
assert_eq!(p["cardsV2"][0]["card"]["header"]["subtitle"], "kingfisher vtest");
|
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"));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -13,7 +13,7 @@
|
||||||
|
|
||||||
use serde_json::{Value, json};
|
use serde_json::{Value, json};
|
||||||
|
|
||||||
use crate::alerts::AlertSummary;
|
use crate::alerts::{AlertDetail, AlertSummary};
|
||||||
use crate::reporter::FindingReporterRecord;
|
use crate::reporter::FindingReporterRecord;
|
||||||
|
|
||||||
const PER_FINDING_LIMIT: usize = 10;
|
const PER_FINDING_LIMIT: usize = 10;
|
||||||
|
|
@ -73,7 +73,7 @@ pub fn build_payload(
|
||||||
"footer": format!("kingfisher v{}", summary.kingfisher_version),
|
"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 take = findings.len().min(PER_FINDING_LIMIT);
|
||||||
let mut details = String::new();
|
let mut details = String::new();
|
||||||
for f in findings.iter().take(take) {
|
for f in findings.iter().take(take) {
|
||||||
|
|
@ -83,14 +83,37 @@ pub fn build_payload(
|
||||||
"<redacted>".to_string()
|
"<redacted>".to_string()
|
||||||
};
|
};
|
||||||
details.push_str(&format!(
|
details.push_str(&format!(
|
||||||
"- **{}** at `{}:{}` — `{}` (validation: {})\n",
|
"- **{}** at `{}:{}` — `{}` (validation: {}) — fp:`{}`\n",
|
||||||
f.rule.id, f.finding.path, f.finding.line, snippet, f.finding.validation.status,
|
f.rule.id,
|
||||||
|
f.finding.path,
|
||||||
|
f.finding.line,
|
||||||
|
snippet,
|
||||||
|
f.finding.validation.status,
|
||||||
|
f.finding.fingerprint,
|
||||||
));
|
));
|
||||||
}
|
}
|
||||||
if findings.len() > take {
|
if findings.len() > take {
|
||||||
details.push_str(&format!("_…{} more findings omitted_\n", findings.len() - take));
|
details.push_str(&format!("_…{} more findings omitted_\n", findings.len() - take));
|
||||||
}
|
}
|
||||||
attachment["text"] = Value::String(details);
|
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!({
|
json!({
|
||||||
|
|
@ -124,6 +147,9 @@ mod tests {
|
||||||
by_rule: vec![],
|
by_rule: vec![],
|
||||||
kingfisher_version: "test".to_string(),
|
kingfisher_version: "test".to_string(),
|
||||||
target: None,
|
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);
|
let p = build_payload(&summary(0, 0), &[], false);
|
||||||
assert_eq!(p["attachments"][0]["footer"], "kingfisher vtest");
|
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`"));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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.
|
/// Webhook payload format / target.
|
||||||
#[derive(Copy, Clone, Debug, PartialEq, Eq, Serialize, Deserialize, clap::ValueEnum)]
|
#[derive(Copy, Clone, Debug, PartialEq, Eq, Serialize, Deserialize, clap::ValueEnum)]
|
||||||
#[serde(rename_all = "lowercase")]
|
#[serde(rename_all = "lowercase")]
|
||||||
|
|
@ -86,9 +109,21 @@ pub struct AlertSink {
|
||||||
pub on: AlertOn,
|
pub on: AlertOn,
|
||||||
pub min_confidence: ConfidenceLevel,
|
pub min_confidence: ConfidenceLevel,
|
||||||
pub include_secret: bool,
|
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.
|
/// 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)]
|
#[derive(Clone, Debug, Serialize)]
|
||||||
pub struct AlertSummary {
|
pub struct AlertSummary {
|
||||||
pub total: usize,
|
pub total: usize,
|
||||||
|
|
@ -98,6 +133,16 @@ pub struct AlertSummary {
|
||||||
pub by_rule: Vec<(String, usize)>,
|
pub by_rule: Vec<(String, usize)>,
|
||||||
pub kingfisher_version: String,
|
pub kingfisher_version: String,
|
||||||
pub target: Option<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 {
|
impl AlertSummary {
|
||||||
|
|
@ -127,6 +172,11 @@ impl AlertSummary {
|
||||||
by_rule,
|
by_rule,
|
||||||
kingfisher_version: env!("CARGO_PKG_VERSION").to_string(),
|
kingfisher_version: env!("CARGO_PKG_VERSION").to_string(),
|
||||||
target,
|
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!(
|
debug!(
|
||||||
"alert dispatch: total={} active={} inactive={} unknown={} sinks={}",
|
"alert dispatch: total={} active={} inactive={} unknown={} sinks={}",
|
||||||
summary.total,
|
base_summary.total,
|
||||||
summary.active,
|
base_summary.active,
|
||||||
summary.inactive,
|
base_summary.inactive,
|
||||||
summary.unknown,
|
base_summary.unknown,
|
||||||
sinks.len()
|
sinks.len()
|
||||||
);
|
);
|
||||||
|
|
||||||
for sink in sinks {
|
for sink in sinks {
|
||||||
if matches!(sink.on, AlertOn::Findings) && summary.total == 0 {
|
if matches!(sink.on, AlertOn::Findings) && base_summary.total == 0 {
|
||||||
debug!(
|
debug!(
|
||||||
"alert dispatch: skipping {} (on=findings, no findings)",
|
"alert dispatch: skipping {} (on=findings, no findings)",
|
||||||
redact_webhook(&sink.url)
|
redact_webhook(&sink.url)
|
||||||
|
|
@ -199,6 +249,23 @@ pub async fn dispatch(
|
||||||
.filter(|f| matches_min_confidence(&f.finding.confidence, sink.min_confidence))
|
.filter(|f| matches_min_confidence(&f.finding.confidence, sink.min_confidence))
|
||||||
.collect();
|
.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 {
|
let payload = match sink.format {
|
||||||
AlertFormat::Slack => slack::build_payload(&summary, &filtered, sink.include_secret),
|
AlertFormat::Slack => slack::build_payload(&summary, &filtered, sink.include_secret),
|
||||||
AlertFormat::Teams => teams::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(())
|
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)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
|
|
@ -329,4 +430,13 @@ mod tests {
|
||||||
AlertFormat::Generic
|
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.
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -2,7 +2,7 @@
|
||||||
|
|
||||||
use serde_json::{Value, json};
|
use serde_json::{Value, json};
|
||||||
|
|
||||||
use crate::alerts::AlertSummary;
|
use crate::alerts::{AlertDetail, AlertSummary};
|
||||||
use crate::reporter::FindingReporterRecord;
|
use crate::reporter::FindingReporterRecord;
|
||||||
|
|
||||||
const PER_FINDING_LIMIT: usize = 10;
|
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 take = findings.len().min(PER_FINDING_LIMIT);
|
||||||
let mut detail_lines: Vec<String> = Vec::with_capacity(take);
|
let mut detail_lines: Vec<String> = Vec::with_capacity(take);
|
||||||
for f in findings.iter().take(take) {
|
for f in findings.iter().take(take) {
|
||||||
|
|
@ -65,12 +65,13 @@ pub fn build_payload(
|
||||||
"<redacted>".to_string()
|
"<redacted>".to_string()
|
||||||
};
|
};
|
||||||
detail_lines.push(format!(
|
detail_lines.push(format!(
|
||||||
"• `{}` at `{}:{}` — {} (validation: {})",
|
"• `{}` at `{}:{}` — {} (validation: {}) — fp:`{}`",
|
||||||
escape_mrkdwn(&f.rule.id),
|
escape_mrkdwn(&f.rule.id),
|
||||||
escape_mrkdwn(&f.finding.path),
|
escape_mrkdwn(&f.finding.path),
|
||||||
f.finding.line,
|
f.finding.line,
|
||||||
snippet,
|
snippet,
|
||||||
escape_mrkdwn(&f.finding.validation.status)
|
escape_mrkdwn(&f.finding.validation.status),
|
||||||
|
escape_mrkdwn(&f.finding.fingerprint),
|
||||||
));
|
));
|
||||||
}
|
}
|
||||||
if findings.len() > take {
|
if findings.len() > take {
|
||||||
|
|
@ -80,6 +81,30 @@ pub fn build_payload(
|
||||||
"type": "section",
|
"type": "section",
|
||||||
"text": { "type": "mrkdwn", "text": detail_lines.join("\n") }
|
"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!({
|
blocks.push(json!({
|
||||||
|
|
@ -119,6 +144,9 @@ mod tests {
|
||||||
by_rule: vec![],
|
by_rule: vec![],
|
||||||
kingfisher_version: "test".to_string(),
|
kingfisher_version: "test".to_string(),
|
||||||
target: None,
|
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)],
|
by_rule: vec![("kingfisher.aws.1".into(), 1)],
|
||||||
kingfisher_version: "test".to_string(),
|
kingfisher_version: "test".to_string(),
|
||||||
target: None,
|
target: None,
|
||||||
|
report_url: None,
|
||||||
|
detail: crate::alerts::AlertDetail::Detail,
|
||||||
|
filtered_total: 1,
|
||||||
};
|
};
|
||||||
let p = build_payload(&summary, &[], false);
|
let p = build_payload(&summary, &[], false);
|
||||||
let header = p["blocks"][0]["text"]["text"].as_str().unwrap();
|
let header = p["blocks"][0]["text"]["text"].as_str().unwrap();
|
||||||
assert!(header.contains("1 finding"));
|
assert!(header.contains("1 finding"));
|
||||||
assert!(!header.contains("findings"), "should be singular");
|
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");
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -7,7 +7,7 @@
|
||||||
|
|
||||||
use serde_json::{Value, json};
|
use serde_json::{Value, json};
|
||||||
|
|
||||||
use crate::alerts::AlertSummary;
|
use crate::alerts::{AlertDetail, AlertSummary};
|
||||||
use crate::reporter::FindingReporterRecord;
|
use crate::reporter::FindingReporterRecord;
|
||||||
|
|
||||||
const PER_FINDING_LIMIT: usize = 10;
|
const PER_FINDING_LIMIT: usize = 10;
|
||||||
|
|
@ -50,7 +50,7 @@ pub fn build_payload(
|
||||||
"markdown": true,
|
"markdown": true,
|
||||||
})];
|
})];
|
||||||
|
|
||||||
if !findings.is_empty() {
|
if !findings.is_empty() && summary.detail == AlertDetail::Detail {
|
||||||
let take = findings.len().min(PER_FINDING_LIMIT);
|
let take = findings.len().min(PER_FINDING_LIMIT);
|
||||||
let mut details = String::new();
|
let mut details = String::new();
|
||||||
for f in findings.iter().take(take) {
|
for f in findings.iter().take(take) {
|
||||||
|
|
@ -60,8 +60,13 @@ pub fn build_payload(
|
||||||
"<redacted>".to_string()
|
"<redacted>".to_string()
|
||||||
};
|
};
|
||||||
details.push_str(&format!(
|
details.push_str(&format!(
|
||||||
"- **{}** at `{}:{}` — `{}` (validation: {})\n",
|
"- **{}** at `{}:{}` — `{}` (validation: {}) — fp:`{}`\n",
|
||||||
f.rule.id, f.finding.path, f.finding.line, snippet, f.finding.validation.status
|
f.rule.id,
|
||||||
|
f.finding.path,
|
||||||
|
f.finding.line,
|
||||||
|
snippet,
|
||||||
|
f.finding.validation.status,
|
||||||
|
f.finding.fingerprint,
|
||||||
));
|
));
|
||||||
}
|
}
|
||||||
if findings.len() > take {
|
if findings.len() > take {
|
||||||
|
|
@ -71,16 +76,35 @@ pub fn build_payload(
|
||||||
"title": "Findings",
|
"title": "Findings",
|
||||||
"text": details,
|
"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",
|
"@type": "MessageCard",
|
||||||
"@context": "https://schema.org/extensions",
|
"@context": "https://schema.org/extensions",
|
||||||
"summary": title,
|
"summary": title,
|
||||||
"themeColor": theme_color,
|
"themeColor": theme_color,
|
||||||
"title": title,
|
"title": title,
|
||||||
"sections": sections,
|
"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 {
|
fn plural(n: usize) -> &'static str {
|
||||||
|
|
@ -108,6 +132,9 @@ mod tests {
|
||||||
by_rule: vec![],
|
by_rule: vec![],
|
||||||
kingfisher_version: "test".to_string(),
|
kingfisher_version: "test".to_string(),
|
||||||
target: None,
|
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);
|
let p = build_payload(&summary(2, 0), &[], false);
|
||||||
assert_eq!(p["themeColor"], "F39C12");
|
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`"));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -254,6 +254,27 @@ pub struct ScanArgs {
|
||||||
#[arg(global = true, long = "alert-include-secret", default_value_t = false)]
|
#[arg(global = true, long = "alert-include-secret", default_value_t = false)]
|
||||||
pub alert_include_secret: bool,
|
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
|
/// Per-webhook overrides loaded from `kingfisher.yaml`. Indexed in lockstep
|
||||||
/// with `alert_webhook` for the trailing config-sourced URLs. Not parsed
|
/// with `alert_webhook` for the trailing config-sourced URLs. Not parsed
|
||||||
/// from the CLI; populated by `apply_config` in main.rs.
|
/// from the CLI; populated by `apply_config` in main.rs.
|
||||||
|
|
@ -270,6 +291,8 @@ pub struct ConfigWebhookOverride {
|
||||||
pub on: Option<crate::alerts::AlertOn>,
|
pub on: Option<crate::alerts::AlertOn>,
|
||||||
pub min_confidence: Option<ConfidenceLevel>,
|
pub min_confidence: Option<ConfidenceLevel>,
|
||||||
pub include_secret: Option<bool>,
|
pub include_secret: Option<bool>,
|
||||||
|
pub report_url: Option<String>,
|
||||||
|
pub detail: Option<crate::alerts::AlertDetail>,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Confidence levels for findings
|
/// Confidence levels for findings
|
||||||
|
|
|
||||||
|
|
@ -12,6 +12,8 @@
|
||||||
//! on: findings # findings | always
|
//! on: findings # findings | always
|
||||||
//! min_confidence: medium # low | medium | high
|
//! min_confidence: medium # low | medium | high
|
||||||
//! include_secret: false
|
//! include_secret: false
|
||||||
|
//! report_url: https://github.com/org/repo/actions/runs/123 # optional pivot link
|
||||||
|
//! detail: auto # summary | detail | auto
|
||||||
//! filters:
|
//! filters:
|
||||||
//! skip_words: ["EXAMPLE", "TEST"]
|
//! skip_words: ["EXAMPLE", "TEST"]
|
||||||
//! skip_regex: ['^DUMMY_']
|
//! skip_regex: ['^DUMMY_']
|
||||||
|
|
@ -25,7 +27,7 @@
|
||||||
use anyhow::{Context, Result};
|
use anyhow::{Context, Result};
|
||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
|
|
||||||
use crate::alerts::{AlertFormat, AlertOn};
|
use crate::alerts::{AlertDetail, AlertFormat, AlertOn};
|
||||||
use crate::cli::commands::scan::ConfidenceLevel;
|
use crate::cli::commands::scan::ConfidenceLevel;
|
||||||
|
|
||||||
/// File name auto-discovered when the user does not pass `--config`.
|
/// File name auto-discovered when the user does not pass `--config`.
|
||||||
|
|
@ -59,6 +61,14 @@ pub struct WebhookConfig {
|
||||||
pub min_confidence: Option<ConfigConfidence>,
|
pub min_confidence: Option<ConfigConfidence>,
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
pub include_secret: Option<bool>,
|
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)]
|
#[derive(Debug, Copy, Clone, Serialize, Deserialize)]
|
||||||
|
|
|
||||||
|
|
@ -1086,6 +1086,8 @@ pub(crate) fn create_minimal_scan_args() -> crate::cli::commands::scan::ScanArgs
|
||||||
alert_on: crate::alerts::AlertOn::Findings,
|
alert_on: crate::alerts::AlertOn::Findings,
|
||||||
alert_min_confidence: ConfidenceLevel::Medium,
|
alert_min_confidence: ConfidenceLevel::Medium,
|
||||||
alert_include_secret: false,
|
alert_include_secret: false,
|
||||||
|
alert_report_url: None,
|
||||||
|
alert_detail: crate::alerts::AlertDetail::Auto,
|
||||||
config_webhook_overrides: Vec::new(),
|
config_webhook_overrides: Vec::new(),
|
||||||
validation_timeout: 10,
|
validation_timeout: 10,
|
||||||
validation_retries: 1,
|
validation_retries: 1,
|
||||||
|
|
|
||||||
105
src/main.rs
105
src/main.rs
|
|
@ -340,11 +340,102 @@ fn apply_config(
|
||||||
on: w.on,
|
on: w.on,
|
||||||
min_confidence: w.min_confidence.map(Into::into),
|
min_confidence: w.min_confidence.map(Into::into),
|
||||||
include_secret: w.include_secret,
|
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.
|
/// 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.config_webhook_overrides` aligns with the trailing entries of
|
||||||
/// `scan_args.alert_webhook` (those that came from `kingfisher.yaml`); CLI URLs
|
/// `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),
|
on: override_.on.unwrap_or(scan_args.alert_on),
|
||||||
min_confidence: override_.min_confidence.unwrap_or(scan_args.alert_min_confidence),
|
min_confidence: override_.min_confidence.unwrap_or(scan_args.alert_min_confidence),
|
||||||
include_secret: override_.include_secret.unwrap_or(scan_args.alert_include_secret),
|
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()
|
.collect()
|
||||||
|
|
@ -554,11 +650,8 @@ async fn async_main(args: CommandLineArgs) -> Result<AsyncMainOutcome> {
|
||||||
};
|
};
|
||||||
match alert_reporter.build_finding_records(&scan_args) {
|
match alert_reporter.build_finding_records(&scan_args) {
|
||||||
Ok(records) => {
|
Ok(records) => {
|
||||||
let target = scan_args
|
let target =
|
||||||
.input_specifier_args
|
describe_scan_target(&scan_args.input_specifier_args);
|
||||||
.path_inputs
|
|
||||||
.first()
|
|
||||||
.map(|p| p.display().to_string());
|
|
||||||
let sinks = build_alert_sinks(&scan_args);
|
let sinks = build_alert_sinks(&scan_args);
|
||||||
kingfisher::alerts::dispatch(&sinks, &records, target).await;
|
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_on: kingfisher::alerts::AlertOn::Findings,
|
||||||
alert_min_confidence: kingfisher::cli::commands::scan::ConfidenceLevel::Medium,
|
alert_min_confidence: kingfisher::cli::commands::scan::ConfidenceLevel::Medium,
|
||||||
alert_include_secret: false,
|
alert_include_secret: false,
|
||||||
|
alert_report_url: None,
|
||||||
|
alert_detail: kingfisher::alerts::AlertDetail::Auto,
|
||||||
config_webhook_overrides: Vec::new(),
|
config_webhook_overrides: Vec::new(),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1871,6 +1871,8 @@ mod tests {
|
||||||
alert_on: crate::alerts::AlertOn::Findings,
|
alert_on: crate::alerts::AlertOn::Findings,
|
||||||
alert_min_confidence: cli::commands::scan::ConfidenceLevel::Medium,
|
alert_min_confidence: cli::commands::scan::ConfidenceLevel::Medium,
|
||||||
alert_include_secret: false,
|
alert_include_secret: false,
|
||||||
|
alert_report_url: None,
|
||||||
|
alert_detail: crate::alerts::AlertDetail::Auto,
|
||||||
config_webhook_overrides: Vec::new(),
|
config_webhook_overrides: Vec::new(),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -237,6 +237,8 @@ mod tests {
|
||||||
alert_on: crate::alerts::AlertOn::Findings,
|
alert_on: crate::alerts::AlertOn::Findings,
|
||||||
alert_min_confidence: cli::commands::scan::ConfidenceLevel::Medium,
|
alert_min_confidence: cli::commands::scan::ConfidenceLevel::Medium,
|
||||||
alert_include_secret: false,
|
alert_include_secret: false,
|
||||||
|
alert_report_url: None,
|
||||||
|
alert_detail: crate::alerts::AlertDetail::Auto,
|
||||||
config_webhook_overrides: Vec::new(),
|
config_webhook_overrides: Vec::new(),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -191,6 +191,14 @@ fn run_skiplist(skip_regex: Vec<String>, skip_skipword: Vec<String>) -> Result<u
|
||||||
validation_timeout: 10,
|
validation_timeout: 10,
|
||||||
full_validation_response: false,
|
full_validation_response: false,
|
||||||
max_validation_response_length: 2048,
|
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 {
|
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,
|
allow_internal_ips: false,
|
||||||
endpoint: Vec::new(),
|
endpoint: Vec::new(),
|
||||||
endpoint_config: None,
|
endpoint_config: None,
|
||||||
|
config: None,
|
||||||
};
|
};
|
||||||
|
|
||||||
let loaded = RuleLoader::from_rule_specifiers(&scan_args.rules).load(&scan_args)?;
|
let loaded = RuleLoader::from_rule_specifiers(&scan_args.rules).load(&scan_args)?;
|
||||||
|
|
|
||||||
|
|
@ -176,6 +176,14 @@ fn test_bitbucket_remote_scan() -> Result<()> {
|
||||||
validation_timeout: 10,
|
validation_timeout: 10,
|
||||||
full_validation_response: false,
|
full_validation_response: false,
|
||||||
max_validation_response_length: 2048,
|
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 {
|
let global_args = GlobalArgs {
|
||||||
|
|
@ -191,6 +199,7 @@ fn test_bitbucket_remote_scan() -> Result<()> {
|
||||||
allow_internal_ips: false,
|
allow_internal_ips: false,
|
||||||
endpoint: Vec::new(),
|
endpoint: Vec::new(),
|
||||||
endpoint_config: None,
|
endpoint_config: None,
|
||||||
|
config: None,
|
||||||
};
|
};
|
||||||
|
|
||||||
let datastore = Arc::new(Mutex::new(FindingsStore::new(clone_dir)));
|
let datastore = Arc::new(Mutex::new(FindingsStore::new(clone_dir)));
|
||||||
|
|
|
||||||
|
|
@ -196,6 +196,14 @@ rules:
|
||||||
validation_timeout: 10,
|
validation_timeout: 10,
|
||||||
full_validation_response: false,
|
full_validation_response: false,
|
||||||
max_validation_response_length: 2048,
|
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 {
|
let global_args = GlobalArgs {
|
||||||
|
|
@ -211,6 +219,7 @@ rules:
|
||||||
allow_internal_ips: false,
|
allow_internal_ips: false,
|
||||||
endpoint: Vec::new(),
|
endpoint: Vec::new(),
|
||||||
endpoint_config: None,
|
endpoint_config: None,
|
||||||
|
config: None,
|
||||||
};
|
};
|
||||||
|
|
||||||
// ── load rules once ─────────────────────────────────────────────
|
// ── load rules once ─────────────────────────────────────────────
|
||||||
|
|
|
||||||
|
|
@ -183,6 +183,14 @@ fn test_github_remote_scan() -> Result<()> {
|
||||||
validation_timeout: 10,
|
validation_timeout: 10,
|
||||||
full_validation_response: false,
|
full_validation_response: false,
|
||||||
max_validation_response_length: 2048,
|
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
|
// Create global arguments
|
||||||
let global_args = GlobalArgs {
|
let global_args = GlobalArgs {
|
||||||
|
|
@ -198,6 +206,7 @@ fn test_github_remote_scan() -> Result<()> {
|
||||||
allow_internal_ips: false,
|
allow_internal_ips: false,
|
||||||
endpoint: Vec::new(),
|
endpoint: Vec::new(),
|
||||||
endpoint_config: None,
|
endpoint_config: None,
|
||||||
|
config: None,
|
||||||
};
|
};
|
||||||
// Create in-memory datastore
|
// Create in-memory datastore
|
||||||
let datastore = Arc::new(Mutex::new(FindingsStore::new(clone_dir)));
|
let datastore = Arc::new(Mutex::new(FindingsStore::new(clone_dir)));
|
||||||
|
|
|
||||||
|
|
@ -181,6 +181,14 @@ fn test_gitlab_remote_scan() -> Result<()> {
|
||||||
validation_timeout: 10,
|
validation_timeout: 10,
|
||||||
full_validation_response: false,
|
full_validation_response: false,
|
||||||
max_validation_response_length: 2048,
|
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 {
|
let global_args = GlobalArgs {
|
||||||
|
|
@ -196,6 +204,7 @@ fn test_gitlab_remote_scan() -> Result<()> {
|
||||||
allow_internal_ips: false,
|
allow_internal_ips: false,
|
||||||
endpoint: Vec::new(),
|
endpoint: Vec::new(),
|
||||||
endpoint_config: None,
|
endpoint_config: None,
|
||||||
|
config: None,
|
||||||
};
|
};
|
||||||
|
|
||||||
let datastore = Arc::new(Mutex::new(FindingsStore::new(clone_dir)));
|
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,
|
validation_timeout: 10,
|
||||||
full_validation_response: false,
|
full_validation_response: false,
|
||||||
max_validation_response_length: 2048,
|
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 {
|
let global_args = GlobalArgs {
|
||||||
|
|
@ -381,6 +398,7 @@ fn test_gitlab_remote_scan_no_history() -> Result<()> {
|
||||||
allow_internal_ips: false,
|
allow_internal_ips: false,
|
||||||
endpoint: Vec::new(),
|
endpoint: Vec::new(),
|
||||||
endpoint_config: None,
|
endpoint_config: None,
|
||||||
|
config: None,
|
||||||
};
|
};
|
||||||
|
|
||||||
let datastore = Arc::new(Mutex::new(FindingsStore::new(clone_dir)));
|
let datastore = Arc::new(Mutex::new(FindingsStore::new(clone_dir)));
|
||||||
|
|
|
||||||
|
|
@ -253,6 +253,14 @@ async fn test_scan_postman_all() -> Result<()> {
|
||||||
validation_timeout: 10,
|
validation_timeout: 10,
|
||||||
full_validation_response: false,
|
full_validation_response: false,
|
||||||
max_validation_response_length: 2048,
|
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 {
|
let global_args = GlobalArgs {
|
||||||
|
|
@ -268,6 +276,7 @@ async fn test_scan_postman_all() -> Result<()> {
|
||||||
allow_internal_ips: false,
|
allow_internal_ips: false,
|
||||||
endpoint: Vec::new(),
|
endpoint: Vec::new(),
|
||||||
endpoint_config: None,
|
endpoint_config: None,
|
||||||
|
config: None,
|
||||||
};
|
};
|
||||||
|
|
||||||
let loaded = RuleLoader::from_rule_specifiers(&scan_args.rules).load(&scan_args)?;
|
let loaded = RuleLoader::from_rule_specifiers(&scan_args.rules).load(&scan_args)?;
|
||||||
|
|
|
||||||
|
|
@ -159,6 +159,14 @@ async fn test_redact_hashes_finding_values() -> Result<()> {
|
||||||
validation_timeout: 10,
|
validation_timeout: 10,
|
||||||
full_validation_response: false,
|
full_validation_response: false,
|
||||||
max_validation_response_length: 2048,
|
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 {
|
let global_args = GlobalArgs {
|
||||||
|
|
@ -174,6 +182,7 @@ async fn test_redact_hashes_finding_values() -> Result<()> {
|
||||||
allow_internal_ips: false,
|
allow_internal_ips: false,
|
||||||
endpoint: Vec::new(),
|
endpoint: Vec::new(),
|
||||||
endpoint_config: None,
|
endpoint_config: None,
|
||||||
|
config: None,
|
||||||
};
|
};
|
||||||
|
|
||||||
let loaded = RuleLoader::from_rule_specifiers(&scan_args.rules).load(&scan_args)?;
|
let loaded = RuleLoader::from_rule_specifiers(&scan_args.rules).load(&scan_args)?;
|
||||||
|
|
|
||||||
|
|
@ -164,6 +164,14 @@ impl TestContext {
|
||||||
validation_timeout: 10,
|
validation_timeout: 10,
|
||||||
full_validation_response: false,
|
full_validation_response: false,
|
||||||
max_validation_response_length: 2048,
|
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)?;
|
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,
|
validation_timeout: 10,
|
||||||
full_validation_response: false,
|
full_validation_response: false,
|
||||||
max_validation_response_length: 2048,
|
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 {
|
let global_args = GlobalArgs {
|
||||||
|
|
@ -347,6 +363,7 @@ async fn test_scan_slack_messages() -> Result<()> {
|
||||||
allow_internal_ips: false,
|
allow_internal_ips: false,
|
||||||
endpoint: Vec::new(),
|
endpoint: Vec::new(),
|
||||||
endpoint_config: None,
|
endpoint_config: None,
|
||||||
|
config: None,
|
||||||
};
|
};
|
||||||
|
|
||||||
let datastore = Arc::new(Mutex::new(FindingsStore::new(clone_dir)));
|
let datastore = Arc::new(Mutex::new(FindingsStore::new(clone_dir)));
|
||||||
|
|
|
||||||
|
|
@ -200,6 +200,14 @@ async fn test_scan_teams_messages() -> Result<()> {
|
||||||
validation_timeout: 10,
|
validation_timeout: 10,
|
||||||
full_validation_response: false,
|
full_validation_response: false,
|
||||||
max_validation_response_length: 2048,
|
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 {
|
let global_args = GlobalArgs {
|
||||||
|
|
@ -215,6 +223,7 @@ async fn test_scan_teams_messages() -> Result<()> {
|
||||||
allow_internal_ips: false,
|
allow_internal_ips: false,
|
||||||
endpoint: Vec::new(),
|
endpoint: Vec::new(),
|
||||||
endpoint_config: None,
|
endpoint_config: None,
|
||||||
|
config: None,
|
||||||
};
|
};
|
||||||
|
|
||||||
let loaded = RuleLoader::from_rule_specifiers(&scan_args.rules).load(&scan_args)?;
|
let loaded = RuleLoader::from_rule_specifiers(&scan_args.rules).load(&scan_args)?;
|
||||||
|
|
|
||||||
|
|
@ -239,6 +239,14 @@ async fn test_validation_cache_and_depvars() -> Result<()> {
|
||||||
validation_timeout: 10,
|
validation_timeout: 10,
|
||||||
full_validation_response: false,
|
full_validation_response: false,
|
||||||
max_validation_response_length: 2048,
|
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,
|
allow_internal_ips: true,
|
||||||
endpoint: Vec::new(),
|
endpoint: Vec::new(),
|
||||||
endpoint_config: None,
|
endpoint_config: None,
|
||||||
|
config: None,
|
||||||
};
|
};
|
||||||
let update_status = UpdateStatus::default();
|
let update_status = UpdateStatus::default();
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -182,6 +182,14 @@ impl TestContext {
|
||||||
validation_timeout: 10,
|
validation_timeout: 10,
|
||||||
full_validation_response: false,
|
full_validation_response: false,
|
||||||
max_validation_response_length: 2048,
|
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)
|
let loaded = RuleLoader::from_rule_specifiers(&scan_args.rules)
|
||||||
|
|
@ -336,6 +344,14 @@ impl TestContext {
|
||||||
validation_timeout: 10,
|
validation_timeout: 10,
|
||||||
full_validation_response: false,
|
full_validation_response: false,
|
||||||
max_validation_response_length: 2048,
|
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 {
|
let global_args = GlobalArgs {
|
||||||
|
|
@ -351,6 +367,7 @@ impl TestContext {
|
||||||
allow_internal_ips: false,
|
allow_internal_ips: false,
|
||||||
endpoint: Vec::new(),
|
endpoint: Vec::new(),
|
||||||
endpoint_config: None,
|
endpoint_config: None,
|
||||||
|
config: None,
|
||||||
};
|
};
|
||||||
|
|
||||||
let datastore = Arc::new(Mutex::new(FindingsStore::new(clone_dir)));
|
let datastore = Arc::new(Mutex::new(FindingsStore::new(clone_dir)));
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue