Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
apiVersion: runwhen.com/v1
kind: GenerationRules
spec:
platform: cloudflare
generationRules:
- resourceTypes:
- cloudflare_zone
matchRules:
- type: pattern
pattern: ".+"
properties: [name]
mode: substring
slxs:
- baseName: cf-zone-waf-rpt
qualifiers: ["zone"]
baseTemplateName: cloudflare-zone-waf-report
levelOfDetail: basic
outputItems:
- type: slx
- type: sli
templateName: cloudflare-zone-waf-report-sli.yaml
- type: runbook
templateName: cloudflare-zone-waf-report-taskset.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
apiVersion: runwhen.com/v1
kind: ServiceLevelIndicator
metadata:
name: {{slx_name}}
labels:
{% include "common-labels.yaml" %}
annotations:
{% include "common-annotations.yaml" %}
spec:
displayUnitsLong: OK
displayUnitsShort: ok
locations:
- {{default_location}}
description: Samples Cloudflare firewallEventsAdaptive GraphQL responses for the monitored zone to verify Analytics credentials stay healthy and sampled volumes remain below SLI guardrails.
codeBundle:
{% if repo_url %}
repoUrl: {{repo_url}}
{% else %}
repoUrl: https://github.com/runwhen-contrib/rw-cli-codecollection.git
{% endif %}
{% if ref %}
ref: {{ref}}
{% else %}
ref: main
{% endif %}
pathToRobot: codebundles/cloudflare-zone-waf-report/sli.robot
intervalStrategy: intermezzo
intervalSeconds: 300
configProvided:
- name: CLOUDFLARE_ZONE_ID
value: "{{ match_resource.zone_id | default(match_resource.id) }}"
- name: SLI_WAF_LOOKBACK_MINUTES
value: "{{ custom.sli_waf_lookback_minutes | default('15') }}"
- name: SLI_WAF_MAX_SAMPLE_ROWS
value: "{{ custom.sli_waf_max_sample_rows | default('400') }}"
- name: SLI_WAF_MAX_EVENTS
value: "{{ custom.sli_waf_max_events | default('250') }}"
secretsProvided:
{% if wb_version %}
{% include "cloudflare-auth.yaml" ignore missing %}
{% else %}
- name: cloudflare_api_token
workspaceKey: AUTH DETAILS NOT FOUND
{% endif %}
alertConfig:
tasks:
persona: eager-edgar
sessionTTL: 10m
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
apiVersion: runwhen.com/v1
kind: ServiceLevelX
metadata:
name: {{slx_name}}
labels:
{% include "common-labels.yaml" %}
annotations:
{% include "common-annotations.yaml" %}
spec:
imageURL: https://storage.googleapis.com/runwhen-nonprod-shared-images/icons/kubernetes/resources/labeled/ing.svg
alias: {{ match_resource.qualified_name }} Cloudflare WAF Report
asMeasuredBy: Sampled firewallEventsAdaptive GraphQL analytics scoped to the monitored Cloudflare zone.
configProvided:
- name: SLX_PLACEHOLDER
value: SLX_PLACEHOLDER
owners:
- {{workspace.owner_email}}
statement: Cloudflare zone firewall analytics remain observable without exceeding operational spike thresholds for sampled security events.
additionalContext:
{% include "cloudflare-hierarchy.yaml" ignore missing %}
qualified_name: "{{ match_resource.qualified_name }}"
tags:
{% include "cloudflare-tags.yaml" ignore missing %}
- name: cloud
value: cloudflare
- name: service
value: waf
- name: scope
value: zone
- name: access
value: read-only
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
apiVersion: runwhen.com/v1
kind: Runbook
metadata:
name: {{slx_name}}
labels:
{% include "common-labels.yaml" %}
annotations:
{% include "common-annotations.yaml" %}
spec:
location: {{default_location}}
description: Pull Cloudflare firewall/WAF sampled events via GraphQL for zone {{ match_resource.qualified_name }}, correlate them by rule, IP geography, and host/path, evaluate thresholds, and publish a consolidated correlation summary.
codeBundle:
{% if repo_url %}
repoUrl: {{repo_url}}
{% else %}
repoUrl: https://github.com/runwhen-contrib/rw-cli-codecollection.git
{% endif %}
{% if ref %}
ref: {{ref}}
{% else %}
ref: main
{% endif %}
pathToRobot: codebundles/cloudflare-zone-waf-report/runbook.robot
configProvided:
- name: CLOUDFLARE_ZONE_ID
value: "{{ match_resource.zone_id | default(match_resource.id) }}"
- name: CLOUDFLARE_ACCOUNT_ID
value: "{{ match_resource.account_id | default('') }}"
- name: WAF_LOOKBACK_MINUTES
value: "{{ custom.waf_lookback_minutes | default('60') }}"
- name: WAF_COMPARE_LOOKBACK_MINUTES
value: "{{ custom.waf_compare_lookback_minutes | default('60') }}"
- name: WAF_TOTAL_EVENTS_ISSUE_THRESHOLD
value: "{{ custom.waf_total_events_issue_threshold | default('500') }}"
- name: WAF_TOP_ENTITY_ISSUE_THRESHOLD
value: "{{ custom.waf_top_entity_issue_threshold | default('100') }}"
- name: WAF_SPIKE_RATIO_THRESHOLD
value: "{{ custom.waf_spike_ratio_threshold | default('0') }}"
- name: WAF_REPORT_TOP_N
value: "{{ custom.waf_report_top_n | default('15') }}"
- name: WAF_FETCH_PAGE_LIMIT
value: "{{ custom.waf_fetch_page_limit | default('800') }}"
- name: WAF_FETCH_MAX_PAGES
value: "{{ custom.waf_fetch_max_pages | default('25') }}"
secretsProvided:
{% if wb_version %}
{% include "cloudflare-auth.yaml" ignore missing %}
{% else %}
- name: cloudflare_api_token
workspaceKey: AUTH DETAILS NOT FOUND
{% endif %}
17 changes: 17 additions & 0 deletions codebundles/cloudflare-zone-waf-report/.test/Taskfile.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
version: "3"

tasks:
default:
desc: "Validate Cloudflare zone WAF report bundle structure"
cmds:
- task: validate-structure

validate-structure:
desc: "Run static checks for required files"
cmds:
- ./validate-cloudflare-zone-waf-report-structure.sh

clean:
desc: "Remove local test outputs"
cmds:
- rm -rf output workspaceInfo.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
#!/usr/bin/env bash
# Static validation for cloudflare-zone-waf-report (no live Cloudflare credentials required).
set -euo pipefail
ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
test -f "$ROOT/runbook.robot"
test -f "$ROOT/sli.robot"
test -f "$ROOT/README.md"
test -f "$ROOT/.runwhen/generation-rules/cloudflare-zone-waf-report.yaml"
test -f "$ROOT/.runwhen/templates/cloudflare-zone-waf-report-slx.yaml"
test -f "$ROOT/.runwhen/templates/cloudflare-zone-waf-report-taskset.yaml"
test -f "$ROOT/.runwhen/templates/cloudflare-zone-waf-report-sli.yaml"
for f in \
fetch-cloudflare-firewall-events.sh \
aggregate-waf-by-rule.sh \
correlate-waf-by-source.sh \
aggregate-waf-by-path.sh \
evaluate-waf-thresholds.sh \
report-waf-correlation-summary.sh \
sli-cloudflare-waf-score.sh
do
test -x "$ROOT/$f"
done
echo "cloudflare-zone-waf-report bundle structure OK"
63 changes: 63 additions & 0 deletions codebundles/cloudflare-zone-waf-report/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,63 @@
# Cloudflare Zone WAF & Security Events Report

This CodeBundle reads sampled firewall and security-adjacent rows from Cloudflare’s GraphQL Analytics `firewallEventsAdaptive` dataset, aggregates them by mitigating rule/action/source, enriches the picture with IP / ASN / country / hostname breakdowns, compares configurable thresholds, and emits an operational correlation summary plus binary SLI scoring hooks.

## Overview

- **Firewall event ingestion**: Uses HTTPS POST to `https://api.cloudflare.com/client/v4/graphql` with bearer-token authentication and bounded pagination so sampled datasets remain reproducible even during bursts ([tutorial](https://developers.cloudflare.com/analytics/graphql-api/tutorials/querying-firewall-events/)).
- **Adaptive sampling transparency**: Cloudflare applies adaptive sampling to Firewall Analytics rows — dashboard totals may extrapolate beyond the sampled hits returned to GraphQL ([details](https://developers.cloudflare.com/analytics/graphql-api/sampling/)).
- **Threshold-aware storytelling**: Raises structured RunWhen issues when aggregate sampled volumes or concentrated buckets exceed operator knobs or spike-ratio comparisons versus an optional trailing baseline window.
- **Operational artifacts**: Writes normalized JSON (rule aggregates, IP correlations, host/path summaries) beside textual rollup outputs suitable for handoffs.

## Configuration

### Required variables

- `CLOUDFLARE_ZONE_ID`: Zone identifier (`zoneTag`) passed into GraphQL filters (`zones(filter:{ zoneTag })`).
- `cloudflare_api_token` secret: API bearer token that satisfies Analytics/Firewall read scopes documented under Cloudflare token templates — expose via RunWhen secrets as plain text ([guidance](https://developers.cloudflare.com/analytics/graphql-api/getting-started/authentication/api-token-auth/)).

### Optional variables

- `CLOUDFLARE_ACCOUNT_ID`: Retained for future account-level filtering hooks when datasets demand additional qualifiers (currently informational metadata inside fetched payloads).
- `WAF_LOOKBACK_MINUTES`: Primary sliding-window length (minutes). Default `60`.
- `WAF_COMPARE_LOOKBACK_MINUTES`: Immediately preceding baseline window length (minutes); `0` disables spike-ratio comparisons. Default `60`.
- `WAF_TOTAL_EVENTS_ISSUE_THRESHOLD`: Sampled-row ceiling across the primary window before emitting aggregate-volume issues. Default `500`.
- `WAF_TOP_ENTITY_ISSUE_THRESHOLD`: Ceiling per concentrated bucket (dominant rule group, IP, host/path bucket). Default `100`.
- `WAF_SPIKE_RATIO_THRESHOLD`: Minimum ratio (`primary_sample_total / prior_sample_total`) before emitting spike issues; `0` disables the spike heuristic entirely (floating decimals supported). Default `0`.
- `WAF_REPORT_TOP_N`: Rank tables truncated after `N` entries in correlators/report sections. Default `15`.
- `WAF_FETCH_PAGE_LIMIT`: Rows requested per GraphQL page (`limit`). Default `800`.
- `WAF_FETCH_MAX_PAGES`: Pagination guard to bound worst-case runtime/credit consumption. Default `25`.

### SLI-only knobs (`sli.robot`)

Configure alongside runbook secrets via workspace/template mappings:

- `SLI_WAF_LOOKBACK_MINUTES`: Minutes sampled inside SLI probe windows — keep ≤15 for rapid evaluations (default `15`).
- `SLI_WAF_MAX_SAMPLE_ROWS`: GraphQL `limit` applied exclusively inside SLI (default `400`).
- `SLI_WAF_MAX_EVENTS`: Fail volume dimension when sampled SLI rows exceed this ceiling (default `250`).

## Tasks Overview

### Fetch Firewall and WAF Events for Zone `${CLOUDFLARE_ZONE_ID}`

Runs GraphQL pagination, persists normalized JSON blobs (`cloudflare_waf_primary_normalized.json`, optional prior-window sibling JSON), and opens severity issues whenever DNS/network/schema/token faults occur mid-fetch.

### Aggregate WAF Events by Rule, Action, and Service for Zone `${CLOUDFLARE_ZONE_ID}`

Clusters sampled hits using `.ruleId` where Cloudflare supplies identifiers — grouping gracefully collapses unknown identifiers — grouped jointly by mitigation `.source`/`.action` values.

### Correlate WAF Events by Source IP and Country for Zone `${CLOUDFLARE_ZONE_ID}`

Produces ranked IPs, autonomous-system aggregates, and country histogram sections honoring `WAF_REPORT_TOP_N`.

### Break Down WAF Activity by Hostname and Request Path for Zone `${CLOUDFLARE_ZONE_ID}`

Combines optional `.clientRequestHTTPHostName` with `.clientRequestPath` buckets plus standalone hostname summaries whenever hostname dimensions populate.

### Evaluate WAF Volume and Spike Thresholds for Zone `${CLOUDFLARE_ZONE_ID}``

Loads aggregator artifacts plus prior-window totals when spike ratios configured — emits remediation-heavy RunWhen issues with remediation bullets referencing dashboards versus definitive logs.

### Produce Consolidated WAF Correlation Report for Zone `${CLOUDFLARE_ZONE_ID}``

Concatenates key rollup summaries referencing aggregated artifacts plus pinned Cloudflare documentation links for responders auditing anomalies offline.
45 changes: 45 additions & 0 deletions codebundles/cloudflare-zone-waf-report/aggregate-waf-by-path.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
#!/usr/bin/env bash
# Break down sampled firewall activity by hostname (when present) and URL path.
set -euo pipefail
set -x

PRIMARY="${CLOUDFLARE_WAF_PRIMARY_JSON:-cloudflare_waf_primary_normalized.json}"
TOP_N="${WAF_REPORT_TOP_N:-15}"
OUT_JSON="${CLOUDFLARE_WAF_PATH_AGG_JSON:-cloudflare_waf_by_path.json}"
ISSUES_JSON="${CLOUDFLARE_WAF_PATH_ISSUES:-cloudflare_waf_path_issues.json}"

echo '[]' >"${ISSUES_JSON}"

if [[ ! -f "${PRIMARY}" ]]; then
jq -n --arg p "${PRIMARY}" '{error:"missing_primary", path:$p}' >"${OUT_JSON}"
exit 0
fi

jq --argjson top "${TOP_N}" '
(.events // []) as $ev
| {
top_paths: (
$ev
| map(. + {host: (.clientRequestHTTPHostName // ""), path: (.clientRequestPath // "")})
| group_by(.host + "\u001f" + .path)
| map({
host: (.[0].host),
path: (.[0].path),
sample_count: length
})
| sort_by(-.sample_count)
| .[0:$top]
),
top_hosts: (
$ev
| map(.clientRequestHTTPHostName // "")
| map(select(length > 0))
| group_by(.)
| map({host: .[0], sample_count: length})
| sort_by(-.sample_count)
| .[0:$top]
)
}
' "${PRIMARY}" >"${OUT_JSON}"

echo "[+] Host/path aggregation → ${OUT_JSON}"
30 changes: 30 additions & 0 deletions codebundles/cloudflare-zone-waf-report/aggregate-waf-by-rule.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
#!/usr/bin/env bash
# Group sampled firewall events by rule id, action, and source service.
set -euo pipefail
set -x

PRIMARY="${CLOUDFLARE_WAF_PRIMARY_JSON:-cloudflare_waf_primary_normalized.json}"
OUT_JSON="${CLOUDFLARE_WAF_RULE_AGG_JSON:-cloudflare_waf_by_rule.json}"
ISSUES_JSON="${CLOUDFLARE_WAF_RULE_ISSUES:-cloudflare_waf_rule_aggregate_issues.json}"

echo '[]' >"${ISSUES_JSON}"

if [[ ! -f "${PRIMARY}" ]]; then
jq -n --arg p "${PRIMARY}" '{error:"missing_primary", path:$p}' >"${OUT_JSON}"
exit 0
fi

jq '
(.events // [])
| map(. + {rule_key: (.ruleId // .rule_id // "unknown")})
| group_by(.rule_key + "|" + (.source // "?") + "|" + (.action // "?"))
| map({
rule_id: (.[0].rule_key),
source: (.[0].source // null),
action: (.[0].action // null),
sample_count: length
})
| sort_by(-.sample_count)
' "${PRIMARY}" >"${OUT_JSON}"

echo "[+] Rule/action/service aggregation → ${OUT_JSON}"
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
#!/usr/bin/env bash
# Correlate sampled events by client IP, ASN, and country with top-N tables.
set -euo pipefail
set -x

PRIMARY="${CLOUDFLARE_WAF_PRIMARY_JSON:-cloudflare_waf_primary_normalized.json}"
TOP_N="${WAF_REPORT_TOP_N:-15}"
OUT_JSON="${CLOUDFLARE_WAF_SOURCE_AGG_JSON:-cloudflare_waf_by_source.json}"
ISSUES_JSON="${CLOUDFLARE_WAF_SOURCE_ISSUES:-cloudflare_waf_source_issues.json}"

echo '[]' >"${ISSUES_JSON}"

if [[ ! -f "${PRIMARY}" ]]; then
jq -n --arg p "${PRIMARY}" '{error:"missing_primary", path:$p}' >"${OUT_JSON}"
exit 0
fi

jq --argjson top "${TOP_N}" '
(.events // []) as $ev
| {
top_ips: (
$ev
| group_by(.clientIP // "unknown")
| map({client_ip: (.[0].clientIP // "unknown"), sample_count: length})
| sort_by(-.sample_count)
| .[0:$top]
),
top_asns: (
$ev
| group_by(.clientAsn // "unknown")
| map({asn: (.[0].clientAsn // "unknown"), sample_count: length})
| sort_by(-.sample_count)
| .[0:$top]
),
top_countries: (
$ev
| group_by(.clientCountryName // "unknown")
| map({country: (.[0].clientCountryName // "unknown"), sample_count: length})
| sort_by(-.sample_count)
| .[0:$top]
),
distinct_ip_estimate: ($ev | map(.clientIP // "") | unique | length)
}
' "${PRIMARY}" >"${OUT_JSON}"

echo "[+] Source ASN/IP/country aggregation → ${OUT_JSON}"
Loading