
Signals Scout for CSP Violations
FreeMonitor and report on CSP violations effectively.
Free · Opens the source repo
What Signals Scout for CSP Violations does
Signals Scout is a specialized tool designed to help developers and security teams monitor and report on Content Security Policy (CSP) violations. By focusing on the $csp_violation event stream, it identifies significant changes such as new blocked URL domains, bursts of violations per directive, and potential regressions following deployments. This helps teams maintain a robust security posture by ensuring that only meaningful violations are reported, thereby reducing noise in the reporting process.
The skill operates by analyzing clusters of CSP violations based on user reach and document distinctiveness, rather than just raw counts. This approach allows for a more nuanced understanding of the impact of CSP violations, ensuring that reports are filed only for clusters that meet a high threshold of significance. When a new blocked domain or directive is detected, Signals Scout facilitates the creation of a report that can be acted upon, thereby streamlining the response to potential security issues.
Users can interact with the skill through a series of commands that guide them through the reporting process. The skill emphasizes the importance of thorough research and ownership of reports, ensuring that each report filed is backed by solid evidence and analysis. This is particularly useful for teams that need to prioritize their response to CSP violations, as it allows them to focus on the most pressing issues while keeping their reporting inbox manageable.
Overall, Signals Scout is tailored for developers and security professionals who need to keep a close eye on CSP violations and want to ensure that their reporting is both effective and efficient. By filtering out noise and focusing on significant events, this skill helps teams maintain a secure web environment without being overwhelmed by irrelevant data.
When to use it
Use this skill when you need to monitor CSP violations and generate actionable reports based on meaningful data.
When not to use it
This skill may not be suitable if your project does not implement CSP reporting or if there are no active CSP violations to monitor.
What you can build with it
Monitoring CSP Violations Post-Deployment
After a new deployment, use Signals Scout to check for any new CSP violations that may have emerged, ensuring that security is maintained.
Identifying Suspicious Third-Party Domains
Leverage Signals Scout to spot and report on any suspicious third-party domains that are triggering CSP violations.
Streamlining Security Reporting
Use Signals Scout to filter out noise and focus on significant CSP violations, creating a more manageable reporting process.
How to install Signals Scout for CSP Violations
View source1. Install with the skills CLI
npx skills add posthog/posthog/signals-scout-csp-violations --agent claude-code2. Or install it manually
Download the skill folder and drop it into ~/.claude/skills/ for all projects, or .claude/skills/ to scope it to one repo. Restart Claude Code so it picks up the new skill.
Anthropic's agentic coding CLI, and the reference implementation of Agent Skills. Drop a skill folder into ~/.claude/skills and Claude Code loads it automatically whenever a task matches the skill's description. Claude Code docs
Inside SKILL.md
Written by posthogSignals 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 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=csportext=blocked) — durable team steering from past CSP runs. Entries withpattern:,noise:,addressed:,dedupe:,allowlist:,report:, orreviewer: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_violationrow intop_eventscarriescount,distinct_users,recent_24h_count,recent_24h_users, plusexisting_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 undersource_product=signals_scout(search these for edit-vs-author — don't filter them out), while the companion push path files raw per-fingerprint signals undersource_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 withinbox-reports-retrievebefore 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.
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:
- Legitimate — CSP policy needs widening. New CDN, new analytics provider, new marketing tag the team rolled out and forgot to add to the allowlist.
- 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=enforceand asource_filethat points at the team's own JS bundle. - 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:<entity> 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:
- High-reach enforced block. A
disposition=enforceblocked 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. - 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_urlhost, 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.
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:<blocked_domain> 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_violationbaseline: ~800/day across ~120 distinct users, mostlyimg-srcfrom*.googletagmanager.comand*.googlesyndication.com. Anything above 1.5× this baseline is fresh." - key
allowlist:csp_violations:gtm— "*.googletagmanager.com,*.googlesyndication.com,*.doubleclick.netare the team's expected analytics/ads domains — known, vetted, do not re-surface." - key
noise:csp_violations:chrome-extension-scheme— "Blocked URL patternchrome-extension://*is a recurring browser-extension noise source for this team — skip unlessdisposition=enforceandeffective_directive=script-src." - key
addressed:csp_violations:cdn.suspicious.example.com— "Surfaced freshscript-srccluster fromcdn.suspicious.example.comon 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— "Fingerprinta1b2c3d4...(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:<blocked_domain>-<directive>— thereport_idof 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:<area>— 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 viacharts— 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: adisposition=enforceblock on ascript-src/connect-srcdirective 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 thereport:csp_violations:<domain>-<directive>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
countANDdistinct_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-onlywith 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_configrow forcsp_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-sqlagainstevents(filtered toevent = '$csp_violation') — primary drill-down. Group bydomain($csp_blocked_url),$csp_effective_directive,$csp_document_url,$csp_source_file. The full property list is inposthog/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 inauthoring-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 routingsuggested_reviewersto 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_violationrow 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.
Frequently asked questions about Signals Scout for CSP Violations
Similar skills
GitHub Actions Hardening
Enhance the security of your GitHub Actions workflows.
Sensitive Logging Audit
Audit and fix sensitive data exposure in Python logging.
Android App Static Analysis
Automate security assessments of Android apps with MobSF.
Integrating DAST with OWASP ZAP
Seamlessly integrate dynamic security testing into CI/CD pipelines.
Implementing Runtime Security with Tetragon
Enhance Kubernetes security with eBPF-based observability.
Implementing Mobile Application Management
Secure enterprise data on mobile devices with app-level controls.
