Skip to content

Add Slack-formatted drift alerts for the control-plane webhook#219

Merged
seonghobae merged 1 commit into
control-planefrom
slack-webhook
Jul 6, 2026
Merged

Add Slack-formatted drift alerts for the control-plane webhook#219
seonghobae merged 1 commit into
control-planefrom
slack-webhook

Conversation

@seonghobae

Copy link
Copy Markdown
Contributor

What

The control plane already POSTs a drift alert to an org's webhook when a scan introduces new deploy-blocking findings (_send_alert in add_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 offending rule_id/file pairs with a +N more overflow line. Text is Slack-escaped (& < >) and trimmed to Block Kit length caps.
  • _send_alert chooses the format internally: Slack URL → Block Kit; any other URL → the unchanged generic payload (backward compatible).
  • add_scan now keeps the list of newly-introduced blocking findings and passes it plus the org name to _send_alert so the Slack card can name the offending rules/files.

Ponytail: no new dependency (stdlib urllib only), no config system, backward-compatible.

Tests

Extended tests/test_controlplane.py (no network — builder called directly / urlopen monkeypatched):

  • _is_slack_webhook host matching (rejects hooks.slack.com.evil…).
  • _slack_blocks shape/content, top-5 cap + +N more, and & < > escaping.
  • _send_alert: Slack URL → payload has blocks and contains org + count; generic URL → payload equals the original (no blocks).
  • Updated the existing drift-alert monkeypatch tests for the new keyword args.

Full suite: 242 passed.

Note on base branch

appguardrail_core/controlplane.py does not exist on develop yet — the control plane ships via the stacked control-plane integration branch (umbrella PR #198; RBAC #201 and pagination #202 already merged into it). This PR therefore targets control-plane, matching how #201 was merged, so it reaches develop together with the rest of the control plane.

🤖 Generated with Claude Code

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 seonghobae merged commit f5ec926 into control-plane Jul 6, 2026
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>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant