Add Slack-formatted drift alerts for the control-plane webhook#219
Merged
Conversation
When an org's drift-alert webhook URL is a Slack Incoming Webhook (host hooks.slack.com), render the alert as a Block Kit message: a header with the new deploy-blocking count, a fields section for org / new blockers / repo / scan id, and a section listing the top 5 offending rule_id/file pairs with a "+N more" overflow line. Text is Slack-escaped and length-trimmed to Block Kit caps. Any non-Slack URL still receives the existing generic JSON payload unchanged (backward compatible). The format is chosen inside _send_alert via host detection; add_scan now passes the org name and the list of newly-introduced blocking findings through so the Slack renderer can name them. No new dependency (stdlib urllib only). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01EywwS2Du8pimW7xqRP3An3
seonghobae
added a commit
that referenced
this pull request
Jul 10, 2026
…ed) (#198) * Add `appguardrail serve`: multi-tenant control-plane API (the platform seed) The $2B unlock a CLI can't be: a persistent, multi-tenant surface. Instead of a one-shot scan, an org pushes every CI scan and queries its history/trend — the recurring-revenue backbone. Stdlib only (sqlite3 + http.server), ships in the same wheel, no dependency. - appguardrail_core/controlplane.py: SQLite store (orgs + scans), API-key auth (sha256-hashed), tenant-scoped add/list/get, and an HTTP API. Counts and deploy-blocking are computed from findings on ingest. - `appguardrail serve [--db --host --port] [--create-org NAME]`; bootstraps a default org + API key on an empty DB. - Endpoints: POST/GET /api/v1/scans, GET /api/v1/scans/{id}, GET /api/v1/health; Authorization: Bearer <key>. - Tests: tests/test_controlplane.py (store, counts, tenant isolation, health, ingest+history round-trip, 401 auth, 400 bad body) + module self-check. - Verified: full suite 215 passed; e2e provision -> POST scan -> GET history, 401 on missing/wrong key, cross-tenant reads blocked. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01EywwS2Du8pimW7xqRP3An3 * Add org console UI to the control plane Gives the control-plane API a user-facing surface: an org's security posture over time, not just JSON endpoints. - scanner/dashboard/console.html: single static page (no framework/build) that connects with an API key and renders scan history, the deploy-blocking trend (per-scan bars), and per-scan finding detail. Reuses the design tokens; all interpolation HTML-escaped. - controlplane.py: the server serves the console at `/`, `/console`, `/index.html` (packaged via importlib.resources, dashboard/*.html). - Tests: console served at root (200 + marker). - Verified: full suite 216 passed; puppeteer e2e — connect with key, history + trend + detail render, a CRITICAL finding shows in scan detail. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01EywwS2Du8pimW7xqRP3An3 * Close the loop: `scan --push` + drift detection in the control plane Turns the platform into continuous monitoring: CI pushes each scan and the control plane reports what got *worse* since last time. - controlplane.py: add_scan computes `new_blocking` — deploy-blocking findings new since this org+repo's previous scan (line-independent fingerprint), stored and returned. list/get expose it. - `appguardrail scan --push <url>`: POST normalized findings to /api/v1/scans (key from APPGUARDRAIL_API_KEY; repo/commit from GITHUB_REPOSITORY/GITHUB_SHA). Missing key / push failure degrades gracefully and never fails the scan. - console.html: surfaces "New since last scan" as a stat and a per-scan drift column. - Tests: drift (first scan all-new, moved-line not-new, newly-introduced counted, per-repo baseline). - Verified: full suite 217 passed; e2e scan --push -> control plane ingests with drift; missing key skips gracefully. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01EywwS2Du8pimW7xqRP3An3 * Wire the monitor workflow to push scans to the control plane Completes the CI integration: the installed `appguardrail monitor` workflow now pushes each scan to a control plane when configured, so history + drift populate automatically — with zero change for users who don't set it up. - MONITOR_WORKFLOW: job-level CP_URL from the APPGUARDRAIL_CONTROL_PLANE_URL secret; the scan step adds `--push $CP_URL` only when it's set (SARIF upload + deploy gate unchanged). APPGUARDRAIL_API_KEY passed from secrets. - Updated the monitor-workflow test to assert the push wiring. - Verified: full suite 217 passed; `appguardrail monitor` installs the workflow with the conditional push step. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01EywwS2Du8pimW7xqRP3An3 * Add drift-alert webhook: notify when a scan introduces new blockers Closes detect -> alert. Drift was visible but passive; now the control plane pushes an alert the moment CI introduces a new deploy-blocking finding, which is the action teams actually pay for. - controlplane.py: orgs gain a webhook_url; set_webhook() and a POST /api/v1/webhook endpoint set it. add_scan fires _send_alert() (best-effort, never fails ingest) with an event payload only when new_blocking > 0. - Tests: alert fires on new blockers and not on a moved/unchanged finding, no webhook -> no delivery, API sets the webhook. - Verified: full suite 220 passed; e2e against a local receiver — alerts land only for scans that introduce new blockers. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01EywwS2Du8pimW7xqRP3An3 * Add RBAC: role-scoped API keys (viewer/member/owner) for the control plane (#201) Multi-tenant needs multi-user: an org shares one key today, so CI, a read-only dashboard, and an admin all hold the same all-powerful credential. This adds per-key roles. - controlplane.py: `keys` table (org_id, key_hash, role, label). create_key() issues a role-scoped key; role_for_key() resolves (org_id, role) and treats the bootstrap key as owner; has_role() enforces viewer<member<owner. create_org now seeds an owner key. - API enforcement: GET (read) = any role; POST /scans = member+; POST /webhook and POST /keys = owner. New POST /api/v1/keys mints role-scoped keys. - Tests: role resolution + has_role ordering, and the full API role matrix (viewer read-only, member ingest-only, owner full) via the live server. - Verified: full suite 236 passed; e2e role matrix (viewer/member/owner) enforced. Claude-Session: https://claude.ai/code/session_01EywwS2Du8pimW7xqRP3An3 Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com> * Add API pagination + trend endpoint to the control plane (#202) Scan history grows unbounded; the API returned everything and the console had no first-class trend series. Adds paging and a chart-ready trend. - controlplane.py: list_scans() gains an offset; new scan_trend() returns an oldest->newest deploy_blocking/new_blocking series. GET /api/v1/scans accepts ?limit=&offset=; new GET /api/v1/scans/trend?limit=. - Tests: pagination offset windows + trend ordering/shape, store and API level. - Verified: full suite passed; e2e limit/offset paging + trend series. Claude-Session: https://claude.ai/code/session_01EywwS2Du8pimW7xqRP3An3 Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com> * Add Slack-formatted drift alerts for the control-plane webhook (#219) When an org's drift-alert webhook URL is a Slack Incoming Webhook (host hooks.slack.com), render the alert as a Block Kit message: a header with the new deploy-blocking count, a fields section for org / new blockers / repo / scan id, and a section listing the top 5 offending rule_id/file pairs with a "+N more" overflow line. Text is Slack-escaped and length-trimmed to Block Kit caps. Any non-Slack URL still receives the existing generic JSON payload unchanged (backward compatible). The format is chosen inside _send_alert via host detection; add_scan now passes the org name and the list of newly-introduced blocking findings through so the Slack renderer can name them. No new dependency (stdlib urllib only). Claude-Session: https://claude.ai/code/session_01EywwS2Du8pimW7xqRP3An3 Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com> * Harden control-plane API: body cap + query clamps (#242) Adversarial review of the control plane found two real gaps, both reachable by any holder of a valid key: - Request bodies were unbounded: a huge Content-Length OOMs the server, and a negative one makes rfile.read(-1) block until EOF. Now capped at 10MiB and negatives rejected (400) before reading. - limit/offset query ints were passed through unclamped: sqlite treats LIMIT -1 as *no limit*, so ?limit=-1 dumped the org's entire scan history, bypassing the pagination cap. Now clamped (list 1..1000, trend 1..365, offset >= 0). Reviewed-and-cleared in the same pass: role validation on key creation (safe fallback to member), unknown-role handling in has_role (rank -1), key lookup by sha256 hash (no timing-sensitive comparison), tenant isolation on all reads (existing tests). Tests: 3 new (limit=-1 / huge limit+negative offset clamped; 50MiB body -> 400; negative Content-Length -> 400). Full suite 245 passed. Claude-Session: https://claude.ai/code/session_01EywwS2Du8pimW7xqRP3An3 Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com> * fix(controlplane): hash API keys with scrypt instead of SHA-256 SHA-256 is a fast, non-memory-hard hash unsuitable for hashing secrets: if the control-plane store leaks, an attacker can brute-force API keys at billions of guesses per second. Switch _hash_key to hashlib.scrypt, a memory-hard KDF from the stdlib, with a fixed application salt so the hash stays deterministic and keys remain findable by indexed equality lookup. Resolves CodeQL py/weak-sensitive-data-hashing (high). --------- Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
What
The control plane already POSTs a drift alert to an org's webhook when a scan introduces new deploy-blocking findings (
_send_alertinadd_scan). This makes that alert render as a readable Slack Block Kit message when the webhook is a Slack Incoming Webhook, while keeping the existing generic JSON payload for every other URL.How
_is_slack_webhook(url)— detects Slack by host (hooks.slack.com), no config/flag needed._slack_blocks(org, payload, new_findings)— builds the Block Kit dict: a header with the new-blocker count, a fields section (org / new blockers / repo / scan id), and a section listing the top 5 offendingrule_id/file pairs with a+N moreoverflow line. Text is Slack-escaped (& < >) and trimmed to Block Kit length caps._send_alertchooses the format internally: Slack URL → Block Kit; any other URL → the unchanged generic payload (backward compatible).add_scannow keeps the list of newly-introduced blocking findings and passes it plus the org name to_send_alertso the Slack card can name the offending rules/files.Ponytail: no new dependency (stdlib
urllibonly), no config system, backward-compatible.Tests
Extended
tests/test_controlplane.py(no network — builder called directly /urlopenmonkeypatched):_is_slack_webhookhost matching (rejectshooks.slack.com.evil…)._slack_blocksshape/content, top-5 cap ++N more, and& < >escaping._send_alert: Slack URL → payload hasblocksand contains org + count; generic URL → payload equals the original (noblocks).Full suite: 242 passed.
Note on base branch
appguardrail_core/controlplane.pydoes not exist ondevelopyet — the control plane ships via the stackedcontrol-planeintegration branch (umbrella PR #198; RBAC #201 and pagination #202 already merged into it). This PR therefore targetscontrol-plane, matching how #201 was merged, so it reachesdeveloptogether with the rest of the control plane.🤖 Generated with Claude Code