From efdb4c140e7240360f4931de5abf3b323a15dc8c Mon Sep 17 00:00:00 2001 From: Julio Jimenez Date: Tue, 29 Sep 2026 01:42:36 -0400 Subject: [PATCH] feat(slack): Job Status Notification Signed-off-by: Julio Jimenez --- .github/workflows/tests.yml | 16 + CLAUDE.md | 12 +- Dockerfile | 4 +- README.md | 43 +- action.yml | 11 + cmd/clickbom/main.go | 157 ++++- cmd/clickbom/main_test.go | 379 ++++++++++++ internal/config/config.go | 95 +++ internal/config/config_test.go | 155 +++++ internal/notify/slack.go | 741 +++++++++++++++++++++++ internal/notify/slack_test.go | 846 +++++++++++++++++++++++++++ internal/validation/sanitize.go | 52 ++ internal/validation/sanitize_test.go | 71 +++ 13 files changed, 2575 insertions(+), 7 deletions(-) create mode 100644 internal/notify/slack.go create mode 100644 internal/notify/slack_test.go diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index 8817c44..13b86f7 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -284,6 +284,18 @@ jobs: fi grep -q "S3_BUCKET is required" out.log || { cat out.log; exit 1; } + - name: 🧪 Binary withholds a rejected Slack webhook URL + run: | + set -euo pipefail + # The webhook URL is a credential: a rejected value must be named but + # never echoed, in the shipped distroless binary as much as in tests. + if docker run --rm -e SLACK_WEBHOOK_URL=https://example.com/services/MARKER clickbom:test > out.log 2>&1; then + echo "expected clickbom to fail with an invalid SLACK_WEBHOOK_URL"; cat out.log; exit 1 + fi + grep -q "invalid SLACK_WEBHOOK_URL" out.log || { cat out.log; exit 1; } + if grep -q "MARKER" out.log; then echo "webhook URL leaked into the log"; cat out.log; exit 1; fi + + # Preflight: only run the E2E job when the required secrets are actually # configured on the repo. Without this, the E2E job fails on any fork/contrib # push (or any repo that hasn't set TEST_S3_BUCKET / AWS_* secrets) with a @@ -346,6 +358,10 @@ jobs: S3_KEY: test-e2e-${{ github.sha }}.json SBOM_SOURCE: github SBOM_FORMAT: cyclonedx + # Optional: when the repository defines TEST_SLACK_WEBHOOK_URL the run + # posts a real message linked to this job; empty means no notification. + SLACK_WEBHOOK_URL: ${{ secrets.TEST_SLACK_WEBHOOK_URL }} + CLICKBOM_JOB_CHECK_RUN_ID: ${{ job.check_run_id }} # Benchmarks benchmark: diff --git a/CLAUDE.md b/CLAUDE.md index 5e67e50..0617f2e 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -9,7 +9,7 @@ This file provides guidance to Claude Code (claude.ai/code) when working with co ## What this is -ClickBOM is a Go-based GitHub Action distributed as a Docker container ([action.yml](action.yml), [Dockerfile](Dockerfile)). It downloads SBOMs from GitHub, Mend, Wiz, or generates them from container images via Trivy, normalizes them between CycloneDX and SPDX, optionally merges multiple SBOMs from S3, and uploads results to S3 and/or ClickHouse. +ClickBOM is a Go-based GitHub Action distributed as a Docker container ([action.yml](action.yml), [Dockerfile](Dockerfile)). It downloads SBOMs from GitHub, Mend, Wiz, or generates them from container images via Trivy, normalizes them between CycloneDX and SPDX, optionally merges multiple SBOMs from S3, and uploads results to S3 and/or ClickHouse, optionally posting a success/failure summary of the run to a Slack incoming webhook. All inputs are passed in through environment variables set by `action.yml` (see [internal/config/config.go](internal/config/config.go)) — there are no CLI flags. The binary entry point is [cmd/clickbom/main.go](cmd/clickbom/main.go). @@ -51,7 +51,8 @@ docker run --rm --platform linux/amd64 --entrypoint /usr/local/bin/cyclonedx cli ### Execution flow -[main.go](cmd/clickbom/main.go) branches on `cfg.Merge`: +`run()` in [main.go](cmd/clickbom/main.go) snapshots `config.SecretsFromEnv()`, loads the config, then calls `execute()` and reports its outcome: when `cfg.SlackWebhookURL` is set, `notifyOutcome` posts one success-or-failure message (bounded by a 2-minute context) built from `notify.RunContextFromEnv()`, `buildSummary(cfg)` (non-sensitive facts only) and `secretsForRedaction(cfg, startupSecrets)`. The run context is the runner's `GITHUB_*` / `RUNNER_OS` default env vars plus `CLICKBOM_JOB_CHECK_RUN_ID`, which action.yml fills from the `job-check-run-id` input whose default is `${{ job.check_run_id }}`: a Docker action's `runs.env` may only reference `inputs`, but input defaults may reference the `job` context, so the id reaches the container without any consumer change. The header links to `{server}/{repo}/actions/runs/{id}/job/{check_run_id}` (verified against the REST API: a job's `html_url` uses its check run id, with no attempt segment) and falls back to `.../runs/{id}[/attempts/{n}]` when the id is empty (older GHES). A notification failure is logged as a warning and never changes the exit status (`notifyOutcome` also recovers a panic without logging its value). `newSlackNotifier` and `notificationTimeout` are package variables so tests can point the notifier at an `httptest` server and shorten the deadline. A `LoadConfig` failure is reported too via `notifyConfigFailure`, which uses `config.SlackWebhookURLFromEnv()` because there is no `Config` to read it from and attaches no summary. `execute()` then branches on `cfg.Merge`: + - **Normal mode** (`handleNormalMode`): dispatches on `cfg.SBOMSource` (`github` / `mend` / `wiz` / `trivy`) → download/generate → `ExtractSBOMFromWrapper` (unwraps the `{"sbom": {...}}` envelope GitHub's deprecated synchronous endpoint used; the asynchronous export returns a bare SPDX document, so for GitHub this is now a pass-through kept for compatibility) → `DetectSBOMFormat` (by inspecting `bomFormat` or `spdxVersion`) → `ConvertSBOM` to the requested format → upload to S3 → optionally upload to ClickHouse. - **Merge mode** (`handleMergeMode`): downloads all objects from the S3 bucket, applies include/exclude glob filters via `ShouldIncludeFile`, keeps only valid CycloneDX files, merges them with `MergeSBOMs`, converts to the desired format, then uploads. @@ -61,13 +62,16 @@ Format conversion shells out to the `cyclonedx` CLI (installed in the Dockerfile ### Package layout - [internal/config](internal/config) — `Config` struct loaded from env vars by `LoadConfig()`. Always calls `Sanitize()` then `Validate()`. Per-source required-field rules live in `Validate()`; do not bypass sanitization, it is the input-trust boundary. -- [internal/validation](internal/validation) — `Sanitize*` helpers (UUIDs, URLs, S3 bucket/key, repository slugs, glob patterns, generic length-capped strings). Hostname allow-listing is enforced by `SanitizeURL(url, kind)` where `kind` selects which hosts are permitted (e.g. `mend`, `wiz`, `clickhouse`). +- [internal/validation](internal/validation) — `Sanitize*` helpers (UUIDs, URLs, S3 bucket/key, repository slugs, glob patterns, generic length-capped strings). Hostname allow-listing is enforced by `SanitizeURL(url, kind)` where `kind` selects which hosts are permitted (e.g. `mend`, `wiz`, `clickhouse`). `SanitizeSlackWebhookURL` is separate on purpose: it accepts only `https://hooks.slack.com/services/...` (or `hooks.slack-gov.com`), fails closed on control characters, credentials, ports, query strings, fragments, `..` and `//`, lower-cases the host, and its error never echoes the value, whereas `SanitizeURL` quotes the URL it rejects. Workflow Builder trigger URLs (`/triggers/`, `/workflows/`) are refused because they take only flat key/value payloads. + - [internal/sbom](internal/sbom) — one file per source/concern: - `github.go`, `mend.go`, `wiz.go`, `trivy.go` — source clients. GitHub uses the asynchronous SBOM export: `GET /repos/{owner}/{repo}/dependency-graph/sbom/generate-report` (201, `sbom_url`) → poll `fetch-report/{uuid}` while it answers 202 (`Retry-After`, doubled from 2 s to a 30 s cap) → 302 to a pre-signed blob URL that is fetched by a separate client **without** the GitHub token (the blob store returns 401 if the token is present). The synchronous `GET .../dependency-graph/sbom` endpoint is sunset by GitHub on 2026-11-13 and timed out deterministically on large repositories (`ClickHouse/data-plane-application`, ~25k packages / 52 MB) — do not reintroduce it. Policy: 3 attempts × 30 s back-off around the whole request→poll→download cycle for transient errors (5xx, 429, network, expired report), a 10-minute poll deadline per report, and no retry on permanent errors (401/403/404, an `sbom_url` on a foreign host). The pre-signed URL carries a SAS token, so it is never logged or quoted — `downloadReport` names only the host and `stripURL` unwraps `*url.Error`. `github_test.go` drives the client against an `httptest.NewTLSServer` stub with a fake clock; `github_integration_test.go` (build tag `integration`) hits the real API when `GITHUB_TOKEN` is set (CI passes `github.token`; `GITHUB_SBOM_TEST_REPOSITORY` overrides the target). Mend uses an async report-export poll loop bounded by `MendMaxWaitTime` / `MendPollInterval`; `exportRequest()` picks the endpoint by scope (project → `/projects/{uuid}/dependencies/reports/SBOM`, product → `/applications/{uuid}/dependencies/reports/SBOM` with optional `projectUuids`). Mend API 3.0 has no organization-level dependency SBOM export, so `MEND_ORG_SCOPE_UUID` alone is rejected at validation. Trivy supports cross-account ECR via STS `AssumeRole` (`trivy-ecr-role-arn`, optional `trivy-ecr-external-id`). - `processing.go` — `Format` enum, `DetectSBOMFormat`, `ExtractSBOMFromWrapper`, `ConvertSBOM` (shells out to `cyclonedx convert`). - `merge.go` — `MergeSBOMs`, `ExtractSourceReference` (multi-strategy: SPDX doc name → component name → bom-ref → filename). - `filter.go` — `filepath.Match` glob filtering for merge mode. - `license_mapper.go` + [license-mappings.json](license-mappings.json) — overrides "unknown"/missing licenses by component name. The mapping file is baked into the Docker image at `/app/license-mappings.json`; override with `LICENSE_MAPPING_FILE`. +- [internal/notify](internal/notify) — `SlackNotifier` posts an `Event` (run context, non-secret `Summary`, error, duration, redaction list) to a Slack incoming webhook as fallback `text` + a header block carrying the job/run link + one colour-coded `attachment` (fields section, fenced `verbatim` error section, context footer), with `unfurl_links`/`unfurl_media` off. Three attempts with linear back-off, honouring `Retry-After` (seconds or HTTP-date) up to 30 s, retrying only network errors, 429 and 5xx. The client never follows redirects (a 3xx is a permanent failure, so the transport can never forward the credential-bearing URL) and only Slack-style `[A-Za-z0-9_-]` response tokens are quoted (`invalid_payload`, `no_service`, ...), never arbitrary bodies. The webhook URL is a credential: nothing here logs it and `*url.Error` is unwrapped before any error is returned. Error text: first line only, `scrub` (URL userinfo/query strings, bearer and basic auth headers, IP:port socket addresses from `*net.OpError` text, AWS key ids, GitHub tokens, `sig=`/`signature=`/`token=` pairs), then list-based `redact` (each secret, its trailing-slash-trimmed, query-/path-escaped forms and its host with/without port, ASCII-case-insensitively via a byte-wise `replaceFold` rather than a per-needle regexp, which would panic on a binary secret and quote it, longest first, nothing shorter than 4 runes), then a 500-rune cap and code-fence neutralisation; field values are capped at 300 runes. The link is mrkdwn `` rather than a Block Kit button because a `url` button still sends an interaction payload that a webhook-only app cannot acknowledge. Env values enter only via `Event` from `cmd/clickbom`: gosec G704 (taint, enforced in CI) fires if `os.Getenv` output reaches `http.Client.Do` inside one package. Unit tests use `httptest` with injected `sleep`/`now`. + - [internal/storage](internal/storage) — `S3Client` (AWS SDK v2) and `ClickHouseClient`. `S3Client` resolves each bucket's home region once (HeadBucket → `x-amz-bucket-region`, which S3 returns even on 301/403) and caches a per-region client, so the job's `AWS_REGION` need not match the bucket; when `AWS_ENDPOINT_URL` is set (RustFS/MinIO/LocalStack) it uses path-style addressing and skips region discovery. ClickHouse uses raw HTTP POST queries (only HTTP is supported — no native protocol); the URL is stored without a trailing slash. `SetupTable` auto-migrates older tables by adding a `source LowCardinality(String)` column when missing. - [pkg/logger](pkg/logger) — colorized leveled logger gated by the `DEBUG` env var or `SetDebug(true)`. @@ -85,4 +89,6 @@ The Dockerfile is multi-stage and ends on `gcr.io/distroless/cc-debian13:nonroot - Integration tests use `//go:build integration` and are excluded from the default `go test ./...` run. CI runs them in the `test_integration` job against RustFS (`rustfs/rustfs:1.0.0`, an Apache-2.0 store with a MinIO-compatible API: port 9000, `/minio/health/live`, root credentials via `RUSTFS_ACCESS_KEY`/`RUSTFS_SECRET_KEY`) and a `clickhouse/clickhouse-server` service container, with the `cyclonedx` CLI installed on the runner. Do not go back to MinIO: the project went source-only in October 2025, was archived in April 2026, and its Docker Hub and quay.io images were withdrawn in September 2026 (quay.io started answering `unauthorized` to anonymous pulls between 2026-09-21 and 2026-09-24, which is what broke the job). LocalStack's community image was discontinued and the remaining image needs an account and auth token. The RustFS tag is pinned by hand because Dependabot does not track images referenced in `run:` steps. Locally: start the same two containers (ClickHouse with `CLICKHOUSE_USER=clickbom CLICKHOUSE_PASSWORD=clickbom`; current images refuse the passwordless `default` user from outside the container), create `test-bucket`, put a `cyclonedx` binary on `PATH`, and export `AWS_ENDPOINT_URL`, `CLICKHOUSE_URL`, `CLICKHOUSE_USERNAME`/`CLICKHOUSE_PASSWORD`, `AWS_ACCESS_KEY_ID`/`AWS_SECRET_ACCESS_KEY`, `AWS_REGION` (see the command block above). - Consumers: [ClickHouse/sbom](https://github.com/ClickHouse/sbom) (`private-government.yml`, `clickhouse-cloud.yml`) and, until fully migrated, [ClickHouse/security-integrations](https://github.com/ClickHouse/security-integrations) `clickbom.yml`. The sbom workflows pin the `v2.0.0` tag, so a fix reaches them only after a new tag and a ref bump; security-integrations pins `@main`. Never point a consumer at a feature branch: the ref breaks the moment the branch is deleted. - Pre-commit blocks direct commits to `main`/`master` and enforces conventional commit messages. +- The Slack message may only carry inputs the README marks non-sensitive. The sensitive list lives in [internal/config](internal/config) (`sensitiveEnvVars`, `SecretsFromEnv`, `(*Config).Secrets`) and must be kept in sync with the README's Sensitive column; `secretsForRedaction` in [main.go](cmd/clickbom/main.go) adds the table-name spellings of Mend/Wiz identifiers (ClickHouse errors echo `db.mend_`), and `buildSummary` names their scopes without identifiers and omits their ClickHouse table name. Never log `SLACK_WEBHOOK_URL`. Because error text now reaches Slack, any new `fmt.Errorf` that can embed a URL should go through `stripURL`/`redactURL` (see `internal/sbom/github.go`); the Wiz and Mend download errors do not yet, and rely on the notifier's URL scrubber. + - `gocyclo -over 26` is the hard cyclomatic-complexity ceiling; `handleNormalMode` and `handleMergeMode` are close to it — prefer extracting helpers when adding branches. diff --git a/Dockerfile b/Dockerfile index a84ad79..69733f3 100644 --- a/Dockerfile +++ b/Dockerfile @@ -7,7 +7,7 @@ RUN apk update && apk upgrade --available --no-cache LABEL maintainer="ClickHouse Security Team" \ description="ClickBOM - SBOM Management Tool" \ - version="2.0.0" + version="2.1.0" # Install build dependencies RUN apk add --no-cache \ @@ -97,7 +97,7 @@ FROM gcr.io/distroless/cc-debian13:nonroot LABEL maintainer="ClickHouse Security Team" \ description="ClickBOM - SBOM Management Tool" \ - version="2.0.2" \ + version="2.1.0" \ security.scan="enabled" # Copy from tools stage diff --git a/README.md b/README.md index 1d1acb8..ed09dc0 100644 --- a/README.md +++ b/README.md @@ -2,7 +2,7 @@ # ClickBOM -Downloads SBOMs from GitHub, Mend, and Wiz, or generates them from container images with Trivy. Normalizes between CycloneDX and SPDX, optionally merges SBOMs stored in S3, and uploads the result to S3 and ClickHouse. +Downloads SBOMs from GitHub, Mend, and Wiz, or generates them from container images with Trivy. Normalizes between CycloneDX and SPDX, optionally merges SBOMs stored in S3, uploads the result to S3 and ClickHouse, and can report the outcome of each run to Slack. > **Versioning.** `v1.0.x` tags are the retired bash implementation. The Go implementation is released as `v2.x` tags (`v2.0.0` and later); pin a tag, for example `ClickHouse/ClickBOM@v2.0.0`, and bump it to pick up fixes. Never pin to a feature branch — branches are deleted after merge and the workflow fails with `Unable to resolve action`. @@ -14,6 +14,7 @@ Downloads SBOMs from GitHub, Mend, and Wiz, or generates them from container ima - [AWS](#aws) - [ClickHouse](#clickhouse) - [General](#general) + - [Slack](#slack) - [Usage](#usage) - [Same Repository](#same-repository) - [Same Repository with ClickHouse](#same-repository-with-clickhouse) @@ -24,6 +25,7 @@ Downloads SBOMs from GitHub, Mend, and Wiz, or generates them from container ima - [Downloading an SBOM from Mend](#downloading-an-sbom-from-mend) - [Downloading an SBOM from Wiz](#downloading-an-sbom-from-wiz) - [Generating an SBOM from a Container Image with Trivy](#generating-an-sbom-from-a-container-image-with-trivy) + - [Posting Results to Slack](#posting-results-to-slack) - [Runtime Image](#runtime-image) - [Creating a GitHub App](#creating-a-github-app) @@ -127,6 +129,21 @@ Downloads SBOMs from GitHub, Mend, and Wiz, or generates them from container ima - If `exclude` is specified, files matching the exclude patterns will be skipped. - `exclude` is applied after `include`, so a file that matches **both** an include and exclude pattern will be *excluded*. +### Slack + +| Name | Description | Default | Required | Sensitive | +| ----------------- | ------------------------------------------------------------------------------------ | ------------------------- | -------- | --------- | +| slack-webhook-url | Slack incoming webhook that receives one success or failure message per run | | false | true | +| job-check-run-id | Id of the running job, used only to link the message to the job. Leave the default. | `${{ job.check_run_id }}` | false | false | + +- When set, ClickBOM posts one message per run to whichever Slack workspace owns the webhook: whether the run succeeded or failed, the repository, workflow, job and step that ran it, what triggered it (event, branch, short commit, actor), the SBOM source, the S3 object written, the ClickHouse database and table when configured, the duration, and a link. On failure the first line of the error is included. +- The link opens the job itself. `job.check_run_id` is evaluated as the default of `job-check-run-id` and handed to the container, so no workflow change is needed; it also tells matrix legs apart, which share a job key. On a GitHub Enterprise Server release without `job.check_run_id` the value is empty and the link opens the workflow run instead (the specific attempt when re-run). +- **Job** is the job's key in the workflow file (`GITHUB_JOB`), not its `name:`. **Step** is the step's `id:` (`GITHUB_ACTION`); give the ClickBOM step an `id` for a readable label, otherwise GitHub generates one such as `__ClickHouse_ClickBOM`. +- Only Slack *incoming webhook* URLs are accepted: `https://hooks.slack.com/services/...` (or `hooks.slack-gov.com` for GovSlack). Workflow Builder webhook triggers (`/triggers/...`, `/workflows/...`) are rejected at start-up because they only take flat key/value payloads. The URL is a credential: pass it from a secret. ClickBOM never logs it, and a rejected value is not echoed in the error. +- Nothing marked Sensitive in this document reaches Slack. For Mend and Wiz the message names the scope (`project scope`, `product scope`, `report`) rather than the identifier and omits the ClickHouse table name, which embeds that identifier. The error text is redacted before posting: URL query strings and credentials are removed, every Sensitive input value (including the table-name spelling of Mend and Wiz identifiers), bearer and basic-auth headers, socket addresses, AWS access key ids and GitHub tokens are replaced with `***`, and only the first line is sent. +- Notification failures are logged as warnings and never change the outcome of the job; delivery is bounded to about two minutes (three attempts). A run that fails configuration validation is reported too, as long as the webhook itself is valid. A retry after a timed-out delivery can produce a duplicate message. +- The inputs first ship in `v2.1.0`; consumers pinned to `v2.0.0` or `v2.0.1` need a ref bump to use them. + ## Usage ### Same Repository @@ -635,6 +652,30 @@ jobs: clickhouse-password: ${{ secrets.CLICKHOUSE_PASSWORD }} ``` +### Posting Results to Slack + +Any of the examples above can report to Slack by adding `slack-webhook-url`. Create an [incoming webhook](https://api.slack.com/messaging/webhooks) for the channel that should receive the messages, store its URL as a repository or organization secret, and pass it in: + +```yaml + - name: Upload SBOM + id: clickbom + uses: ClickHouse/ClickBOM@main + with: + github-token: ${{ secrets.GITHUB_TOKEN }} + s3-bucket: my-sbom-bucket + s3-key: clickbom.json + repository: ${{ github.repository }} + slack-webhook-url: ${{ secrets.SLACK_WEBHOOK_URL }} +``` + +Each run posts one message, for example: + +> :white_check_mark: **ClickBOM succeeded** in my-org/my-repo · Upload SBOM #42 *(links to the job)* +> +> **Workflow** Upload SBOM · **Job** clickbom · **Step** clickbom · **Trigger** push on main @ 0123456 by octocat · **Source** github · my-org/my-repo · **Output** s3://my-sbom-bucket/clickbom.json (cyclonedx) · **Duration** 1m23s + +A failed run is posted the same way, in red, with the first line of the error, so a matrix of many ClickBOM jobs can share one channel. + ## Runtime Image The action runs as a Docker container built from this repository's `Dockerfile`: a static Go binary plus two external tools, `cyclonedx` (format conversion) and `trivy` (image scanning), on `gcr.io/distroless/cc-debian13:nonroot`. The `cc` variant is required because `cyclonedx-cli` is a dynamically linked .NET application; on `distroless/static` it cannot execute at all. CI builds the image and runs a conversion inside it on every push. diff --git a/action.yml b/action.yml index ee29df4..b889fd1 100644 --- a/action.yml +++ b/action.yml @@ -147,6 +147,14 @@ inputs: description: 'Enable debug logging' required: false default: 'false' + # Notifications + slack-webhook-url: + description: 'Slack incoming webhook URL (https://hooks.slack.com/services/...). When set, ClickBOM posts a success or failure message with the workflow, job, step, trigger, SBOM source, S3 target and a link to the job. Pass it from a secret; it is never logged.' + required: false + job-check-run-id: + description: 'Id of the running job, used only to link the Slack message to the job. Leave the default: job.check_run_id is evaluated here because a Docker action can only see the inputs context. Empty on GitHub Enterprise Server releases without job.check_run_id, in which case the message links to the run.' + required: false + default: '${{ job.check_run_id }}' runs: using: 'docker' image: 'Dockerfile' @@ -198,6 +206,9 @@ runs: INCLUDE: ${{ inputs.include }} EXCLUDE: ${{ inputs.exclude }} DEBUG: ${{ inputs.debug }} + # Notifications + SLACK_WEBHOOK_URL: ${{ inputs.slack-webhook-url }} + CLICKBOM_JOB_CHECK_RUN_ID: ${{ inputs.job-check-run-id }} branding: icon: 'list' color: 'yellow' diff --git a/cmd/clickbom/main.go b/cmd/clickbom/main.go index 74b9763..ab264f2 100644 --- a/cmd/clickbom/main.go +++ b/cmd/clickbom/main.go @@ -9,8 +9,10 @@ import ( "path/filepath" "regexp" "strings" + "time" "github.com/ClickHouse/ClickBOM/internal/config" + "github.com/ClickHouse/ClickBOM/internal/notify" "github.com/ClickHouse/ClickBOM/internal/sbom" "github.com/ClickHouse/ClickBOM/internal/storage" "github.com/ClickHouse/ClickBOM/pkg/logger" @@ -23,18 +25,45 @@ func main() { } func run() error { + start := time.Now() + // Snapshot the sensitive environment before anything can rewrite it + // (Trivy's AssumeRole replaces the AWS_* variables mid-run), so the + // original credentials are redacted from the notification as well. + startupSecrets := config.SecretsFromEnv() logger.Info("Starting ClickBOM GitHub Action for SBOM processing") // Load and validate configuration cfg, err := config.LoadConfig() if err != nil { - return fmt.Errorf("configuration error: %w", err) + err = fmt.Errorf("configuration error: %w", err) + // The configuration is unusable, but the webhook alone may still be + // valid; a misconfigured job should still page whoever owns it. + notifyConfigFailure(context.Background(), err, time.Since(start)) + return err } logger.SetDebug(cfg.Debug) ctx := context.Background() + // Every outcome of the run, success or failure, is reported once. A failed + // notification is only logged: it must never change the exit status. + notifier := newSlackNotifier(cfg.SlackWebhookURL) + err = execute(ctx, cfg) + notifyOutcome(ctx, notifier, notify.Event{ + Run: notify.RunContextFromEnv(), + Summary: buildSummary(cfg), + Err: err, + Duration: time.Since(start), + Redact: secretsForRedaction(cfg, startupSecrets), + }) + return err +} + +// execute performs the configured run. It is separate from run so that the +// notification wraps everything after configuration, including temp-dir and +// S3-client setup. +func execute(ctx context.Context, cfg *config.Config) error { // Create temp directory tempDir, err := os.MkdirTemp("", "clickbom-*") if err != nil { @@ -373,3 +402,129 @@ func generateTableName(cfg *config.Config) string { return "sbom_data" } } + +// buildSummary describes the run for the Slack message using only inputs the +// README marks non-sensitive. Mend UUIDs and Wiz report IDs are Sensitive, so +// those sources name their scope but not their identifier, and their ClickHouse +// table (which embeds the identifier) is left out. +func buildSummary(cfg *config.Config) notify.Summary { + s := notify.Summary{ + Source: cfg.SBOMSource, + Format: cfg.SBOMFormat, + Bucket: cfg.S3Bucket, + Key: cfg.S3Key, + } + switch { + case cfg.Merge: + s.Source = "merge" + s.Target = mergeFilters(cfg) + case cfg.SBOMSource == config.SourceGitHub: + s.Target = cfg.Repository + case cfg.SBOMSource == config.SourceMend: + s.Target = "product scope" + if cfg.MendProjectUUID != "" { + s.Target = "project scope" + } + case cfg.SBOMSource == config.SourceWiz: + s.Target = "report" + case cfg.SBOMSource == config.SourceTrivy: + s.Target = cfg.TrivyImage + } + if cfg.ClickHouseURL != "" { + s.ClickHouse = cfg.ClickHouseDatabase + if tableNameIsPublic(cfg) { + s.ClickHouse += "." + generateTableName(cfg) + } + } + return s +} + +// mergeFilters renders the include/exclude patterns of a merge run. +func mergeFilters(cfg *config.Config) string { + var parts []string + if cfg.Include != "" { + parts = append(parts, "include "+cfg.Include) + } + if cfg.Exclude != "" { + parts = append(parts, "exclude "+cfg.Exclude) + } + return strings.Join(parts, ", ") +} + +// tableNameIsPublic reports whether generateTableName derives the table name +// from non-sensitive inputs only (the S3 key, a repository slug or an image). +func tableNameIsPublic(cfg *config.Config) bool { + return cfg.Merge || cfg.SBOMSource == config.SourceGitHub || cfg.SBOMSource == config.SourceTrivy +} + +// secretsForRedaction is every value that must not appear in a Slack message: +// the configuration's sensitive values (config.Secrets), the sensitive +// environment as it was at start-up, and the derived spellings that ClickHouse +// errors echo for Mend and Wiz runs: the table name and the table-name form of +// every identifier (lower-cased, non-alphanumerics collapsed to `_`, hyphens +// dropped), which an exact match on the hyphenated UUID would miss. +func secretsForRedaction(cfg *config.Config, startupSecrets []string) []string { + out := append(cfg.Secrets(), startupSecrets...) + if tableNameIsPublic(cfg) { + return out + } + out = append(out, generateTableName(cfg)) + ids := append(strings.Split(cfg.MendProjectUUIDs, ","), + cfg.MendOrgUUID, cfg.MendProjectUUID, cfg.MendProductUUID, cfg.MendOrgScopeUUID, cfg.WizReportID) + for _, id := range ids { + if id = strings.TrimSpace(id); id != "" { + out = append(out, sanitizeForTableName(id), strings.ReplaceAll(id, "-", "")) + } + } + return out +} + +// newSlackNotifier builds the notifier; a variable so tests can point it at a +// local server (the validator only accepts hooks.slack.com URLs). +var newSlackNotifier = notify.NewSlackNotifier + +// notificationTimeout bounds the whole best-effort notification; three +// attempts with capped back-off could otherwise hold the job for ~105 s. A +// variable so tests can shorten it. +var notificationTimeout = 2 * time.Minute + +// notifyOutcome posts ev and logs, but never returns, a delivery failure. +func notifyOutcome(ctx context.Context, notifier *notify.SlackNotifier, ev notify.Event) { + if notifier == nil { + return + } + // A notification must never change the outcome of the run, not even by + // panicking. The recovered value is not logged: it could quote a secret. + defer func() { + if r := recover(); r != nil { + logger.Warning("Slack notification failed: internal error") + } + }() + ctx, cancel := context.WithTimeout(ctx, notificationTimeout) + defer cancel() + if err := notifier.Notify(ctx, ev); err != nil { + logger.Warning("Slack notification failed: %v", err) + } +} + +// notifyConfigFailure posts a failure for a configuration LoadConfig rejected. +// The webhook is validated on its own because there is no Config to read it +// from; when SLACK_WEBHOOK_URL is unset or itself invalid nothing is sent (the +// returned configuration error already explains the latter). No summary is +// attached: nothing in an unvalidated configuration is known to be safe. +func notifyConfigFailure(ctx context.Context, cause error, elapsed time.Duration) { + webhook, err := config.SlackWebhookURLFromEnv() + if err != nil { + return + } + notifyOutcome(ctx, newSlackNotifier(webhook), configFailureEvent(cause, elapsed)) +} + +func configFailureEvent(cause error, elapsed time.Duration) notify.Event { + return notify.Event{ + Run: notify.RunContextFromEnv(), + Err: cause, + Duration: elapsed, + Redact: config.SecretsFromEnv(), + } +} diff --git a/cmd/clickbom/main_test.go b/cmd/clickbom/main_test.go index 46ae265..1001323 100644 --- a/cmd/clickbom/main_test.go +++ b/cmd/clickbom/main_test.go @@ -1,10 +1,20 @@ package main import ( + "bytes" + "context" + "errors" + "io" + "log" + "net/http" + "net/http/httptest" "reflect" + "strings" "testing" + "time" "github.com/ClickHouse/ClickBOM/internal/config" + "github.com/ClickHouse/ClickBOM/internal/notify" ) func TestGenerateTableName(t *testing.T) { @@ -195,3 +205,372 @@ func TestDefaultSourceForConfig(t *testing.T) { }) } } + +// sensitiveConfig fills every field the README marks Sensitive with a +// recognisable marker so tests can prove none of them reach the Slack summary. +func sensitiveConfig(source string) *config.Config { + return &config.Config{ + SBOMSource: source, + SBOMFormat: "cyclonedx", + S3Bucket: "my-sbom-bucket", + S3Key: "out/clickbom.json", + Repository: "ClickHouse/ClickBOM", + TrivyImage: "registry.example.com/app:1.2.3", + GitHubToken: "SECRET_github_token", + MendEmail: "SECRET_mend@example.com", + MendOrgUUID: "SECRET-org-uuid", + MendUserKey: "SECRET_mend_user_key", + MendProjectUUID: "SECRET-project-uuid", + MendProductUUID: "SECRET-product-uuid", + MendOrgScopeUUID: "SECRET-org-scope-uuid", + MendProjectUUIDs: "SECRET-uuid-a,SECRET-uuid-b", + WizAuthEndpoint: "https://SECRET-auth.wiz.io/oauth/token", + WizAPIEndpoint: "https://SECRET-api.wiz.io", + WizClientID: "SECRET_wiz_client_id", + WizClientSecret: "SECRET_wiz_client_secret", + WizReportID: "SECRET-wiz-report", + TrivyECRExternalID: "SECRET_external_id", + AWSAccessKeyID: "SECRET_AKIA", + AWSSecretAccessKey: "SECRET_aws_secret", + ClickHouseURL: "https://SECRET-ch.example.com:8443", + ClickHouseDatabase: "sboms", + ClickHouseUsername: "clickbom", + ClickHousePassword: "SECRET_ch_password", + SlackWebhookURL: "https://hooks.slack.com/services/SECRET/SECRET/SECRET", + } +} + +func with(c *config.Config, mutate func(*config.Config)) *config.Config { + mutate(c) + return c +} + +func TestBuildSummary(t *testing.T) { + tests := []struct { + name string + cfg *config.Config + want []string // Source, Target, Format, Bucket, Key, ClickHouse + }{ + { + name: "github names the repository and the table", + cfg: sensitiveConfig("github"), + want: []string{"github", "ClickHouse/ClickBOM", "cyclonedx", "my-sbom-bucket", "out/clickbom.json", "sboms.clickhouse_clickbom"}, + }, + { + name: "mend project scope hides the UUID and the table", + cfg: sensitiveConfig("mend"), + want: []string{"mend", "project scope", "cyclonedx", "my-sbom-bucket", "out/clickbom.json", "sboms"}, + }, + { + name: "mend product scope", + cfg: with(sensitiveConfig("mend"), func(c *config.Config) { c.MendProjectUUID = "" }), + want: []string{"mend", "product scope", "cyclonedx", "my-sbom-bucket", "out/clickbom.json", "sboms"}, + }, + { + name: "wiz hides the report id and the table", + cfg: sensitiveConfig("wiz"), + want: []string{"wiz", "report", "cyclonedx", "my-sbom-bucket", "out/clickbom.json", "sboms"}, + }, + { + name: "trivy names the image and the table", + cfg: sensitiveConfig("trivy"), + want: []string{"trivy", "registry.example.com/app:1.2.3", "cyclonedx", "my-sbom-bucket", "out/clickbom.json", "sboms.trivy_app_1_2_3"}, + }, + { + name: "merge names the filters and the merged table", + cfg: with(sensitiveConfig("github"), func(c *config.Config) { + c.Merge, c.Include, c.Exclude = true, "*-prod.json", "old.json" + }), + want: []string{"merge", "include *-prod.json, exclude old.json", "cyclonedx", "my-sbom-bucket", "out/clickbom.json", "sboms.out_clickbom_merged"}, + }, + { + name: "merge without filters has no target", + cfg: with(sensitiveConfig("github"), func(c *config.Config) { c.Merge = true }), + want: []string{"merge", "", "cyclonedx", "my-sbom-bucket", "out/clickbom.json", "sboms.out_clickbom_merged"}, + }, + { + name: "no clickhouse leaves the field empty", + cfg: with(sensitiveConfig("github"), func(c *config.Config) { c.ClickHouseURL = "" }), + want: []string{"github", "ClickHouse/ClickBOM", "cyclonedx", "my-sbom-bucket", "out/clickbom.json", ""}, + }, + } + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + s := buildSummary(tc.cfg) + got := []string{s.Source, s.Target, s.Format, s.Bucket, s.Key, s.ClickHouse} + if !reflect.DeepEqual(got, tc.want) { + t.Errorf("buildSummary() = %q\nwant %q", got, tc.want) + } + for _, v := range got { + if strings.Contains(v, "SECRET") { + t.Errorf("summary leaks a sensitive value: %q", v) + } + } + }) + } +} + +// isolateSensitiveEnv blanks every sensitive variable so tests do not depend +// on the ambient environment (CI sets CLICKHOUSE_PASSWORD, GITHUB_TOKEN, ...). +func isolateSensitiveEnv(t *testing.T) { + t.Helper() + for _, name := range config.SensitiveEnvVars() { + t.Setenv(name, "") + } +} + +// captureLogs redirects the package logger for the duration of the test. +func captureLogs(t *testing.T) *bytes.Buffer { + t.Helper() + var buf bytes.Buffer + prev := log.Writer() + log.SetOutput(&buf) + t.Cleanup(func() { log.SetOutput(prev) }) + return &buf +} + +// swapNotifierFactory makes every notifier main builds post to srv and records +// the webhook each one was asked for. +func swapNotifierFactory(t *testing.T, srv *httptest.Server) *[]string { + t.Helper() + var webhooks []string + prev := newSlackNotifier + newSlackNotifier = func(webhook string) *notify.SlackNotifier { + webhooks = append(webhooks, webhook) + return notify.NewSlackNotifier(srv.URL + "/services/T/B/X") + } + t.Cleanup(func() { newSlackNotifier = prev }) + return &webhooks +} + +func toSet(values []string) map[string]bool { + set := make(map[string]bool, len(values)) + for _, v := range values { + set[v] = true + } + return set +} + +func TestSecretsForRedaction(t *testing.T) { + isolateSensitiveEnv(t) + + t.Run("mend adds table-name spellings and start-up secrets", func(t *testing.T) { + got := secretsForRedaction(sensitiveConfig("mend"), []string{"SECRET_startup_aws_key"}) + set := toSet(got) + for _, want := range []string{ + "SECRET_github_token", "SECRET-project-uuid", "SECRET_ch_password", "https://SECRET-ch.example.com:8443", + "https://hooks.slack.com/services/SECRET/SECRET/SECRET", "SECRET-uuid-a", "SECRET-uuid-b", + "SECRET_startup_aws_key", + // the ClickHouse table and the table-name form of every identifier + "mend_secret_project_uuid", + "secret_org_uuid", "secret_project_uuid", "secret_product_uuid", "secret_org_scope_uuid", + "secret_uuid_a", "secret_uuid_b", "secret_wiz_report", + "SECRETprojectuuid", "SECRETuuida", + } { + if !set[want] { + t.Errorf("redaction list is missing %q", want) + } + } + for _, v := range got { + if v == "" || strings.TrimSpace(v) != v { + t.Errorf("redaction list contains an empty or untrimmed value %q", v) + } + } + for _, public := range []string{"my-sbom-bucket", "out/clickbom.json", "ClickHouse/ClickBOM", "sboms", "clickbom", "cyclonedx"} { + if set[public] { + t.Errorf("redaction list wrongly contains non-sensitive value %q", public) + } + } + }) + t.Run("wiz adds its table name", func(t *testing.T) { + set := toSet(secretsForRedaction(sensitiveConfig("wiz"), nil)) + if !set["wiz_secret_wiz_report"] || !set["secret_wiz_report"] || !set["SECRETwizreport"] { + t.Errorf("wiz table-name spellings missing from %v", set) + } + }) + t.Run("github does not redact its public table name", func(t *testing.T) { + set := toSet(secretsForRedaction(sensitiveConfig("github"), nil)) + if set["clickhouse_clickbom"] || set["secret_project_uuid"] { + t.Error("github runs must not add table-name spellings") + } + }) + t.Run("merge does not redact the merged table name", func(t *testing.T) { + cfg := with(sensitiveConfig("mend"), func(c *config.Config) { c.Merge = true }) + if set := toSet(secretsForRedaction(cfg, nil)); set["out_clickbom_merged"] { + t.Error("merged table name is public and must not be redacted") + } + }) + t.Run("minimal config yields no empty values", func(t *testing.T) { + cfg := &config.Config{SBOMSource: "github", S3Bucket: "b", Repository: "o/r"} + if got := secretsForRedaction(cfg, nil); len(got) != 0 { + t.Errorf("with nothing sensitive configured, secretsForRedaction() = %q, want none", got) + } + }) +} + +func TestConfigFailureEvent(t *testing.T) { + isolateSensitiveEnv(t) + t.Setenv("GITHUB_REPOSITORY", "org/repo") + t.Setenv("GITHUB_RUN_ID", "5") + t.Setenv("CLICKHOUSE_URL", "http://SECRET-host:8123") + cause := errors.New("configuration error: invalid clickhouse URL format: http://SECRET-host:8123") + + ev := configFailureEvent(cause, 3*time.Second) + if ev.Err != cause || ev.Duration != 3*time.Second { + t.Errorf("event = %+v", ev) + } + if ev.Run.Repository != "org/repo" || ev.Run.RunID != "5" { + t.Errorf("run context not read from env: %+v", ev.Run) + } + if ev.Summary != (notify.Summary{}) { + t.Errorf("config failures must not carry an unvalidated summary: %+v", ev.Summary) + } + if !reflect.DeepEqual(ev.Redact, []string{"http://SECRET-host:8123"}) { + t.Errorf("redaction list = %q, want exactly the raw CLICKHOUSE_URL", ev.Redact) + } +} + +func TestNotifyConfigFailure_WithoutValidWebhookIsNoop(t *testing.T) { + isolateSensitiveEnv(t) + calls := 0 + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + calls++ + _, _ = w.Write([]byte("ok")) + })) + defer srv.Close() + webhooks := swapNotifierFactory(t, srv) + logs := captureLogs(t) + + for _, value := range []string{"", "https://evil.example/services/x", "https://hooks.slack.com/triggers/x", "not a url"} { + t.Setenv("SLACK_WEBHOOK_URL", value) + notifyConfigFailure(context.Background(), errors.New("boom"), time.Second) + } + if len(*webhooks) != 0 || calls != 0 || logs.Len() != 0 { + t.Errorf("expected no notifier, no request and no log; got webhooks=%q calls=%d logs=%q", *webhooks, calls, logs.String()) + } +} + +func TestNotifyConfigFailure_PostsRedactedFailure(t *testing.T) { + isolateSensitiveEnv(t) + t.Setenv("SLACK_WEBHOOK_URL", "https://hooks.slack.com/services/T/B/X") + t.Setenv("CLICKHOUSE_URL", "http://SECRET-host:8123") + t.Setenv("MEND_PROJECT_UUIDS", "SECRET-uuid-a, SECRET-uuid-b") + t.Setenv("GITHUB_REPOSITORY", "org/repo") + t.Setenv("GITHUB_RUN_ID", "5") + t.Setenv("GITHUB_RUN_ATTEMPT", "1") + + var posted string + calls := 0 + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + calls++ + body, _ := io.ReadAll(r.Body) + posted = string(body) + _, _ = w.Write([]byte("ok")) + })) + defer srv.Close() + webhooks := swapNotifierFactory(t, srv) + logs := captureLogs(t) + + cause := errors.New("configuration error: sanitization failed: invalid UUID format for MEND_PROJECT_UUIDS: SECRET-uuid-b (after http://SECRET-host:8123)") + notifyConfigFailure(context.Background(), cause, time.Second) + + if calls != 1 || !reflect.DeepEqual(*webhooks, []string{"https://hooks.slack.com/services/T/B/X"}) { + t.Fatalf("calls = %d, webhooks = %q; want one notifier for the validated webhook and one request", calls, *webhooks) + } + if !strings.Contains(posted, "ClickBOM failed") || !strings.Contains(posted, "org/repo/actions/runs/5") { + t.Errorf("payload lacks the failure header or run link: %s", posted) + } + for _, leaked := range []string{"SECRET-host", "SECRET-uuid-b", "8123"} { + if strings.Contains(posted, leaked) { + t.Errorf("payload leaks %q: %s", leaked, posted) + } + } + for _, field := range []string{"*Source*", "*Output*", "*ClickHouse*"} { + if strings.Contains(posted, field) { + t.Errorf("config failures must not carry a summary field %s: %s", field, posted) + } + } + if strings.Contains(logs.String(), "SECRET") || strings.Contains(logs.String(), "notification failed") { + t.Errorf("unexpected log output: %s", logs.String()) + } +} + +func TestNotifyOutcome_NilNotifierIsNoop(t *testing.T) { + logs := captureLogs(t) + notifyOutcome(context.Background(), nil, notify.Event{}) + if logs.Len() != 0 { + t.Errorf("nil notifier must not log anything, got %q", logs.String()) + } +} + +func TestNotifyOutcome_LogsDeliveryFailureAndReturns(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(http.StatusBadRequest) + _, _ = w.Write([]byte("invalid_payload")) + })) + defer srv.Close() + logs := captureLogs(t) + + notifyOutcome(context.Background(), notify.NewSlackNotifier(srv.URL+"/services/T/B/X"), notify.Event{}) + + if !strings.Contains(logs.String(), "[WARNING]") || !strings.Contains(logs.String(), "Slack notification failed") || !strings.Contains(logs.String(), "invalid_payload") { + t.Errorf("delivery failure must be logged as a warning with Slack's token: %s", logs.String()) + } + if strings.Contains(logs.String(), "/services/T/B/X") { + t.Errorf("log leaks the webhook URL: %s", logs.String()) + } +} + +func TestNotifyOutcome_AbandonsHangingDelivery(t *testing.T) { + prev := notificationTimeout + notificationTimeout = 50 * time.Millisecond + t.Cleanup(func() { notificationTimeout = prev }) + + // The handler parks every request until the test releases it, so the + // only way notifyOutcome can return promptly is the deadline. + release := make(chan struct{}) + srv := httptest.NewServer(http.HandlerFunc(func(_ http.ResponseWriter, r *http.Request) { + _, _ = io.Copy(io.Discard, r.Body) + <-release + })) + defer srv.Close() + defer close(release) // runs before srv.Close, letting parked handlers finish + logs := captureLogs(t) + + start := time.Now() + notifyOutcome(context.Background(), notify.NewSlackNotifier(srv.URL+"/services/T/B/X"), notify.Event{}) + if elapsed := time.Since(start); elapsed > 5*time.Second { + t.Fatalf("notifyOutcome took %s; the deadline must abandon a hanging delivery", elapsed) + } + if !strings.Contains(logs.String(), "Slack notification failed") { + t.Errorf("abandoned delivery must be logged: %s", logs.String()) + } +} + +// panicErr is an error whose message cannot be rendered; it stands in for any +// unexpected failure inside the notifier. +type panicErr struct{} + +func (panicErr) Error() string { panic("boom SECRET_from_panic") } + +func TestNotifyOutcome_RecoversFromPanic(t *testing.T) { + calls := 0 + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + calls++ + _, _ = w.Write([]byte("ok")) + })) + defer srv.Close() + logs := captureLogs(t) + + notifyOutcome(context.Background(), notify.NewSlackNotifier(srv.URL+"/services/T/B/X"), notify.Event{Err: panicErr{}}) + + if calls != 0 { + t.Errorf("a panicking payload must not be posted, got %d requests", calls) + } + if !strings.Contains(logs.String(), "Slack notification failed: internal error") { + t.Errorf("recovered panic must be logged as a warning: %s", logs.String()) + } + if strings.Contains(logs.String(), "SECRET_from_panic") { + t.Errorf("the panic value must not be logged: %s", logs.String()) + } +} diff --git a/internal/config/config.go b/internal/config/config.go index 98b85d8..67edd10 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -2,8 +2,10 @@ package config import ( + "encoding/base64" "fmt" "os" + "strings" "github.com/ClickHouse/ClickBOM/internal/validation" ) @@ -73,6 +75,9 @@ type Config struct { // License mapping LicenseMappingFile string + + // Notifications + SlackWebhookURL string // Slack incoming webhook; a credential, never logged } // LoadConfig loads configuration from environment variables. @@ -141,6 +146,9 @@ func LoadConfig() (*Config, error) { Exclude: os.Getenv("EXCLUDE"), Debug: debug, LicenseMappingFile: getEnvOrDefault("LICENSE_MAPPING_FILE", "/app/license-mappings.json"), + + // Notifications + SlackWebhookURL: os.Getenv("SLACK_WEBHOOK_URL"), } // Sanitize inputs @@ -342,6 +350,15 @@ func (c *Config) Sanitize() error { if err := c.sanitizeURLs(); err != nil { return err } + + // The Slack webhook is a credential, so it has its own validator whose + // error never echoes the value (SanitizeURL quotes the URL it rejects). + if c.SlackWebhookURL != "" { + c.SlackWebhookURL, err = validation.SanitizeSlackWebhookURL(c.SlackWebhookURL) + if err != nil { + return err + } + } if err := c.sanitizeUUIDs(); err != nil { return err } @@ -390,3 +407,81 @@ func (c *Config) Sanitize() error { return nil } + +// sensitiveEnvVars lists every environment variable whose value the README +// marks Sensitive, plus the AWS credentials that arrive as job env rather than +// as inputs. Keep it in sync with the README input tables: these values are +// what the Slack notifier redacts from error text before posting. +var sensitiveEnvVars = []string{ + "GITHUB_TOKEN", + "MEND_EMAIL", "MEND_ORG_UUID", "MEND_USER_KEY", + "MEND_PROJECT_UUID", "MEND_PRODUCT_UUID", "MEND_ORG_SCOPE_UUID", "MEND_PROJECT_UUIDS", + "WIZ_AUTH_ENDPOINT", "WIZ_API_ENDPOINT", "WIZ_CLIENT_ID", "WIZ_CLIENT_SECRET", "WIZ_REPORT_ID", + "TRIVY_ECR_EXTERNAL_ID", + "AWS_ACCESS_KEY_ID", "AWS_SECRET_ACCESS_KEY", "AWS_SESSION_TOKEN", + "CLICKHOUSE_URL", "CLICKHOUSE_PASSWORD", + "SLACK_WEBHOOK_URL", +} + +// SecretsFromEnv returns the raw values of every sensitive environment +// variable. The one list-valued variable, MEND_PROJECT_UUIDS, contributes the +// whole value and each comma-separated element, because validation errors +// echo a single entry. It needs no Config, so it also serves runs whose +// configuration failed to load, and it should be called early: Trivy's +// AssumeRole rewrites the AWS_* variables mid-run. +func SecretsFromEnv() []string { + var out []string + for _, name := range sensitiveEnvVars { + v := os.Getenv(name) + out = appendNonEmpty(out, v) + if name == "MEND_PROJECT_UUIDS" { + out = appendNonEmpty(out, strings.Split(v, ",")...) + } + } + return out +} + +// SensitiveEnvVars returns a copy of the sensitive variable names so tests and +// tooling can isolate them. +func SensitiveEnvVars() []string { + return append([]string(nil), sensitiveEnvVars...) +} + +// Secrets returns every configuration value that must never appear in a +// notification: the sanitized sensitive fields (which may differ from the raw +// input) plus the raw environment values they came from. AWSAccessKeyID and +// AWSSecretAccessKey are not listed because LoadConfig never populates them; +// SecretsFromEnv covers the job's AWS credentials. +func (c *Config) Secrets() []string { + out := appendNonEmpty(nil, + c.GitHubToken, + c.MendEmail, c.MendOrgUUID, c.MendUserKey, + c.MendProjectUUID, c.MendProductUUID, c.MendOrgScopeUUID, + c.WizAuthEndpoint, c.WizAPIEndpoint, c.WizClientID, c.WizClientSecret, c.WizReportID, + c.TrivyECRExternalID, + c.ClickHouseURL, c.ClickHousePassword, + c.SlackWebhookURL, + ) + out = appendNonEmpty(out, strings.Split(c.MendProjectUUIDs, ",")...) + if c.ClickHousePassword != "" { + // Every ClickHouse request carries `Authorization: Basic `; an + // intermediary that echoes request headers would leak that spelling. + out = append(out, base64.StdEncoding.EncodeToString([]byte(c.ClickHouseUsername+":"+c.ClickHousePassword))) + } + return append(out, SecretsFromEnv()...) +} + +// SlackWebhookURLFromEnv validates SLACK_WEBHOOK_URL on its own, for the path +// where LoadConfig has already rejected the configuration as a whole. +func SlackWebhookURLFromEnv() (string, error) { + return validation.SanitizeSlackWebhookURL(os.Getenv("SLACK_WEBHOOK_URL")) +} + +func appendNonEmpty(out []string, values ...string) []string { + for _, v := range values { + if v = strings.TrimSpace(v); v != "" { + out = append(out, v) + } + } + return out +} diff --git a/internal/config/config_test.go b/internal/config/config_test.go index 12d6527..96d5d67 100644 --- a/internal/config/config_test.go +++ b/internal/config/config_test.go @@ -1,7 +1,12 @@ package config import ( + "encoding/base64" "os" + "reflect" + "slices" + "sort" + "strings" "testing" ) @@ -319,3 +324,153 @@ func containsAny(s, chars string) bool { } return false } + +func TestLoadConfig_SlackWebhookURL(t *testing.T) { + const good = "https://hooks.slack.com/services/T00000000/B00000000/XXXXXXXXXXXXXXXXXXXXXXXX" + + t.Run("absent leaves the field empty", func(t *testing.T) { + setEnv(t, map[string]string{ + "S3_BUCKET": "test-bucket", + "REPOSITORY": "owner/repo", + }) + cfg, err := LoadConfig() + if err != nil { + t.Fatalf("LoadConfig: %v", err) + } + if cfg.SlackWebhookURL != "" { + t.Errorf("SlackWebhookURL = %q, want empty", cfg.SlackWebhookURL) + } + }) + + t.Run("valid webhook is kept", func(t *testing.T) { + setEnv(t, map[string]string{ + "S3_BUCKET": "test-bucket", + "REPOSITORY": "owner/repo", + "SLACK_WEBHOOK_URL": " " + good + "\n", + }) + cfg, err := LoadConfig() + if err != nil { + t.Fatalf("LoadConfig: %v", err) + } + if cfg.SlackWebhookURL != good { + t.Errorf("SlackWebhookURL = %q, want %q", cfg.SlackWebhookURL, good) + } + }) + + t.Run("non-Slack host is rejected without echoing the value", func(t *testing.T) { + const marker = "SECRETMARKER123" + setEnv(t, map[string]string{ + "S3_BUCKET": "test-bucket", + "REPOSITORY": "owner/repo", + "SLACK_WEBHOOK_URL": "https://evil.example/services/" + marker, + }) + _, err := LoadConfig() + if err == nil { + t.Fatal("LoadConfig accepted a non-Slack webhook host") + } + if strings.Contains(err.Error(), marker) { + t.Errorf("error %q echoes the webhook URL", err.Error()) + } + }) +} + +func TestSecretsFromEnv(t *testing.T) { + setEnv(t, map[string]string{"S3_BUCKET": "public-bucket"}) + if got := SecretsFromEnv(); len(got) != 0 { + t.Fatalf("SecretsFromEnv() with no sensitive env = %q, want none", got) + } + setEnv(t, map[string]string{ + "MEND_PROJECT_UUIDS": " a1 ,, b2 ", + "GITHUB_TOKEN": "tok", + "AWS_SESSION_TOKEN": "sess", + "S3_BUCKET": "public-bucket", + }) + got := SecretsFromEnv() + sort.Strings(got) + // The list variable contributes the whole value and each entry. + want := []string{"a1", "a1 ,, b2", "b2", "sess", "tok"} + if !reflect.DeepEqual(got, want) { + t.Errorf("SecretsFromEnv() = %q, want %q", got, want) + } + + // Other variables are never split: a comma inside a password is part of it. + setEnv(t, map[string]string{"CLICKHOUSE_PASSWORD": "p,w"}) + if got := SecretsFromEnv(); !reflect.DeepEqual(got, []string{"p,w"}) { + t.Errorf("SecretsFromEnv() with a comma in a password = %q, want [p,w]", got) + } +} + +func TestConfigSecrets(t *testing.T) { + setEnv(t, map[string]string{ + "CLICKHOUSE_URL": "https://raw.example.com:8443/", + "AWS_ACCESS_KEY_ID": "AKIAEXAMPLE", + }) + cfg := &Config{ + GitHubToken: "tok", MendEmail: "m@example.com", MendOrgUUID: "org", MendUserKey: "ukey", + MendProjectUUID: "proj", MendProductUUID: "prod", MendOrgScopeUUID: "scope", MendProjectUUIDs: "u1, u2,", + WizAuthEndpoint: "https://auth.wiz.example", WizAPIEndpoint: "https://api.wiz.example", + WizClientID: "cid", WizClientSecret: "csec", WizReportID: "rep", + TrivyECRExternalID: "ext", ClickHouseURL: "https://raw.example.com:8443", ClickHousePassword: "pw", + SlackWebhookURL: "https://hooks.slack.com/services/T/B/X", + // Non-sensitive inputs must never be redacted, or the message becomes useless. + S3Bucket: "bucket", S3Key: "key.json", Repository: "o/r", ClickHouseDatabase: "db", ClickHouseUsername: "user", TrivyImage: "img:1", + } + got := cfg.Secrets() + set := map[string]bool{} + for _, v := range got { + if v == "" || v != strings.TrimSpace(v) { + t.Errorf("Secrets() contains an empty or untrimmed value %q", v) + } + set[v] = true + } + for _, want := range []string{ + "tok", "m@example.com", "org", "ukey", "proj", "prod", "scope", "u1", "u2", + "https://auth.wiz.example", "https://api.wiz.example", "cid", "csec", "rep", "ext", + "https://raw.example.com:8443", "pw", "https://hooks.slack.com/services/T/B/X", + // raw environment values, including the untrimmed URL + "https://raw.example.com:8443/", "AKIAEXAMPLE", + // the basic-auth spelling of the ClickHouse credentials + base64.StdEncoding.EncodeToString([]byte("user:pw")), + } { + if !set[want] { + t.Errorf("Secrets() is missing %q", want) + } + } + for _, public := range []string{"bucket", "key.json", "o/r", "db", "user", "img:1"} { + if set[public] { + t.Errorf("Secrets() wrongly contains non-sensitive value %q", public) + } + } + if got := (&Config{}).Secrets(); len(got) != 2 { + t.Errorf("empty Config with two sensitive env vars: Secrets() = %q, want exactly the env values", got) + } +} + +func TestSlackWebhookURLFromEnv(t *testing.T) { + setEnv(t, map[string]string{}) + if _, err := SlackWebhookURLFromEnv(); err == nil { + t.Error("expected an error when SLACK_WEBHOOK_URL is unset") + } + setEnv(t, map[string]string{"SLACK_WEBHOOK_URL": "https://hooks.slack.com/services/T/B/X "}) + got, err := SlackWebhookURLFromEnv() + if err != nil || got != "https://hooks.slack.com/services/T/B/X" { + t.Errorf("SlackWebhookURLFromEnv() = %q, %v", got, err) + } + setEnv(t, map[string]string{"SLACK_WEBHOOK_URL": "https://evil.example/services/SECRETMARKER"}) + if _, err := SlackWebhookURLFromEnv(); err == nil || strings.Contains(err.Error(), "SECRETMARKER") { + t.Errorf("invalid webhook: err = %v, want an error that withholds the value", err) + } +} + +func TestSensitiveEnvVars_ReturnsACopy(t *testing.T) { + got := SensitiveEnvVars() + for _, want := range []string{"SLACK_WEBHOOK_URL", "CLICKHOUSE_URL", "MEND_PROJECT_UUIDS", "AWS_SESSION_TOKEN"} { + if !slices.Contains(got, want) { + t.Errorf("SensitiveEnvVars() lacks %q", want) + } + } + got[0] = "MUTATED" + if slices.Contains(SensitiveEnvVars(), "MUTATED") { + t.Error("SensitiveEnvVars() must return a copy, not the package slice") + } +} diff --git a/internal/notify/slack.go b/internal/notify/slack.go new file mode 100644 index 0000000..c956281 --- /dev/null +++ b/internal/notify/slack.go @@ -0,0 +1,741 @@ +// Package notify posts a summary of a ClickBOM run to an external channel. +// Only Slack incoming webhooks are supported. +// +// Everything in this package is best effort: a notification failure is logged +// by the caller and never changes the outcome of the run. The webhook URL is a +// credential, so no code path here may log it or embed it in an error. +// +// Layering rule: values read from the environment reach the HTTP request only +// through an Event that cmd/clickbom builds. RunContextFromEnv lives here for +// convenience, but nothing on the path into post() may call it or os.Getenv: +// gosec's taint analysis (G704, run by golangci-lint in CI) flags an +// env-tainted request URL or body reaching http.Client.Do within one package. +package notify + +import ( + "bytes" + "context" + "encoding/json" + "errors" + "fmt" + "io" + "net/http" + "net/url" + "os" + "regexp" + "sort" + "strconv" + "strings" + "time" + "unicode/utf8" + + "github.com/ClickHouse/ClickBOM/pkg/logger" +) + +const ( + slackMaxAttempts = 3 + slackRetryDelay = 2 * time.Second + slackMaxRetryAfter = 30 * time.Second + slackTimeout = 15 * time.Second + + // Slack limits: 10 fields of 2000 characters per section and 3000 + // characters of section text. Escaping can grow a rune to five characters + // ("&"), so the rune caps below stay well inside those limits. + slackMaxFields = 10 + fieldMaxRunes = 300 + labelMaxRunes = 100 + slackMaxErrorRunes = 500 + + // minSecretRunes: shorter values are not redacted. Replacing a one- or + // two-character "secret" everywhere turns the text to mush and gives the + // value away through the pattern of the replacements. + minSecretRunes = 4 + + colorSuccess = "#2eb886" + colorFailure = "#a30200" + + blockSection = "section" + blockContext = "context" + textMrkdwn = "mrkdwn" + + defaultServerURL = "https://github.com" +) + +// RunContext is the non-secret GitHub Actions metadata about the workflow run, +// job and step that invoked ClickBOM. Every value comes from the default +// environment variables the runner injects into container actions, except +// JobCheckRunID, which action.yml passes in from the job-check-run-id input. +type RunContext struct { + ServerURL string + Repository string // owner/repo of the workflow, not of the SBOM + Workflow string + Job string // the job's key in the workflow file (GITHUB_JOB), not its name + Step string // the step id (GITHUB_ACTION); generated when the step has no id + ActionRepository string // ClickHouse/ClickBOM + ActionRef string // the ref the consumer pinned, e.g. v2.0.0 + RunID string + RunNumber string + RunAttempt string + JobCheckRunID string // job.check_run_id; empty on servers that do not provide it + EventName string + RefName string + HeadRef string // source branch of a pull request; RefName is "/merge" there + SHA string + Actor string + TriggeringActor string // who re-ran the workflow, when that differs from Actor + RunnerOS string +} + +// RunContextFromEnv reads the GitHub Actions default environment variables. +// Missing variables leave the field empty and are simply omitted from the +// message. +func RunContextFromEnv() RunContext { + return RunContext{ + ServerURL: serverURL(os.Getenv("GITHUB_SERVER_URL")), + Repository: os.Getenv("GITHUB_REPOSITORY"), + Workflow: os.Getenv("GITHUB_WORKFLOW"), + Job: os.Getenv("GITHUB_JOB"), + Step: os.Getenv("GITHUB_ACTION"), + ActionRepository: os.Getenv("GITHUB_ACTION_REPOSITORY"), + ActionRef: os.Getenv("GITHUB_ACTION_REF"), + RunID: os.Getenv("GITHUB_RUN_ID"), + RunNumber: os.Getenv("GITHUB_RUN_NUMBER"), + RunAttempt: os.Getenv("GITHUB_RUN_ATTEMPT"), + JobCheckRunID: os.Getenv("CLICKBOM_JOB_CHECK_RUN_ID"), + EventName: os.Getenv("GITHUB_EVENT_NAME"), + RefName: os.Getenv("GITHUB_REF_NAME"), + HeadRef: os.Getenv("GITHUB_HEAD_REF"), + SHA: os.Getenv("GITHUB_SHA"), + Actor: os.Getenv("GITHUB_ACTOR"), + TriggeringActor: os.Getenv("GITHUB_TRIGGERING_ACTOR"), + RunnerOS: os.Getenv("RUNNER_OS"), + } +} + +// serverURL accepts an http(s) origin (GITHUB_SERVER_URL never carries a +// path, credentials, query or fragment) and falls back to github.com +// otherwise, so the value can be spliced into a Slack link (`` +// breaks on `|` and `>`). +func serverURL(raw string) string { + raw = strings.TrimSpace(raw) + u, err := url.Parse(raw) + if err != nil || (u.Scheme != "https" && u.Scheme != "http") || u.Host == "" || u.User != nil || + (u.Path != "" && u.Path != "/") || u.RawQuery != "" || u.Fragment != "" || strings.ContainsAny(raw, "|<>") { + return defaultServerURL + } + return u.Scheme + "://" + u.Host +} + +// runURLRepository restricts the repository slug to characters that are safe +// inside a Slack link. +var runURLRepository = regexp.MustCompile(`^[A-Za-z0-9_.-]+/[A-Za-z0-9_.-]+$`) + +// runBase is "{server}/{repo}/actions/runs/{id}", or "" when the environment +// does not describe a run. +func (rc RunContext) runBase() string { + if !runURLRepository.MatchString(rc.Repository) || !isDigits(rc.RunID) { + return "" + } + return fmt.Sprintf("%s/%s/actions/runs/%s", rc.ServerURL, rc.Repository, rc.RunID) +} + +// RunURL links the workflow run, pointing at the specific attempt when the +// run was re-run. +func (rc RunContext) RunURL() string { + base := rc.runBase() + if base == "" { + return "" + } + if n, err := strconv.Atoi(rc.RunAttempt); err == nil && n > 1 { + base += fmt.Sprintf("/attempts/%d", n) + } + return base +} + +// JobURL links the job itself, which is what distinguishes matrix legs that +// share a job key. Each attempt's job has its own check run id, so no attempt +// segment is added. Returns "" when the id is unknown. +func (rc RunContext) JobURL() string { + base := rc.runBase() + if base == "" || !isDigits(rc.JobCheckRunID) { + return "" + } + return base + "/job/" + rc.JobCheckRunID +} + +// Link is the most specific link available: the job, else the run. +func (rc RunContext) Link() string { + if u := rc.JobURL(); u != "" { + return u + } + return rc.RunURL() +} + +func isDigits(s string) bool { + if s == "" { + return false + } + for _, r := range s { + if r < '0' || r > '9' { + return false + } + } + return true +} + +// Summary is the non-secret description of what ClickBOM was asked to do. +// The caller builds it from the configuration; nothing in it may derive from +// an input the README marks Sensitive. +type Summary struct { + Source string // github, mend, wiz, trivy, or merge + Target string // what was processed: owner/repo, an image, "project scope", merge filters + Format string // cyclonedx or spdxjson + Bucket string + Key string + ClickHouse string // "db.table", or "db" when the table name would be sensitive; "" when disabled +} + +// Event is one finished run. +type Event struct { + Run RunContext + Summary Summary + Err error // nil on success + Duration time.Duration + // Redact lists secret values that must never appear in the posted error + // text; see redact for the variants that are matched. + Redact []string +} + +// SlackNotifier posts Events to a Slack incoming webhook. +type SlackNotifier struct { + webhookURL string + client *http.Client + maxAttempts int + retryDelay time.Duration + sleep func(context.Context, time.Duration) error + now func() time.Time +} + +// NewSlackNotifier returns a notifier for webhookURL, or nil when the URL is +// empty. A nil *SlackNotifier is safe to use: Notify is a no-op. +func NewSlackNotifier(webhookURL string) *SlackNotifier { + if webhookURL == "" { + return nil + } + return &SlackNotifier{ + webhookURL: webhookURL, + client: &http.Client{ + Timeout: slackTimeout, + // Never follow a redirect: the webhook path is the credential and + // Go would re-send it (as URL and Referer) to wherever the + // response points. A 3xx surfaces as a permanent failure instead. + CheckRedirect: func(*http.Request, []*http.Request) error { + return http.ErrUseLastResponse + }, + }, + maxAttempts: slackMaxAttempts, + retryDelay: slackRetryDelay, + sleep: sleepContext, + now: time.Now, + } +} + +// Notify posts ev. Transient failures (network errors, 429, 5xx) are retried +// with a growing delay, honouring Retry-After up to a cap; any other status, +// including redirects, is permanent. The returned error never contains the +// webhook URL. +func (n *SlackNotifier) Notify(ctx context.Context, ev Event) error { + if n == nil { + return nil + } + body, err := json.Marshal(buildPayload(ev)) + if err != nil { + return fmt.Errorf("encode Slack payload: %w", err) + } + + var lastErr error + for attempt := 1; attempt <= n.maxAttempts; attempt++ { + retryAfter, retryable, err := n.post(ctx, body) + if err == nil { + logger.Success("Slack notification sent") + return nil + } + lastErr = err + if !retryable || attempt == n.maxAttempts { + break + } + delay := n.retryDelay * time.Duration(attempt) + if retryAfter > delay { + delay = retryAfter + } + if delay > slackMaxRetryAfter { + delay = slackMaxRetryAfter + } + logger.Warning("Slack notification attempt %d/%d failed: %v (retrying in %s)", attempt, n.maxAttempts, err, delay) + if err := n.sleep(ctx, delay); err != nil { + return err + } + } + return lastErr +} + +// post performs one webhook call. It reports whether a failure is worth +// retrying and any Retry-After the server asked for. +func (n *SlackNotifier) post(ctx context.Context, body []byte) (retryAfter time.Duration, retryable bool, err error) { + req, err := http.NewRequestWithContext(ctx, http.MethodPost, n.webhookURL, bytes.NewReader(body)) + if err != nil { + return 0, false, fmt.Errorf("build Slack request: %w", stripURL(err)) + } + req.Header.Set("Content-Type", "application/json") + + resp, err := n.client.Do(req) + if err != nil { + return 0, true, fmt.Errorf("post to Slack: %w", stripURL(err)) + } + defer func() { + if cerr := resp.Body.Close(); cerr != nil { + logger.Warning("Failed to close Slack response body: %v", cerr) + } + }() + + respBody, _ := io.ReadAll(io.LimitReader(resp.Body, 4096)) + reason := responseToken(respBody) + + switch { + case resp.StatusCode >= 200 && resp.StatusCode < 300: + return 0, false, nil + case resp.StatusCode == http.StatusTooManyRequests || resp.StatusCode >= 500: + return parseRetryAfter(resp.Header.Get("Retry-After"), n.now()), true, + fmt.Errorf("slack webhook returned status %d%s", resp.StatusCode, reason) + default: + return 0, false, fmt.Errorf("slack webhook returned status %d%s", resp.StatusCode, reason) + } +} + +// slackTokenRE matches Slack's short error bodies ("ok", "invalid_payload", +// "no_service", "channel_not_found", ...). +var slackTokenRE = regexp.MustCompile(`^[A-Za-z0-9_-]{1,64}$`) + +// responseToken quotes a Slack error token and withholds anything else: an +// intermediary's error page may echo the request path, which is the secret. +func responseToken(body []byte) string { + s := strings.TrimSpace(string(body)) + switch { + case s == "": + return "" + case slackTokenRE.MatchString(s): + return ": " + s + default: + return " (response body withheld)" + } +} + +// parseRetryAfter understands both forms of Retry-After: delay-seconds and an +// HTTP-date. Anything else means "no hint". +func parseRetryAfter(v string, now time.Time) time.Duration { + v = strings.TrimSpace(v) + if secs, err := strconv.Atoi(v); err == nil { + if secs <= 0 { + return 0 + } + return time.Duration(secs) * time.Second + } + if t, err := http.ParseTime(v); err == nil { + if d := t.Sub(now); d > 0 { + return d + } + } + return 0 +} + +// stripURL unwraps *url.Error so the webhook URL it quotes never reaches logs +// or error output. Same purpose as stripURL in internal/sbom/github.go, which +// protects pre-signed download URLs. +func stripURL(err error) error { + var uerr *url.Error + if errors.As(err, &uerr) { + return uerr.Err + } + return err +} + +// sleepContext is a context-aware time.Sleep (also in internal/sbom/github.go). +func sleepContext(ctx context.Context, d time.Duration) error { + t := time.NewTimer(d) + defer t.Stop() + select { + case <-ctx.Done(): + return ctx.Err() + case <-t.C: + return nil + } +} + +// Slack message model. The header lives in top-level blocks so `text` is only +// the notification fallback; the details sit in a colour-coded attachment. +// The link is mrkdwn rather than a Block Kit button because a `url` button +// still sends an interaction payload that a webhook-only app cannot answer. +type payload struct { + Text string `json:"text"` + Blocks []block `json:"blocks"` + Attachments []attachment `json:"attachments,omitempty"` + UnfurlLinks bool `json:"unfurl_links"` + UnfurlMedia bool `json:"unfurl_media"` +} + +type attachment struct { + Color string `json:"color"` + Blocks []block `json:"blocks"` +} + +type block struct { + Type string `json:"type"` + Text *text `json:"text,omitempty"` + Fields []text `json:"fields,omitempty"` + Elements []text `json:"elements,omitempty"` +} + +type text struct { + Type string `json:"type"` + Text string `json:"text"` + // Verbatim stops Slack from auto-linking URLs and parsing mentions in the + // text; set on the error block, whose content is not ours. + Verbatim bool `json:"verbatim,omitempty"` +} + +func mrkdwn(s string) *text { return &text{Type: textMrkdwn, Text: s} } + +// buildPayload renders ev as a Slack message. Every value that came from the +// environment, the configuration or an error is escaped so it cannot inject +// mrkdwn links, mentions or formatting. +func buildPayload(ev Event) payload { + status, emoji, color := "succeeded", ":white_check_mark:", colorSuccess + if ev.Err != nil { + status, emoji, color = "failed", ":x:", colorFailure + } + + label := runLabel(ev.Run) + header := fmt.Sprintf("%s *ClickBOM %s*", emoji, status) + fallback := "ClickBOM " + status + if label != "" { + fallback += ": " + label + if link := ev.Run.Link(); link != "" { + header += fmt.Sprintf(" in <%s|%s>", link, slackEscape(label)) + } else { + header += " in " + slackEscape(label) + } + } + + var fields []text + fields = appendField(fields, "Workflow", ev.Run.Workflow) + fields = appendField(fields, "Job", ev.Run.Job) + fields = appendField(fields, "Step", ev.Run.Step) + fields = appendField(fields, "Trigger", trigger(ev.Run)) + fields = appendField(fields, "Source", joinNonEmpty(" · ", ev.Summary.Source, ev.Summary.Target)) + fields = appendField(fields, "Output", output(ev.Summary)) + fields = appendField(fields, "ClickHouse", ev.Summary.ClickHouse) + if ev.Duration > 0 { + fields = appendField(fields, "Duration", ev.Duration.Round(time.Second).String()) + } + + var details []block + if len(fields) > 0 { + details = append(details, block{Type: blockSection, Fields: fields}) + } + if ev.Err != nil { + details = append(details, block{Type: blockSection, Text: &text{ + Type: textMrkdwn, + Text: "```" + slackEscape(errorText(ev.Err, ev.Redact)) + "```", + Verbatim: true, + }}) + } + if footer := footer(ev.Run); footer != "" { + details = append(details, block{Type: blockContext, Elements: []text{{Type: textMrkdwn, Text: slackEscape(footer)}}}) + } + + p := payload{ + Text: slackEscape(fallback), + Blocks: []block{{Type: blockSection, Text: mrkdwn(header)}}, + } + if len(details) > 0 { + p.Attachments = []attachment{{Color: color, Blocks: details}} + } + return p +} + +// appendField adds a "*Name*\nvalue" field, skipping empty values, capping the +// value length and honouring Slack's limit of ten fields per section. +func appendField(fields []text, name, value string) []text { + value = strings.TrimSpace(value) + if value == "" || len(fields) >= slackMaxFields { + return fields + } + return append(fields, text{Type: textMrkdwn, Text: "*" + name + "*\n" + slackEscape(truncateRunes(value, fieldMaxRunes))}) +} + +// runLabel is "owner/repo · workflow #12 (attempt 2)" with missing parts left +// out. Repository and workflow names are capped so the header (which also +// carries the link) stays inside Slack's 3000-character section limit. +func runLabel(rc RunContext) string { + label := joinNonEmpty(" · ", truncateRunes(rc.Repository, labelMaxRunes), truncateRunes(rc.Workflow, labelMaxRunes)) + if rc.RunNumber != "" { + label = joinNonEmpty(" ", label, "#"+rc.RunNumber) + } + if n, err := strconv.Atoi(rc.RunAttempt); err == nil && n > 1 { + label = joinNonEmpty(" ", label, fmt.Sprintf("(attempt %d)", n)) + } + return label +} + +// trigger is "push on main @ abc1234 by octocat" with missing parts left out. +// Pull requests show their source branch rather than "/merge", and a re-run +// names the person who re-ran it. +func trigger(rc RunContext) string { + parts := []string{rc.EventName} + ref := rc.RefName + if rc.HeadRef != "" { + ref = rc.HeadRef + } + if ref != "" { + parts = append(parts, "on "+ref) + } + if rc.SHA != "" { + parts = append(parts, "@ "+shortSHA(rc.SHA)) + } + actor := rc.Actor + if n, err := strconv.Atoi(rc.RunAttempt); err == nil && n > 1 && rc.TriggeringActor != "" { + actor = rc.TriggeringActor + } + if actor != "" { + parts = append(parts, "by "+actor) + } + return joinNonEmpty(" ", parts...) +} + +func shortSHA(sha string) string { + if len(sha) > 7 { + return sha[:7] + } + return sha +} + +func output(s Summary) string { + if s.Bucket == "" { + return "" + } + out := "s3://" + s.Bucket + if s.Key != "" { + out += "/" + s.Key + } + if s.Format != "" { + out += " (" + s.Format + ")" + } + return out +} + +// footer names the ClickBOM release that ran and the runner OS. Both are +// absent for local (`uses: ./`) and docker:// references, in which case the +// footer is omitted rather than rendered as "@". +func footer(rc RunContext) string { + var parts []string + if rc.ActionRepository != "" { + action := rc.ActionRepository + if rc.ActionRef != "" { + action += "@" + rc.ActionRef + } + parts = append(parts, action) + } + if rc.RunnerOS != "" { + parts = append(parts, rc.RunnerOS) + } + return joinNonEmpty(" · ", parts...) +} + +func joinNonEmpty(sep string, parts ...string) string { + kept := make([]string, 0, len(parts)) + for _, p := range parts { + if p != "" { + kept = append(kept, p) + } + } + return strings.Join(kept, sep) +} + +var slackEscaper = strings.NewReplacer("&", "&", "<", "<", ">", ">") + +// slackEscape applies the three escapes Slack requires in mrkdwn text. The +// replacer handles all three in one pass, so "&" is never double-escaped. +func slackEscape(s string) string { return slackEscaper.Replace(s) } + +// errorText prepares an error for the fenced failure block: the first line +// only (tool output dumps follow the first newline), scrubbed and redacted, +// capped, and with code fences neutralised so it cannot close the block. +func errorText(err error, secrets []string) string { + msg := err.Error() + if i := strings.IndexAny(msg, "\r\n"); i >= 0 { + msg = msg[:i] + } + msg = truncateRunes(redact(msg, secrets), slackMaxErrorRunes) + for strings.Contains(msg, "```") { + msg = strings.ReplaceAll(msg, "```", "'''") + } + if strings.TrimSpace(msg) == "" { + return "(no details)" + } + return msg +} + +var ( + urlRE = regexp.MustCompile(`https?://[^\s"'<>` + "`" + `]+`) + // authHeaderRE matches "Bearer " and "Basic " as echoed request + // headers; looksLikeCredential keeps prose such as "basic validation" intact. + authHeaderRE = regexp.MustCompile(`(?i)\b(bearer|basic)\s+([A-Za-z0-9._~+/=-]+)`) + // ipPortRE matches the socket addresses in *net.OpError text ("dial tcp + // 3.226.14.9:8443", "[2600::1]:8443", "[fe80::1%en0]:8443"): the resolved + // address of a host that may itself be sensitive, which no configured value + // can match. + ipPortRE = regexp.MustCompile(`\b(?:\d{1,3}\.){3}\d{1,3}:\d{1,5}\b|\[[0-9a-fA-F:.]+(?:%(?:25)?[A-Za-z0-9_.-]+)?\]:\d{1,5}`) + awsKeyIDRE = regexp.MustCompile(`\b(?:AKIA|ASIA)[0-9A-Z]{16}\b`) + githubTokenRE = regexp.MustCompile(`\b(?:gh[pousr]_[A-Za-z0-9]{20,}|github_pat_[A-Za-z0-9_]{20,})\b`) + kvSecretRE = regexp.MustCompile(`(?i)\b(X-Amz-Signature|X-Amz-Credential|X-Amz-Security-Token|signature|sig|token|access_token|password)=[^\s&"'<>]+`) +) + +// scrub removes credential shapes that no configuration value can predict: +// the userinfo and query string of every URL (pre-signed download URLs carry +// their signature there), bearer and basic auth headers, socket addresses, +// AWS access key ids, GitHub tokens and signature-like key=value pairs. +func scrub(s string) string { + s = urlRE.ReplaceAllStringFunc(s, func(raw string) string { + u, err := url.Parse(raw) + if err != nil || u.Host == "" { + return "***" + } + return u.Scheme + "://" + u.Host + u.EscapedPath() + }) + s = scrubAuthHeaders(s) + s = ipPortRE.ReplaceAllLiteralString(s, "***") + s = awsKeyIDRE.ReplaceAllLiteralString(s, "***") + s = githubTokenRE.ReplaceAllLiteralString(s, "***") + return kvSecretRE.ReplaceAllString(s, "$1=***") +} + +// scrubAuthHeaders blanks the token of an echoed Authorization header while +// leaving ordinary prose ("basic validation", "bearer token") untouched. +func scrubAuthHeaders(s string) string { + return authHeaderRE.ReplaceAllStringFunc(s, func(m string) string { + parts := authHeaderRE.FindStringSubmatch(m) + if len(parts) != 3 || !looksLikeCredential(parts[2]) { + return m + } + return strings.ToUpper(parts[1][:1]) + strings.ToLower(parts[1][1:]) + " ***" + }) +} + +// looksLikeCredential tells a token from an English word: tokens are long or +// carry digits, punctuation or a capital letter after the first character. +func looksLikeCredential(tok string) bool { + if len(tok) >= 20 { + return true + } + for i, r := range tok { + switch { + case r >= '0' && r <= '9', strings.ContainsRune("._~+/=-", r): + return true + case r >= 'A' && r <= 'Z' && i > 0: + return true + } + } + return false +} + +// redact scrubs s and then replaces every secret with "***". Longer needles +// are applied first so a secret that contains another one is removed whole; +// matching ignores ASCII case because hosts and hex identifiers may be +// re-cased by the libraries that formatted the error. +func redact(s string, secrets []string) string { + s = scrub(s) + needles := expandSecrets(secrets) + sort.Slice(needles, func(i, j int) bool { return len(needles[i]) > len(needles[j]) }) + for _, needle := range needles { + s = replaceFold(s, needle, "***") + } + return s +} + +// replaceFold replaces every occurrence of needle in s with repl, ignoring +// ASCII case. It works on bytes instead of compiling a regexp per needle: a +// needle holding invalid UTF-8 (a binary secret) must never panic, and the +// regexp panic message would have quoted the secret. +func replaceFold(s, needle, repl string) string { + if needle == "" || len(needle) > len(s) { + return s + } + var b strings.Builder + for i := 0; i < len(s); { + if hasPrefixFold(s[i:], needle) { + b.WriteString(repl) + i += len(needle) + continue + } + b.WriteByte(s[i]) + i++ + } + return b.String() +} + +// hasPrefixFold reports whether s starts with prefix, ignoring ASCII case. +func hasPrefixFold(s, prefix string) bool { + if len(s) < len(prefix) { + return false + } + for i := 0; i < len(prefix); i++ { + a, c := s[i], prefix[i] + if 'A' <= a && a <= 'Z' { + a += 'a' - 'A' + } + if 'A' <= c && c <= 'Z' { + c += 'a' - 'A' + } + if a != c { + return false + } + } + return true +} + +// expandSecrets adds, for each secret, its trailing-slash-trimmed form, its +// query- and path-escaped forms and, when it parses as a URL, its host with +// and without port. Empty and very short values are dropped so nothing +// matches everywhere. +func expandSecrets(secrets []string) []string { + seen := make(map[string]bool) + var out []string + keep := func(v string) { + v = strings.TrimSpace(v) + if utf8.RuneCountInString(v) < minSecretRunes || seen[v] { + return + } + seen[v] = true + out = append(out, v) + } + for _, secret := range secrets { + secret = strings.TrimSpace(secret) + keep(secret) + keep(strings.TrimRight(secret, "/")) + keep(url.QueryEscape(secret)) + keep(url.PathEscape(secret)) + if u, err := url.Parse(secret); err == nil && u.Scheme != "" && u.Host != "" { + keep(u.Host) + keep(u.Hostname()) + } + } + return out +} + +func truncateRunes(s string, n int) string { + if utf8.RuneCountInString(s) <= n { + return s + } + runes := []rune(s) + return string(runes[:n]) + "…" +} diff --git a/internal/notify/slack_test.go b/internal/notify/slack_test.go new file mode 100644 index 0000000..1dd4992 --- /dev/null +++ b/internal/notify/slack_test.go @@ -0,0 +1,846 @@ +package notify + +import ( + "bytes" + "context" + "encoding/json" + "errors" + "io" + "log" + "net/http" + "net/http/httptest" + "strconv" + "strings" + "testing" + "time" +) + +func sampleRun() RunContext { + return RunContext{ + ServerURL: "https://github.com", + Repository: "ClickHouse/sbom", + Workflow: "Weekly SBOMs", + Job: "github-prod", + Step: "__ClickHouse_ClickBOM", + ActionRepository: "ClickHouse/ClickBOM", + ActionRef: "v2.0.0", + RunID: "123456789", + RunNumber: "42", + RunAttempt: "1", + JobCheckRunID: "987654321", + EventName: "schedule", + RefName: "main", + SHA: "0123456789abcdef0123456789abcdef01234567", + Actor: "octocat", + RunnerOS: "Linux", + } +} + +func sampleSummary() Summary { + return Summary{ + Source: "github", + Target: "ClickHouse/ClickBOM", + Format: "cyclonedx", + Bucket: "my-sbom-bucket", + Key: "clickbom.json", + ClickHouse: "default.clickhouse_clickbom", + } +} + +const webhookPath = "/services/T000/B000/SECRET" + +// newTestNotifier points a notifier at srv with instant, recorded sleeps and a +// fixed clock. +func newTestNotifier(srv *httptest.Server, slept *[]time.Duration) *SlackNotifier { + n := NewSlackNotifier(srv.URL + webhookPath) + n.client.Transport = srv.Client().Transport + n.sleep = func(_ context.Context, d time.Duration) error { + *slept = append(*slept, d) + return nil + } + n.now = func() time.Time { return time.Date(2026, 9, 29, 12, 0, 0, 0, time.UTC) } + return n +} + +// captureLogs redirects the package logger for the duration of the test. +func captureLogs(t *testing.T) *bytes.Buffer { + t.Helper() + var buf bytes.Buffer + prev := log.Writer() + log.SetOutput(&buf) + t.Cleanup(func() { log.SetOutput(prev) }) + return &buf +} + +func TestRunContextFromEnv(t *testing.T) { + env := map[string]string{ + "GITHUB_SERVER_URL": "https://github.example.com/", + "GITHUB_REPOSITORY": "org/repo", + "GITHUB_WORKFLOW": "CI", + "GITHUB_JOB": "build", + "GITHUB_ACTION": "clickbom", + "GITHUB_ACTION_REPOSITORY": "ClickHouse/ClickBOM", + "GITHUB_ACTION_REF": "v2.1.0", + "GITHUB_RUN_ID": "99", + "GITHUB_RUN_NUMBER": "7", + "GITHUB_RUN_ATTEMPT": "2", + "CLICKBOM_JOB_CHECK_RUN_ID": "555", + "GITHUB_EVENT_NAME": "pull_request", + "GITHUB_REF_NAME": "12/merge", + "GITHUB_HEAD_REF": "feature/x", + "GITHUB_SHA": "abcdef1234567890", + "GITHUB_ACTOR": "octocat", + "GITHUB_TRIGGERING_ACTOR": "rerunner", + "RUNNER_OS": "Linux", + } + for k, v := range env { + t.Setenv(k, v) + } + got := RunContextFromEnv() + want := RunContext{ + ServerURL: "https://github.example.com", Repository: "org/repo", Workflow: "CI", Job: "build", + Step: "clickbom", ActionRepository: "ClickHouse/ClickBOM", ActionRef: "v2.1.0", RunID: "99", + RunNumber: "7", RunAttempt: "2", JobCheckRunID: "555", EventName: "pull_request", RefName: "12/merge", + HeadRef: "feature/x", SHA: "abcdef1234567890", Actor: "octocat", TriggeringActor: "rerunner", RunnerOS: "Linux", + } + if got != want { + t.Errorf("RunContextFromEnv() = %+v, want %+v", got, want) + } +} + +func TestServerURL(t *testing.T) { + tests := map[string]string{ + "": defaultServerURL, + "https://github.com": "https://github.com", + "https://ghe.example.com/": "https://ghe.example.com", + " http://ghe.internal:8080 ": "http://ghe.internal:8080", + "not a url": defaultServerURL, + "ftp://ghe.example.com": defaultServerURL, + "https://user@ghe.example.com": defaultServerURL, + "https://ghe.example.com/?q=1": defaultServerURL, + "https://ghe.example.com/#frag": defaultServerURL, + "https://ghe.example.com/a|b": defaultServerURL, + "https://ghe.example.com/x>y": defaultServerURL, + } + for in, want := range tests { + if got := serverURL(in); got != want { + t.Errorf("serverURL(%q) = %q, want %q", in, got, want) + } + } +} + +func TestRunURL(t *testing.T) { + tests := []struct { + name string + rc RunContext + want string + }{ + {name: "first attempt", rc: RunContext{ServerURL: "https://github.com", Repository: "o/r", RunID: "1", RunAttempt: "1"}, want: "https://github.com/o/r/actions/runs/1"}, + {name: "no attempt given", rc: RunContext{ServerURL: "https://github.com", Repository: "o/r", RunID: "1"}, want: "https://github.com/o/r/actions/runs/1"}, + {name: "re-run links the attempt", rc: RunContext{ServerURL: "https://github.com", Repository: "o/r", RunID: "1", RunAttempt: "3"}, want: "https://github.com/o/r/actions/runs/1/attempts/3"}, + {name: "enterprise server", rc: RunContext{ServerURL: "https://ghe.example.com", Repository: "o/r", RunID: "1"}, want: "https://ghe.example.com/o/r/actions/runs/1"}, + {name: "missing repository", rc: RunContext{ServerURL: "https://github.com", RunID: "1"}, want: ""}, + {name: "missing run id", rc: RunContext{ServerURL: "https://github.com", Repository: "o/r"}, want: ""}, + {name: "non-numeric run id", rc: RunContext{ServerURL: "https://github.com", Repository: "o/r", RunID: "1|evil"}, want: ""}, + {name: "repository with link-breaking chars", rc: RunContext{ServerURL: "https://github.com", Repository: "o/r|", RunID: "1"}, want: ""}, + } + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + if got := tc.rc.RunURL(); got != tc.want { + t.Errorf("RunURL() = %q, want %q", got, tc.want) + } + }) + } +} + +func TestJobURLAndLink(t *testing.T) { + rc := RunContext{ServerURL: "https://github.com", Repository: "o/r", RunID: "1", RunAttempt: "2", JobCheckRunID: "77"} + if got := rc.JobURL(); got != "https://github.com/o/r/actions/runs/1/job/77" { + t.Errorf("JobURL() = %q; the job link must not carry an attempt segment", got) + } + if got := rc.Link(); got != rc.JobURL() { + t.Errorf("Link() = %q, want the job URL", got) + } + + rc.JobCheckRunID = "" + if got := rc.JobURL(); got != "" { + t.Errorf("JobURL() without an id = %q, want empty", got) + } + if got := rc.Link(); got != "https://github.com/o/r/actions/runs/1/attempts/2" { + t.Errorf("Link() without a job id = %q, want the run attempt URL", got) + } + + rc.JobCheckRunID = "77|evil" + if got := rc.JobURL(); got != "" { + t.Errorf("JobURL() with a non-numeric id = %q, want empty", got) + } + if got := (RunContext{JobCheckRunID: "77"}).JobURL(); got != "" { + t.Errorf("JobURL() without a run = %q, want empty", got) + } +} + +func TestNewSlackNotifier(t *testing.T) { + if NewSlackNotifier("") != nil { + t.Fatal("expected nil notifier for empty URL") + } + var nilNotifier *SlackNotifier + if err := nilNotifier.Notify(context.Background(), Event{}); err != nil { + t.Fatalf("nil notifier Notify() = %v, want nil", err) + } + n := NewSlackNotifier("https://hooks.slack.com/services/x") + if n == nil || n.maxAttempts != slackMaxAttempts || n.client.Timeout != slackTimeout { + t.Fatalf("NewSlackNotifier returned %+v", n) + } + if n.client.CheckRedirect == nil || n.client.CheckRedirect(nil, nil) != http.ErrUseLastResponse { + t.Error("client must refuse to follow redirects") + } +} + +// fieldMap turns a fields section into name -> value. +func fieldMap(t *testing.T, b block) map[string]string { + t.Helper() + out := map[string]string{} + for _, f := range b.Fields { + name, value, ok := strings.Cut(f.Text, "\n") + if !ok { + t.Fatalf("field %q has no name/value separator", f.Text) + } + out[strings.Trim(name, "*")] = value + } + return out +} + +func TestBuildPayload_Success(t *testing.T) { + p := buildPayload(Event{Run: sampleRun(), Summary: sampleSummary(), Duration: 83*time.Second + 400*time.Millisecond}) + + if p.Text != "ClickBOM succeeded: ClickHouse/sbom · Weekly SBOMs #42" { + t.Errorf("fallback text = %q", p.Text) + } + if p.UnfurlLinks || p.UnfurlMedia { + t.Error("unfurling must be disabled") + } + if len(p.Blocks) != 1 || p.Blocks[0].Text == nil { + t.Fatalf("expected one header block, got %+v", p.Blocks) + } + wantHeader := ":white_check_mark: *ClickBOM succeeded* in " + if got := p.Blocks[0].Text.Text; got != wantHeader { + t.Errorf("header = %q\nwant %q", got, wantHeader) + } + if len(p.Attachments) != 1 || p.Attachments[0].Color != colorSuccess { + t.Fatalf("attachments = %+v", p.Attachments) + } + blocks := p.Attachments[0].Blocks + if len(blocks) != 2 || blocks[0].Type != blockSection || blocks[1].Type != blockContext { + t.Fatalf("expected [section, context] blocks, got %+v", blocks) + } + fields := fieldMap(t, blocks[0]) + want := map[string]string{ + "Workflow": "Weekly SBOMs", + "Job": "github-prod", + "Step": "__ClickHouse_ClickBOM", + "Trigger": "schedule on main @ 0123456 by octocat", + "Source": "github · ClickHouse/ClickBOM", + "Output": "s3://my-sbom-bucket/clickbom.json (cyclonedx)", + "ClickHouse": "default.clickhouse_clickbom", + "Duration": "1m23s", + } + for k, v := range want { + if fields[k] != v { + t.Errorf("field %s = %q, want %q", k, fields[k], v) + } + } + if len(fields) != len(want) { + t.Errorf("fields = %v, want exactly %d", fields, len(want)) + } + if got := blocks[1].Elements[0].Text; got != "ClickHouse/ClickBOM@v2.0.0 · Linux" { + t.Errorf("context = %q", got) + } + + raw, err := json.Marshal(p) + if err != nil { + t.Fatalf("marshal: %v", err) + } + for _, want := range []string{`"unfurl_links":false`, `"unfurl_media":false`} { + if !strings.Contains(string(raw), want) { + t.Errorf("JSON lacks %s: %s", want, raw) + } + } + if strings.Contains(string(raw), `"verbatim"`) { + t.Errorf("success payload must not mark text verbatim: %s", raw) + } +} + +func TestBuildPayload_FailureRedactsAndEscapes(t *testing.T) { + run := sampleRun() + run.RunAttempt = "2" + run.TriggeringActor = "rerunner" + run.RefName = "feature/bold&co" + err := errors.New(`failed to upload to ClickHouse: Post "https://ch.example.com:8443/?query=INSERT+INTO+default.mend_dead_beef": dial tcp: lookup CH.EXAMPLE.COM: no such host; token=ghp_abc123