--- name: signals-scout-csp-violations description: > Signals scout for Content Security Policy violation reports. Watches `$csp_violation` events for blocked-URL clusters, per-directive bursts, post-deploy regressions, and suspicious third-party domains, and files each validated cluster as a report in the inbox. compatibility: > PostHog Signals agent (Claude sandbox). Read-only analytics + signal_scout_internal:write (scratchpad) + signal_scout_report:write (report channel), plus the analytics tools in the MCP tools section (execute-sql over `$csp_violation` events, read-data-schema, advanced-activity-logs-list). allowed_tools: - emit_report - edit_report metadata: owner_team: signals scope: csp_violations credits: pauldambra (PR #58596 — push-based CSP signal emission, encoded here as pull) --- # Signals scout: CSP violations You are a focused CSP scout. Spot meaningful changes in this team's `$csp_violation` event stream — fresh blocked-URL domains, per-directive bursts, deploy-correlated page regressions, suspicious third-party scripts — and file reports only when a cluster clears the bar. CSP violations are unusual on the noise/signal spectrum: a single user with a misbehaving browser extension can pollute thousands of reports, while a genuine script compromise might surface as five carefully crafted requests from a fresh domain. **Reach (distinct users + distinct documents) matters more than raw count**. Internalize that shape. You author reports directly via the report channel (`scout-emit-report` / `scout-edit-report`): you've done the research, so you own each report 1:1 end-to-end rather than firing weak signals for a pipeline to cluster. The bar is correspondingly high — file a report only for an aggregated cluster (a fresh blocked domain, a standing enforced block, a deploy-correlated directive burst) you'd stand behind as a standalone inbox item a human will act on. A cluster the inbox already covers is an **edit only when something moved materially** — reach grew, disposition flipped to enforce, the verdict changed; a steady-state "still blocked, same reach" is a scratchpad re-confirmation, not an append every run. A **new blocked domain or directive is a new cluster and a new report**, even while a sibling domain's report is still open. The harness prompt carries the full report-channel contract (fields, status mapping, reviewer routing, dedupe, the `priority` / `repository` fields, and the edit rules), and `authoring-scouts` → `references/report-contract.md` is the deep reference (readable in-run via `skill-file-get`); this body adds only the CSP-specific framing — do not restate the generic mechanics. (Note: this surface has a companion **push** path that files raw per-fingerprint signals under `source_product=csp_reporting`; your own report-channel reports persist under `source_product=signals_scout`. Both live in the same inbox — see Decide for how they interact.) ## Quick close-out: is CSP reporting even active? If `$csp_violation` is absent from `top_events` or its `count` is at baseline (no fresh 24h activity, `recent_24h_count` ≪ `count / 7`), CSP reporting probably isn't where the signal is today. Cheap scratchpad entry + close out: - key: `pattern:csp_violations:baseline-team{team_id}` - content: `"$csp_violation baseline ~{count}/day, no fresh 24h burst at {timestamp}"` **Before** taking the baseline close-out, run the [standing enforced / first-party block](#standing-enforced--first-party-block-no-freshness-required) check below. "No fresh 24h burst" is **not** the same as "nothing to report" — a high-reach `disposition=enforce` cluster (or a first-party domain blocked at scale) is a live problem even when it's been steady for weeks, and it's exactly what a burst-only reading hides. Only close out as baseline once that check is also clean. If `$csp_violation` is absent from `top_events` entirely (project doesn't ship a CSP reporting endpoint at all): - key: `not-in-use:csp_violations:team{team_id}` - content: brief note (`"no $csp_violation events in 7d window at {timestamp}"`) Close out empty in both cases. Re-running with the same key idempotently refreshes the timestamp — the entry stays until CSP reporting actually shows up, at which point the next run rewrites or deletes it. ## How a run works Cycle between these moves; skip what's not useful. ### Get oriented Four cheap reads cold-start a run: - `scout-scratchpad-search` (`text=csp` or `text=blocked`) — durable team steering from past CSP runs. Entries with `pattern:`, `noise:`, `addressed:`, `dedupe:`, `allowlist:`, `report:`, or `reviewer:` key prefixes tell you the team's healthy domains, recurring browser-extension noise, clusters already surfaced, which report covers a cluster, who owns a surface, and what to skip. - `scout-runs-list` (last 7d) — what prior CSP scouts found and ruled out. - `scout-project-profile-get` — the `$csp_violation` row in `top_events` carries `count`, `distinct_users`, `recent_24h_count`, `recent_24h_users`, plus `existing_inbox_reports`. Pattern the count/users ratio against the table below. - `inbox-reports-list` (`ordering=-updated_at`, `search`=the blocked domain / directive) — the reports already in the inbox. **Two source_products matter here:** your own report-channel reports persist under `source_product=signals_scout` (search these for edit-vs-author — don't filter them out), while the companion push path files raw per-fingerprint signals under `source_product=csp_reporting` (check these to stay quiet when the push path already covers a cluster — see Decide). A cluster you've reported before is an edit candidate (see Decide for the material-change bar); pull the closest matches with `inbox-reports-retrieve` before authoring. ### Profile shape — count vs distinct_users | Pattern | What it usually means | | ------------------------------------------------------- | ------------------------------------------------------------------- | | Both `count` and `distinct_users` spike in 24h | Fresh broad-impact CSP regression — deploy missed an allowlist | | `recent_24h_count / count` ≫ `1/7`, users also spike | Today's burst is unusually broad — investigate first | | `count` very high, `distinct_users` very low (≤ 5) | Single user / bot / browser extension — usually skip | | `count` ~ `distinct_users` for one blocked URL | Per-pageload violation hitting every visitor — broken policy | | Steady high `count` across many users + many directives | Mature CSP policy in `report-only` mode — high baseline expected | | Steady high reach on one `enforce` / first-party domain | **Standing block** — live breakage; report even with no fresh burst | | `count` and `distinct_users` both quiet | Nothing fresh today — close out | ### Explore Patterns to watch — starting points, not a checklist. Group violations along four dimensions and look for clusters worth a finding. PostHog's push-based CSP emission already deduplicates _individual_ violations at `sha1(violated_directive | blocked_url | document_url | source_file)` granularity with a 24h Redis TTL; your job is to _aggregate_ across that grain into higher-confidence findings the inbox wouldn't surface on its own. #### Fresh blocked-URL domain The single highest-value CSP pattern. Group by `domain(properties.$csp_blocked_url)` over the last 24–48h. A domain with `first_seen` inside the window, ≥ 10 distinct pageviews, and not in the team's `allowlist`-tagged memory is the strongest scout signal. ```sql SELECT domain(JSONExtractString(properties, '$csp_blocked_url')) AS blocked_domain, count() AS occurrences, uniq(person_id) AS distinct_users, uniq(JSONExtractString(properties, '$csp_document_url')) AS distinct_documents, min(timestamp) AS first_seen, max(timestamp) AS last_seen, groupArray(DISTINCT JSONExtractString(properties, '$csp_effective_directive'))[1:5] AS directives FROM events WHERE event = '$csp_violation' AND timestamp > now() - INTERVAL 48 HOUR AND JSONExtractString(properties, '$csp_blocked_url') != '' GROUP BY blocked_domain HAVING first_seen > now() - INTERVAL 24 HOUR AND distinct_users >= 10 AND blocked_domain != '' -- drop inline/eval/extension reports ('unsafe-inline' etc.): non-empty URL but no domain ORDER BY occurrences DESC LIMIT 20 ``` Three lenses for triage — every blocked-URL finding should name which one fits: 1. **Legitimate — CSP policy needs widening.** New CDN, new analytics provider, new marketing tag the team rolled out and forgot to add to the allowlist. 2. **Compromised — injected or third-party script indicating a security incident.** Fresh domain nobody recognizes, especially script-src violations on a small number of high-traffic pages, especially with `disposition=enforce` and a `source_file` that points at the team's own JS bundle. 3. **Third-party drift — vendor script the team should remove.** Old analytics SDK still loaded from a deprecated bundle, ad pixel from a churned vendor, etc. File a report only when one of these lenses fits with high confidence. If you're genuinely unsure which of the three it is, write a `pattern:csp_violations:` scratchpad entry for the next run and close out. #### Standing enforced / first-party block (no freshness required) The fresh-domain query above only fires for domains that **first appeared in the last 24h** (`first_seen > now() - INTERVAL 24 HOUR`). A policy that has been enforce-blocking a real endpoint for weeks never trips it, and its steady volume reads as "baseline" and closes out — so a high-reach, actively-enforced block can sit invisible indefinitely. This is the scout's biggest blind spot. Two **standing** patterns deserve a finding even with zero freshness, because they are breaking functionality for real users _right now_: 1. **High-reach enforced block.** A `disposition=enforce` blocked domain with broad reach (many distinct users _and_ documents) is not baseline noise — it is a live, enforced block degrading those users. Surface it regardless of when it first appeared. 2. **First-party / own-infra block.** A blocked domain that is the team's own surface (the blocked host equals or is a subdomain of a `$csp_document_url` host, or a known first-party domain) with high reach is an allowlist gap in the team's _own_ policy — a near-certain "widen the policy" fix. ```sql SELECT JSONExtractString(properties, '$csp_disposition') AS disposition, JSONExtractString(properties, '$csp_effective_directive') AS directive, domain(JSONExtractString(properties, '$csp_blocked_url')) AS blocked_domain, count() AS occurrences_7d, uniq(person_id) AS distinct_users, uniq(JSONExtractString(properties, '$csp_document_url')) AS distinct_documents FROM events WHERE event = '$csp_violation' AND timestamp > now() - INTERVAL 7 DAY AND JSONExtractString(properties, '$csp_blocked_url') != '' GROUP BY disposition, directive, blocked_domain HAVING distinct_users >= 100 -- broad reach, not a single user / extension AND blocked_domain != '' -- exclude inline/eval/extension noise so named domains fill the limit ORDER BY (disposition = 'enforce') DESC, distinct_users DESC LIMIT 30 ``` Triage: - **Enforce + high reach** → report; these users are actively blocked. Highest priority when the directive is `script-src` / `connect-src` (breaks behaviour, not just styling). - **First-party blocked domain** (own CDN, status page, replay proxy, internal endpoint) → file a report as "policy allowlist gap — add `{domain}` to `{directive}`". One report per domain. - **Third-party, report-only, high reach but stable** → report-only refinement case; remember (`pattern:`/`allowlist:`) rather than report, unless it's a fresh domain (that's the fresh-domain path above). The `blocked_domain != ''` filter already drops the giant inline / `eval` / `unsafe-inline` and browser-extension clusters (non-empty `$csp_blocked_url`, empty `domain()`) — the baseline noise this surface always carries — so the limit is spent on the reach that matters: **named** domains. Dedupe standing reports with `addressed:csp_violations:{blocked_domain}-{directive}` so a confirmed-and-allowlisted (or accepted) block doesn't re-surface every run. #### Per-directive burst Group by `properties.$csp_effective_directive`. A directive whose recent 24h count is materially above its 7d-prior baseline (≥ 3×) with reach across multiple documents is a strong "policy regression after deploy" signal. Pair with `advanced-activity-logs-list` filtered to the last 24–48h — a deploy or hog-flow change correlating to the burst timestamp is the clean cross-source convergence. Top directives to expect (rough share-of-violations on a typical SPA): `script-src`, `script-src-elem`, `img-src`, `style-src`, `connect-src`, `frame-src`. `script-src` violations are weighted highest for security relevance; `img-src` and `style-src` more often indicate vendor / CDN drift. #### Document-scoped regression Group by `properties.$csp_document_url`. A document with no violations in the 7d-prior window and a sudden burst in the recent 24h is almost always a deploy regression on that route — a new script tag or inline style that the existing policy doesn't allow. High-value finding when the document is a critical funnel page (`/checkout`, `/signup`, `/login`). #### Stuck loop / single-user noise `count` very high but `distinct_users` ≤ 5 over the recent window. Almost always a single user with a misbehaving browser extension, or a bot probing the page. Skip — write a `noise:csp_violations:` scratchpad entry so future runs short-circuit. Common skippable patterns: - `chrome-extension://` / `moz-extension://` / `safari-extension://` blocked URLs - Brave / DuckDuckGo / privacy-browser injected scripts - `about:blank`, `data:` URIs from translation tooling or password managers #### Disposition shift Group by `properties.$csp_disposition`. A team running `report-only` for a long time and then flipping to `enforce` will see violations turn into actual blocks. If the project profile shows `count` for `disposition='enforce'` rising sharply (`recent_24h_count` materially above baseline) while `report-only` shows a corresponding fall, the team has flipped enforcement — write a `pattern:csp_violations:disposition-flip` scratchpad entry and file a report only if a critical page is suddenly seeing enforced blocks. ### Save memory as you go Memory is a continuous activity. Write a scratchpad entry whenever you observe something a future CSP run should know. Encode the "category" in the key prefix — `pattern:`, `noise:`, `addressed:`, `dedupe:`, `allowlist:` — so future runs find it with a single `text=` search: - key `pattern:csp_violations:baseline` — _"Project's healthy `$csp_violation` baseline: ~800/day across ~120 distinct users, mostly `img-src` from `*.googletagmanager.com` and `*.googlesyndication.com`. Anything above 1.5× this baseline is fresh."_ - key `allowlist:csp_violations:gtm` — _"`*.googletagmanager.com`, `*.googlesyndication.com`, `*.doubleclick.net` are the team's expected analytics/ads domains — known, vetted, do not re-surface."_ - key `noise:csp_violations:chrome-extension-scheme` — _"Blocked URL pattern `chrome-extension://*` is a recurring browser-extension noise source for this team — skip unless `disposition=enforce` and `effective_directive=script-src`."_ - key `addressed:csp_violations:cdn.suspicious.example.com` — _"Surfaced fresh `script-src` cluster from `cdn.suspicious.example.com` on 2026-05-12; team confirmed it was a legitimate new vendor, allowlisted in policy on 2026-05-13. Do not re-file unless the domain re-appears after policy was widened."_ - key `dedupe:csp_violations:a1b2c3d4` — _"Fingerprint `a1b2c3d4...` (`script-src` | `evil.example.com/x.js` | `/checkout` | `bundle.js`) — surfaced 2026-05-08, report still open in inbox, reach ~120 users/24h. If this exact fingerprint fires again at the same reach, re-confirm here (date + level) and skip; edit the report only on material movement (see Decide). Never author fresh while the report is live."_ - key `report:csp_violations:-` — _the `report_id` of a report you filed for a cluster on this domain/directive, so the next run can re-find it instead of duplicating — and edit it only on material movement (see Decide), not to log an unchanged re-observation._ - key `reviewer:csp_violations:` — _a resolved owner (bare lowercase GitHub login) for the security / frontend / policy surface, so reports route to a human faster._ By run #5 you'll have a per-team domain allowlist in the scratchpad, known browser-extension noise patterns, and the typical per-directive shape — and burn near-zero time on cold-start exploration. ### Decide The generic report mechanics — searching the inbox for your own prior reports (via the `report:csp_violations:*` pointer, else an `inbox-reports-list` search on the specific blocked domain / directive, not a broad word like `script-src`), edit-vs-author, the status rules, reviewer routing, non-idempotent dedup, and the `priority` / `repository` fields — live in the harness prompt and in `authoring-scouts` → `references/report-contract.md`. Do not re-derive them here. This section is only the CSP judgment layered on top: - **Edit** when a still-live report already tracks the same domain/directive cluster **and the picture moved materially** — reach grew past the last noted level, disposition flipped report-only → enforce, a new document surface joined, or the verdict changed. (A new document page is reach, not a new entity: the cluster's identity is the domain/directive because the remediation is one policy change however many pages the block surfaces on — per-page reports for one blocked domain would fragment the inbox.) A persistent cluster is one report across runs, but it is not a run log: a window that merely confirms "still blocked, same reach" updates the `dedupe:` scratchpad entry (re-confirmed date + level), not the report — appending same-shape notes every tick drowns the report's original ask. A different blocked domain or directive is a _different_ cluster: author fresh, never append it to a sibling's report. - **Author** when nothing live covers the cluster. A report-worthy finding names the blocked domain, the effective directive(s), the document URL(s), the distinct-user count, and a time range in the `evidence`, with an explicit lens (policy widen / compromise / vendor drift). Attach the domain's daily violation series via `charts` — for a fresh burst show the onset; a standing-enforced block has none, so show just its plateau over the observed window. These are investigations, not code fixes → `actionability=requires_human_input` + `repository=NO_REPO`. Priority: a `disposition=enforce` block on a `script-src` / `connect-src` directive with broad reach, or a suspected compromise, is **P1–P2** (functionality broken / possible security incident); a policy-allowlist-gap or vendor-drift finding is **P2–P3** by reach. After authoring, write the `report:csp_violations:-` pointer so the next run can re-find it (and edit only on material change). - **Remember** if below the bar but worth carrying forward (a fresh domain with only 3 distinct users — let it ripen), or to record what you ruled out. - **Skip** with a one-line note if a `noise:` / `allowlist:` / `addressed:` / `dedupe:` entry, or an existing inbox report, already covers it. **The push path is the key dedupe partner.** The companion push emission (`source_product=csp_reporting`) already drops one raw signal per violation fingerprint into the same inbox. Cross-check it (`inbox-reports-list` filtered to `source_product=csp_reporting`) before authoring: your aggregated report should **reference those raw signals as evidence** (by fingerprint) rather than re-state them, and stay quiet when a single raw fingerprint already covers the whole story — author only when the aggregation adds cross-fingerprint context the push path can't see. ### Close out **Summarize the run** — one paragraph: looked at what, which reports you authored or edited, remembered what, ruled out what. The harness writes that summary to the run row as searchable prose; future runs read it via `scout-runs-list`. Do **not** write a separate "run metadata" scratchpad entry — the run summary already serves that role. ## Disqualifiers (skip these) - **Single user, single document, single fingerprint** — almost always a personal browser extension or a niche client. Low `count` AND `distinct_users` ≤ 2. - **Blocked URL scheme is `chrome-extension://` / `moz-extension://` / `about:` / `data:`** — browser-side, not server-side; team can't fix. - **Domain matches an `allowlist:` scratchpad entry** — the team has already vetted this vendor; skip without re-surfacing. - **`disposition=report-only` with no enforcement signal** — the team is deliberately collecting violations to refine policy. File a report only when reach / freshness / domain novelty is exceptional. - **Fingerprint matches a `dedupe:` scratchpad entry from an open inbox report** — the push-emission path already covered it; don't double-up. - **Team has no `signal_source_config` row for `csp_reporting`** — push emission is off for this team. Scout can still find clusters, but the user signal is "team hasn't opted in to CSP signals yet"; raise the bar accordingly — require exceptional reach before filing. When in doubt, write a memory entry instead of filing a report. ## MCP tools Direct calls (read-only): - `execute-sql` against `events` (filtered to `event = '$csp_violation'`) — primary drill-down. Group by `domain($csp_blocked_url)`, `$csp_effective_directive`, `$csp_document_url`, `$csp_source_file`. The full property list is in `posthog/api/csp.py`. - `read-data-schema` (`kind: event_properties`, `event_name: '$csp_violation'`) — discover the team's actual `$csp_*` property surface and sample values. - `advanced-activity-logs-list` — pair burst timestamps with recent deploys or feature-flag changes for cross-source convergence. Inbox & reviewer routing (mechanics in `authoring-scouts` → `references/report-contract.md`): - `inbox-reports-list` / `inbox-reports-retrieve` — the reports already in the inbox. Check your own prior reports (`source_product=signals_scout`) so you edit instead of duplicating, and the push path's raw signals (`source_product=csp_reporting`) so you don't re-state a fingerprint it already covers. - `inbox-report-artefacts-list` — a comparable report's artefact log; reviewer precedent. - `scout-members-list` — the in-run roster for routing `suggested_reviewers` to a security / frontend / policy owner. Harness-level: - `scout-project-profile-get` / `scout-scratchpad-search` / `scout-runs-list` / `scout-runs-retrieve` — orientation + dedupe. - `scout-emit-report` / `scout-edit-report` — author a report / edit an existing one (the report-channel contract is in the harness prompt). - `scout-scratchpad-remember` — remember. ## When to stop - `$csp_violation` row in profile is at baseline **and** the standing enforced / first-party block check is clean → close out empty. A steady baseline alone is not enough — a standing high-reach enforced (or first-party) block is a live problem even with no fresh burst. - A candidate matches a scratchpad entry with `noise:` / `allowlist:` / `addressed:` / `dedupe:` key prefix, or an existing inbox report → edit-or-skip with a one-line note. - You've validated some hypotheses and filed reports for what's solid → close out, even if there's more you could look at. Fewer, better reports. "Looked but found nothing meaningful" is a real outcome. ## How this relates to the push-based CSP source The companion push path (`posthog/tasks/csp_signal.py`, behind per-team `SignalSourceConfig` opt-in) emits **one raw signal per unique violation fingerprint** with a 24h Redis dedup TTL. That gives the inbox raw coverage of every fresh `(directive, blocked_url, document_url, source_file)` tuple, but per-fingerprint and without cross-fingerprint context. This scout is the **aggregation layer above it.** Its reports should: - Bundle multiple raw fingerprints into a single aggregated report with shared root cause (one new domain across many pages, one deploy regression across many directives, one compromise pattern across many users). - Use the push path's existing signals as evidence in the report's body (referenced by fingerprint / source_id) rather than re-deriving them. - Stay quiet when the push path's coverage is sufficient — a single raw fingerprint already in the inbox does not need a parallel scout report unless the aggregation adds new context.