diff --git a/codebundles/cloudflare-zone-waf-report/.runwhen/generation-rules/cloudflare-zone-waf-report.yaml b/codebundles/cloudflare-zone-waf-report/.runwhen/generation-rules/cloudflare-zone-waf-report.yaml new file mode 100644 index 00000000..8a6e0c9d --- /dev/null +++ b/codebundles/cloudflare-zone-waf-report/.runwhen/generation-rules/cloudflare-zone-waf-report.yaml @@ -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 diff --git a/codebundles/cloudflare-zone-waf-report/.runwhen/templates/cloudflare-zone-waf-report-sli.yaml b/codebundles/cloudflare-zone-waf-report/.runwhen/templates/cloudflare-zone-waf-report-sli.yaml new file mode 100644 index 00000000..48300b85 --- /dev/null +++ b/codebundles/cloudflare-zone-waf-report/.runwhen/templates/cloudflare-zone-waf-report-sli.yaml @@ -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 diff --git a/codebundles/cloudflare-zone-waf-report/.runwhen/templates/cloudflare-zone-waf-report-slx.yaml b/codebundles/cloudflare-zone-waf-report/.runwhen/templates/cloudflare-zone-waf-report-slx.yaml new file mode 100644 index 00000000..c58f5759 --- /dev/null +++ b/codebundles/cloudflare-zone-waf-report/.runwhen/templates/cloudflare-zone-waf-report-slx.yaml @@ -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 diff --git a/codebundles/cloudflare-zone-waf-report/.runwhen/templates/cloudflare-zone-waf-report-taskset.yaml b/codebundles/cloudflare-zone-waf-report/.runwhen/templates/cloudflare-zone-waf-report-taskset.yaml new file mode 100644 index 00000000..ff24d220 --- /dev/null +++ b/codebundles/cloudflare-zone-waf-report/.runwhen/templates/cloudflare-zone-waf-report-taskset.yaml @@ -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 %} diff --git a/codebundles/cloudflare-zone-waf-report/.test/Taskfile.yaml b/codebundles/cloudflare-zone-waf-report/.test/Taskfile.yaml new file mode 100644 index 00000000..12ed37d1 --- /dev/null +++ b/codebundles/cloudflare-zone-waf-report/.test/Taskfile.yaml @@ -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 diff --git a/codebundles/cloudflare-zone-waf-report/.test/validate-cloudflare-zone-waf-report-structure.sh b/codebundles/cloudflare-zone-waf-report/.test/validate-cloudflare-zone-waf-report-structure.sh new file mode 100755 index 00000000..0810ab66 --- /dev/null +++ b/codebundles/cloudflare-zone-waf-report/.test/validate-cloudflare-zone-waf-report-structure.sh @@ -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" diff --git a/codebundles/cloudflare-zone-waf-report/README.md b/codebundles/cloudflare-zone-waf-report/README.md new file mode 100644 index 00000000..90bf673a --- /dev/null +++ b/codebundles/cloudflare-zone-waf-report/README.md @@ -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. diff --git a/codebundles/cloudflare-zone-waf-report/aggregate-waf-by-path.sh b/codebundles/cloudflare-zone-waf-report/aggregate-waf-by-path.sh new file mode 100755 index 00000000..a24b6c16 --- /dev/null +++ b/codebundles/cloudflare-zone-waf-report/aggregate-waf-by-path.sh @@ -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}" diff --git a/codebundles/cloudflare-zone-waf-report/aggregate-waf-by-rule.sh b/codebundles/cloudflare-zone-waf-report/aggregate-waf-by-rule.sh new file mode 100755 index 00000000..40b07602 --- /dev/null +++ b/codebundles/cloudflare-zone-waf-report/aggregate-waf-by-rule.sh @@ -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}" diff --git a/codebundles/cloudflare-zone-waf-report/correlate-waf-by-source.sh b/codebundles/cloudflare-zone-waf-report/correlate-waf-by-source.sh new file mode 100755 index 00000000..7c1e5522 --- /dev/null +++ b/codebundles/cloudflare-zone-waf-report/correlate-waf-by-source.sh @@ -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}" diff --git a/codebundles/cloudflare-zone-waf-report/evaluate-waf-thresholds.sh b/codebundles/cloudflare-zone-waf-report/evaluate-waf-thresholds.sh new file mode 100755 index 00000000..65680aaa --- /dev/null +++ b/codebundles/cloudflare-zone-waf-report/evaluate-waf-thresholds.sh @@ -0,0 +1,116 @@ +#!/usr/bin/env bash +# Compare sampled WAF/security-event volumes against operator thresholds and emit Robot JSON issues. +set -euo pipefail +set -x + +PRIMARY="${CLOUDFLARE_WAF_PRIMARY_JSON:-cloudflare_waf_primary_normalized.json}" +PRIOR="${CLOUDFLARE_WAF_PRIOR_JSON:-cloudflare_waf_prior_normalized.json}" +RULE_AGG="${CLOUDFLARE_WAF_RULE_AGG_JSON:-cloudflare_waf_by_rule.json}" +SOURCE_AGG="${CLOUDFLARE_WAF_SOURCE_AGG_JSON:-cloudflare_waf_by_source.json}" +PATH_AGG="${CLOUDFLARE_WAF_PATH_AGG_JSON:-cloudflare_waf_by_path.json}" + +TOTAL_THR="${WAF_TOTAL_EVENTS_ISSUE_THRESHOLD:-500}" +ENTITY_THR="${WAF_TOP_ENTITY_ISSUE_THRESHOLD:-100}" +SPIKE_RATIO="${WAF_SPIKE_RATIO_THRESHOLD:-0}" + +OUT_ISSUES="${CLOUDFLARE_WAF_THRESHOLD_ISSUES:-cloudflare_waf_threshold_issues.json}" + +issues_json='[]' + +append_issue() { + local title="$1" details="$2" severity="$3" next="$4" + issues_json=$(echo "$issues_json" | jq \ + --arg t "$title" \ + --arg d "$details" \ + --arg ns "$next" \ + --argjson sev "$severity" \ + '. += [{"title": $t, "details": $d, "severity": $sev, "next_steps": $ns}]') +} + +if [[ ! -f "${PRIMARY}" ]]; then + jq -n --arg msg "missing ${PRIMARY}" '[{title:"Missing primary WAF dataset", details:$msg, severity:4, next_steps:"Run Fetch Firewall and WAF Events before thresholds evaluation."}]' >"${OUT_ISSUES}" + exit 0 +fi + +primary_total=$(jq '.events | length' "${PRIMARY}") +prior_total=0 +if [[ -f "${PRIOR}" ]]; then + prior_total=$(jq 'if (.events | type == "array") then (.events | length) else 0 end' "${PRIOR}") +fi + +if [[ "${primary_total}" -gt "${TOTAL_THR}" ]]; then + append_issue \ + "WAF sampled-event volume exceeds total threshold for zone window" \ + "Sampled firewall/WAF rows in primary window: ${primary_total} (threshold ${TOTAL_THR}). Dataset uses adaptive sampling; interpret spikes alongside dashboard Firewall Analytics." \ + "3" \ + "Investigate Security Events for spikes; tune Managed Rules / Rate Limits / Bot Fight Mode as appropriate; consider enabling Logpush for definitive counts." +fi + +compare_entities() { + local label="$1" file="$2" jqexpr="$3" + [[ -f "$file" ]] || return 0 + local max_ent max_rule_json detail severity_title + + max_ent=$(jq -r "${jqexpr}" "$file" || echo "0") + max_ent=$((max_ent + 0)) + if [[ "${max_ent}" -gt "${ENTITY_THR}" ]]; then + top_row="$(jq -c '.[0]' "$file" 2>/dev/null || echo {})" + append_issue \ + "High-volume ${label} bucket exceeds top-entity threshold" \ + "Maximum sampled bucket count=${max_ent} (threshold ${ENTITY_THR}). Example bucket JSON: ${top_row}" \ + "3" \ + "Review the offending IPs/rules/paths in Firewall Analytics; add granular bypass rules only after validating traffic legitimacy." + fi +} + +if [[ -f "${RULE_AGG}" ]]; then + compare_entities "rule/action/source" "${RULE_AGG}" '[.[].sample_count] | max // 0' +fi + +if [[ -f "${SOURCE_AGG}" ]]; then + ip_max=$(jq '[.top_ips[]?.sample_count] | max // 0' "${SOURCE_AGG}") + ip_max=$((ip_max + 0)) + if [[ "${ip_max}" -gt "${ENTITY_THR}" ]]; then + row="$(jq -c '.top_ips[0]' "${SOURCE_AGG}" 2>/dev/null || echo {})" + append_issue \ + "Concentrated WAF activity from a single client IP" \ + "Top IP sampled-count=${ip_max} (threshold ${ENTITY_THR}). Row=${row}" \ + "3" \ + "Validate whether IP belongs to scanners/CDNs/partners; blocklist cautiously and correlate with origin logs." + fi +fi + +if [[ -f "${PATH_AGG}" ]]; then + path_max=$(jq '[.top_paths[]?.sample_count] | max // 0' "${PATH_AGG}") + path_max=$((path_max + 0)) + if [[ "${path_max}" -gt "${ENTITY_THR}" ]]; then + row="$(jq -c '.top_paths[0]' "${PATH_AGG}" 2>/dev/null || echo {})" + append_issue \ + "High-volume hostname/path bucket exceeds top-entity threshold" \ + "Maximum sampled host/path bucket count=${path_max} (threshold ${ENTITY_THR}). Example=${row}" \ + "3" \ + "Inspect offending URLs in Firewall Analytics and tune managed/custom rules without weakening protections broadly." + fi +fi + +if awk -v thr="${SPIKE_RATIO:-0}" 'BEGIN { exit !(thr > 0) }'; then + if [[ "${prior_total}" -gt 0 ]]; then + if awk -v a="$primary_total" -v b="$prior_total" -v t="$SPIKE_RATIO" 'BEGIN { exit !((b > 0) && ((a / b) >= t)) }'; then + ratio_human="$(awk -v a="$primary_total" -v b="$prior_total" 'BEGIN { printf "%.2f", (b > 0 ? a / b : 0) }')" + append_issue \ + "Firewall sampled-volume spike versus prior window" \ + "Primary sampled hits=${primary_total}, prior sampled hits=${prior_total}; ratio≈${ratio_human} with spike threshold=${SPIKE_RATIO}." \ + "4" \ + "Treat as potential coordinated attack or noisy rule deployment — drill into Security Events timeline and recent rule publishes." + fi + elif [[ "${prior_total}" -eq 0 && "${primary_total}" -gt "${ENTITY_THR}" ]]; then + append_issue \ + "Firewall activity emergence versus quiet baseline window" \ + "Prior window sampled hits were zero while primary sampled hits=${primary_total}; spike ratio gate (${SPIKE_RATIO}) flagged emergence." \ + "3" \ + "Confirm baseline window was not truncated by pagination limits; inspect spikes directly in Cloudflare dashboard Security Analytics." + fi +fi + +echo "$issues_json" >"${OUT_ISSUES}" +echo "[+] Threshold evaluation wrote $(jq length "${OUT_ISSUES}") issue(s) → ${OUT_ISSUES}" diff --git a/codebundles/cloudflare-zone-waf-report/fetch-cloudflare-firewall-events.sh b/codebundles/cloudflare-zone-waf-report/fetch-cloudflare-firewall-events.sh new file mode 100755 index 00000000..bff18530 --- /dev/null +++ b/codebundles/cloudflare-zone-waf-report/fetch-cloudflare-firewall-events.sh @@ -0,0 +1,224 @@ +#!/usr/bin/env bash +# Fetch sampled firewall/WAF events for the configured Cloudflare zone via GraphQL Analytics +# (firewallEventsAdaptive). Writes normalized JSON for aggregation scripts. +# +# Outputs: +# cloudflare_waf_primary_normalized.json — { meta, events: [...] } +# cloudflare_waf_prior_normalized.json — optional compare window (empty payload when compare disabled) +# cloudflare_waf_fetch_issues.json — Robot issue payloads (API/auth failures) +# +# Notes: +# - firewallEventsAdaptive is adaptively sampled; counts reflect sampled hits returned by GraphQL, +# while Cloudflare may extrapolate totals internally on dashboards. +set -euo pipefail +set -x + +: "${CLOUDFLARE_ZONE_ID:?Must set CLOUDFLARE_ZONE_ID}" + +TOKEN="${CLOUDFLARE_API_TOKEN:-${cloudflare_api_token:-}}" +GRAPHQL_URL="${CLOUDFLARE_GRAPHQL_URL:-https://api.cloudflare.com/client/v4/graphql}" + +PRIMARY_OUT="${CLOUDFLARE_WAF_PRIMARY_JSON:-cloudflare_waf_primary_normalized.json}" +PRIOR_OUT="${CLOUDFLARE_WAF_PRIOR_JSON:-cloudflare_waf_prior_normalized.json}" +ISSUES_OUT="${CLOUDFLARE_WAF_FETCH_ISSUES:-cloudflare_waf_fetch_issues.json}" + +LOOKBACK="${WAF_LOOKBACK_MINUTES:-60}" +COMPARE="${WAF_COMPARE_LOOKBACK_MINUTES:-60}" +PAGE_LIMIT="${WAF_FETCH_PAGE_LIMIT:-800}" +MAX_PAGES="${WAF_FETCH_MAX_PAGES:-25}" + +issues_json='[]' + +die_issue() { + local title="$1" details="$2" severity="${3:-4}" next="${4:-Verify CLOUDFLARE_ZONE_ID, token scopes (Analytics read + Firewall/WAF read), and network egress to api.cloudflare.com.}" + issues_json=$(echo "$issues_json" | jq \ + --arg t "$title" \ + --arg d "$details" \ + --arg ns "$next" \ + --argjson sev "$severity" \ + '. += [{"title": $t, "details": $d, "severity": $sev, "next_steps": $ns}]') +} + +firewall_graphql_call() { + local zone="$1" start_iso="$2" end_iso="$3" limit="$4" + jq -n \ + --arg z "$zone" \ + --arg ds "$start_iso" \ + --arg de "$end_iso" \ + --argjson lim "$limit" \ + '{ + query: "query ($zoneTag: string, $filter: FirewallEventsAdaptiveFilter_InputObject!, $limit: int!) { viewer { zones(filter: { zoneTag: $zoneTag }) { firewallEventsAdaptive(filter: $filter, limit: $limit, orderBy: [datetime_DESC]) { action clientAsn clientCountryName clientIP clientRequestPath clientRequestQuery datetime source userAgent ruleId description clientRequestHTTPHostName } } } }", + variables: { + zoneTag: $z, + filter: { datetime_geq: $ds, datetime_leq: $de }, + limit: $lim + } + }' \ + | curl -sS --max-time 120 \ + "$GRAPHQL_URL" \ + -H "Authorization: Bearer ${TOKEN}" \ + -H "Content-Type: application/json" \ + -d @- +} + +firewall_graphql_fallback_call() { + local zone="$1" start_iso="$2" end_iso="$3" limit="$4" + jq -n \ + --arg z "$zone" \ + --arg ds "$start_iso" \ + --arg de "$end_iso" \ + --argjson lim "$limit" \ + '{ + query: "query ($zoneTag: string, $filter: FirewallEventsAdaptiveFilter_InputObject!, $limit: int!) { viewer { zones(filter: { zoneTag: $zoneTag }) { firewallEventsAdaptive(filter: $filter, limit: $limit, orderBy: [datetime_DESC]) { action clientAsn clientCountryName clientIP clientRequestPath clientRequestQuery datetime source userAgent } } } }", + variables: { + zoneTag: $z, + filter: { datetime_geq: $ds, datetime_leq: $de }, + limit: $lim + } + }' \ + | curl -sS --max-time 120 \ + "$GRAPHQL_URL" \ + -H "Authorization: Bearer ${TOKEN}" \ + -H "Content-Type: application/json" \ + -d @- +} + +graphql_firewall_page() { + local zone="$1" start_iso="$2" end_iso="$3" limit="$4" + local body + + body="$(firewall_graphql_call "$zone" "$start_iso" "$end_iso" "$limit")" || return 1 + + if echo "$body" | jq -e '.errors != null and (.errors | length > 0)' >/dev/null; then + # Retry without optional columns some tenants/schemas omit. + body="$(firewall_graphql_fallback_call "$zone" "$start_iso" "$end_iso" "$limit")" || return 1 + if echo "$body" | jq -e '.errors != null and (.errors | length > 0)' >/dev/null; then + echo "$body" | jq -c '.errors' >&2 + return 2 + fi + fi + + local zones + zones=$(echo "$body" | jq '[ .data.viewer.zones[]? ] | length') + if [[ "${zones:-0}" -eq 0 ]]; then + return 3 + fi + + echo "$body" | jq -c '.data.viewer.zones[0].firewallEventsAdaptive // []' +} + +collect_window() { + local zone="$1" start_iso="$2" end_iso="$3" + local agg='[]' oldest iso_old="" + local i + + for ((i = 1; i <= MAX_PAGES; i++)); do + local batch_json rc + batch_json="$(graphql_firewall_page "$zone" "$start_iso" "$end_iso" "$PAGE_LIMIT")" && rc=0 || rc=$? + if [[ "$rc" == "1" ]]; then + die_issue "Cloudflare GraphQL HTTP failure for zone \`${zone}\`" "curl exited non-zero while calling ${GRAPHQL_URL}" 4 + echo "$agg" + return 1 + fi + if [[ "$rc" == "2" ]]; then + local detail + detail="$(firewall_graphql_call "$zone" "$start_iso" "$end_iso" "$PAGE_LIMIT" | jq -c '.errors // []')" + die_issue "Cloudflare GraphQL errors for zone \`${zone}\`" "GraphQL errors: ${detail}" 4 \ + "Confirm token Analytics/Firewall scopes per Cloudflare token templates; see https://developers.cloudflare.com/analytics/graphql-api/getting-started/authentication/api-token-auth/" + echo "$agg" + return 1 + fi + if [[ "$rc" == "3" ]]; then + die_issue "Cloudflare zone not returned by Analytics GraphQL for \`${zone}\`" "zones(filter:{zoneTag}) returned zero zones — verify zone tag/id matches Analytics datasets." 4 + echo "$agg" + return 1 + fi + + local n + n=$(echo "$batch_json" | jq 'length') + if [[ "${n:-0}" -eq 0 ]]; then + break + fi + + agg="$(jq -s 'add' <<<"$agg"$'\n'"${batch_json}")" + + if [[ "$n" -lt "$PAGE_LIMIT" ]]; then + break + fi + + oldest=$(echo "$batch_json" | jq -r 'map(.datetime) | min') + if [[ -z "$oldest" || "$oldest" == "null" ]]; then + break + fi + if [[ "$oldest" == "$iso_old" ]]; then + break + fi + iso_old="$oldest" + + # Move exclusive upper bound slightly earlier than the oldest row so DESC paging can proceed. + local oldest_epoch prior_epoch new_end + oldest_epoch=$(date -u -d "${oldest/Z/+0000}" +%s 2>/dev/null || date -u -d "$oldest" +%s) + prior_epoch=$((oldest_epoch - 1)) + new_end=$(date -u -d "@${prior_epoch}" +%Y-%m-%dT%H:%M:%SZ) + end_iso="$new_end" + + if [[ "$(echo "$agg" | jq 'length')" -ge "$((PAGE_LIMIT * MAX_PAGES))" ]]; then + break + fi + done + + echo "$agg" +} + +if [[ -z "${TOKEN}" ]]; then + die_issue "Missing Cloudflare API token for zone \`${CLOUDFLARE_ZONE_ID}\`" "Set secret cloudflare_api_token / CLOUDFLARE_API_TOKEN with Analytics read scope." 4 +fi + +end_primary=$(date -u +%Y-%m-%dT%H:%M:%SZ) +start_primary=$(date -u -d "-${LOOKBACK} minutes" +%Y-%m-%dT%H:%M:%SZ) + +events_primary="$(collect_window "$CLOUDFLARE_ZONE_ID" "$start_primary" "$end_primary")" + +lookback_num=$((LOOKBACK + 0)) + +jq -n \ + --arg z "${CLOUDFLARE_ZONE_ID}" \ + --arg start "${start_primary}" \ + --arg end "${end_primary}" \ + --argjson lb "${lookback_num}" \ + --argjson ev "$(echo "${events_primary}" | jq -c '.')" \ + --arg note "Adaptive sampled firewallEventsAdaptive rows — dashboards may extrapolate beyond sampled hits." \ + --arg acct "${CLOUDFLARE_ACCOUNT_ID:-}" \ + '{ + meta: { + zone_tag: $z, + window: {start: $start, end: $end, lookback_minutes: $lb}, + effective_limits: {page_limit: '"${PAGE_LIMIT}"', max_pages: '"${MAX_PAGES}"'}, + dataset_note: $note, + account_id_hint: $acct + }, + events: $ev + }' >"${PRIMARY_OUT}" + +echo "[+] Primary window rows (sample hits): $(jq '.events | length' "${PRIMARY_OUT}") → ${PRIMARY_OUT}" + +compare_num=$((COMPARE + 0)) + +if [[ "${compare_num}" -gt 0 ]]; then + end_prior="${start_primary}" + start_prior_epoch=$(( $(date -u +%s) - (lookback_num + compare_num) * 60 )) + start_prior=$(date -u -d "@${start_prior_epoch}" +%Y-%m-%dT%H:%M:%SZ) + events_prior="$(collect_window "$CLOUDFLARE_ZONE_ID" "$start_prior" "${end_prior}")" + jq -n \ + --arg z "${CLOUDFLARE_ZONE_ID}" \ + --arg start "${start_prior}" \ + --arg end "${end_prior}" \ + --argjson ev "$(echo "${events_prior}" | jq -c '.')" \ + '{meta:{zone_tag:$z, window:{start:$start, end:$end}}, events:$ev}' >"${PRIOR_OUT}" + echo "[+] Prior window rows (sample hits): $(jq '.events | length' "${PRIOR_OUT}") → ${PRIOR_OUT}" +else + jq -n '{meta:{disabled:true, reason:"WAF_COMPARE_LOOKBACK_MINUTES is 0"}, events:[]}' >"${PRIOR_OUT}" +fi + +echo "$issues_json" >"${ISSUES_OUT}" +echo "[+] Issues JSON written to ${ISSUES_OUT}" diff --git a/codebundles/cloudflare-zone-waf-report/report-waf-correlation-summary.sh b/codebundles/cloudflare-zone-waf-report/report-waf-correlation-summary.sh new file mode 100755 index 00000000..c035d0ff --- /dev/null +++ b/codebundles/cloudflare-zone-waf-report/report-waf-correlation-summary.sh @@ -0,0 +1,65 @@ +#!/usr/bin/env bash +# Emit a consolidated textual summary referencing fetched firewall analytics artifacts. +set -euo pipefail +set -x + +PRIMARY="${CLOUDFLARE_WAF_PRIMARY_JSON:-cloudflare_waf_primary_normalized.json}" +RULE_AGG="${CLOUDFLARE_WAF_RULE_AGG_JSON:-cloudflare_waf_by_rule.json}" +SOURCE_AGG="${CLOUDFLARE_WAF_SOURCE_AGG_JSON:-cloudflare_waf_by_source.json}" +PATH_AGG="${CLOUDFLARE_WAF_PATH_AGG_JSON:-cloudflare_waf_by_path.json}" + +ISSUES_JSON="${CLOUDFLARE_WAF_REPORT_ISSUES:-cloudflare_waf_report_issues.json}" +echo '[]' >"${ISSUES_JSON}" + +{ + echo "=== Cloudflare Zone WAF & Security Events Summary ===" + echo "" + + if [[ -f "${PRIMARY}" ]]; then + jq -r ' + "## Zone\n" + + "- Zone tag: " + (.meta.zone_tag // "?") + "\n" + + "- Primary window: " + (.meta.window.start // "?") + " → " + (.meta.window.end // "?") + "\n" + + "- Sampled rows fetched (primary): " + ((.events | length) | tostring) + "\n" + + "- Dataset note: " + (.meta.dataset_note // "") + "\n" + ' "${PRIMARY}" + else + echo "(Primary normalized JSON missing — did fetch succeed?)" + fi + + echo "" + echo "## Top rule/action/source buckets (sampled counts)" + if [[ -f "${RULE_AGG}" ]]; then + jq -r '.[0:10][] | "- rule=\(.rule_id // "?") source=\(.source // "?") action=\(.action // "?") count=\(.sample_count)"' "${RULE_AGG}" 2>/dev/null || echo "(unable to parse rule aggregation)" + else + echo "(aggregate-waf-by-rule output missing)" + fi + + echo "" + echo "## Top IPs / countries (sampled counts)" + if [[ -f "${SOURCE_AGG}" ]]; then + echo "- IPs:" + jq -r '.top_ips[:10][]? | " * ip=\(.client_ip) count=\(.sample_count)"' "${SOURCE_AGG}" 2>/dev/null || true + echo "- Countries:" + jq -r '.top_countries[:10][]? | " * country=\(.country) count=\(.sample_count)"' "${SOURCE_AGG}" 2>/dev/null || true + else + echo "(correlate-waf-by-source output missing)" + fi + + echo "" + echo "## Top hosts/paths (sampled counts)" + if [[ -f "${PATH_AGG}" ]]; then + jq -r '.top_paths[:10][]? | "- host=\(.host // "") path=\(.path // "") count=\(.sample_count)"' "${PATH_AGG}" 2>/dev/null || echo "(unable to parse path aggregation)" + else + echo "(aggregate-waf-by-path output missing)" + fi + + echo "" + echo "## References" + echo "- GraphQL Analytics overview: https://developers.cloudflare.com/analytics/graphql-api/" + echo "- Firewall Events tutorial: https://developers.cloudflare.com/analytics/graphql-api/tutorials/querying-firewall-events/" + echo "- Sampling behavior: https://developers.cloudflare.com/analytics/graphql-api/sampling/" +} + +echo "" +echo "[+] Summary rendered (issues JSON intentionally empty at ${ISSUES_JSON})" diff --git a/codebundles/cloudflare-zone-waf-report/runbook.robot b/codebundles/cloudflare-zone-waf-report/runbook.robot new file mode 100644 index 00000000..c82c207e --- /dev/null +++ b/codebundles/cloudflare-zone-waf-report/runbook.robot @@ -0,0 +1,322 @@ +*** Settings *** +Documentation Pulls Cloudflare firewall and security-adjacent sampled events for a zone via GraphQL Analytics, aggregates them by rule, IP geography, and path, compares configurable thresholds, and renders a consolidated report for proactive abuse detection. +Metadata Author rw-codebundle-agent +Metadata Display Name Cloudflare Zone WAF & Security Events Report +Metadata Supports Cloudflare WAF Firewall Security GraphQL Analytics + +Force Tags Cloudflare WAF Firewall Security Analytics + +Library String +Library BuiltIn +Library RW.Core +Library RW.CLI +Library RW.platform + +Suite Setup Suite Initialization + +*** Tasks *** +Fetch Firewall and WAF Events for Zone `${CLOUDFLARE_ZONE_ID}` + [Documentation] Queries Cloudflare GraphQL firewallEventsAdaptive for the primary lookback window (optional prior window for spike math), persists normalized JSON artifacts for downstream aggregation tasks, and surfaces connectivity/schema failures as issues. + [Tags] Cloudflare WAF firewall access:read-only data:metrics + + ${result}= RW.CLI.Run Bash File + ... bash_file=fetch-cloudflare-firewall-events.sh + ... env=${env} + ... secret__cloudflare_api_token=${cloudflare_api_token} + ... include_in_history=false + ... timeout_seconds=240 + ... show_in_rwl_cheatsheet=true + ... cmd_override=CLOUDFLARE_ZONE_ID="${CLOUDFLARE_ZONE_ID}" ./fetch-cloudflare-firewall-events.sh + + ${issues}= RW.CLI.Run Cli + ... cmd=cat cloudflare_waf_fetch_issues.json + ... timeout_seconds=30 + TRY + ${issue_list}= Evaluate json.loads(r'''${issues.stdout}''') json + EXCEPT + Log Failed to parse JSON for fetch task, defaulting to empty list. WARN + ${issue_list}= Create List + END + IF len(@{issue_list}) > 0 + FOR ${issue} IN @{issue_list} + RW.Core.Add Issue + ... severity=${issue['severity']} + ... expected=GraphQL Analytics should return firewallEventsAdaptive rows or an empty sample without authorization errors + ... actual=${issue['details']} + ... title=${issue['title']} + ... reproduce_hint=${result.cmd} + ... details=${issue['details']} + ... next_steps=${issue['next_steps']} + END + END + + RW.Core.Add Pre To Report Firewall/WAF fetch summary (${CLOUDFLARE_ZONE_ID}):\n${result.stdout} + +Aggregate WAF Events by Rule, Action, and Service for Zone `${CLOUDFLARE_ZONE_ID}` + [Documentation] Reads normalized primary events and groups sampled hits by rule identifier, mitigating action, and protection source so operators see which defenses trigger most often. + [Tags] Cloudflare WAF aggregate access:read-only data:metrics + + ${result}= RW.CLI.Run Bash File + ... bash_file=aggregate-waf-by-rule.sh + ... env=${env} + ... include_in_history=false + ... timeout_seconds=120 + ... show_in_rwl_cheatsheet=false + ... cmd_override=./aggregate-waf-by-rule.sh + + ${issues}= RW.CLI.Run Cli + ... cmd=cat cloudflare_waf_rule_aggregate_issues.json + ... timeout_seconds=30 + TRY + ${issue_list}= Evaluate json.loads(r'''${issues.stdout}''') json + EXCEPT + Log Failed to parse JSON for rule aggregation task, defaulting to empty list. WARN + ${issue_list}= Create List + END + IF len(@{issue_list}) > 0 + FOR ${issue} IN @{issue_list} + RW.Core.Add Issue + ... severity=${issue['severity']} + ... expected=Rule aggregation should parse existing normalized JSON without structural errors + ... actual=${issue['details']} + ... title=${issue['title']} + ... reproduce_hint=${result.cmd} + ... details=${issue['details']} + ... next_steps=${issue['next_steps']} + END + END + + RW.Core.Add Pre To Report WAF rule/action/source aggregation (${CLOUDFLARE_ZONE_ID}):\n${result.stdout} + +Correlate WAF Events by Source IP and Country for Zone `${CLOUDFLARE_ZONE_ID}` + [Documentation] Builds top-N tables for client IPs, autonomous systems, and countries to differentiate concentrated attacks from widespread noise using sampled firewall rows. + [Tags] Cloudflare WAF correlate access:read-only data:metrics + + ${result}= RW.CLI.Run Bash File + ... bash_file=correlate-waf-by-source.sh + ... env=${env} + ... include_in_history=false + ... timeout_seconds=120 + ... show_in_rwl_cheatsheet=false + ... cmd_override=./correlate-waf-by-source.sh + + ${issues}= RW.CLI.Run Cli + ... cmd=cat cloudflare_waf_source_issues.json + ... timeout_seconds=30 + TRY + ${issue_list}= Evaluate json.loads(r'''${issues.stdout}''') json + EXCEPT + Log Failed to parse JSON for source correlation task, defaulting to empty list. WARN + ${issue_list}= Create List + END + IF len(@{issue_list}) > 0 + FOR ${issue} IN @{issue_list} + RW.Core.Add Issue + ... severity=${issue['severity']} + ... expected=Source correlation should parse normalized JSON successfully + ... actual=${issue['details']} + ... title=${issue['title']} + ... reproduce_hint=${result.cmd} + ... details=${issue['details']} + ... next_steps=${issue['next_steps']} + END + END + + RW.Core.Add Pre To Report WAF IP/ASN/country correlation (${CLOUDFLARE_ZONE_ID}):\n${result.stdout} + +Break Down WAF Activity by Hostname and Request Path for Zone `${CLOUDFLARE_ZONE_ID}` + [Documentation] Summarizes sampled hits per hostname/path combination when Cloudflare returns host metadata so hotspots can be tuned surgically. + [Tags] Cloudflare WAF paths access:read-only data:metrics + + ${result}= RW.CLI.Run Bash File + ... bash_file=aggregate-waf-by-path.sh + ... env=${env} + ... include_in_history=false + ... timeout_seconds=120 + ... show_in_rwl_cheatsheet=false + ... cmd_override=./aggregate-waf-by-path.sh + + ${issues}= RW.CLI.Run Cli + ... cmd=cat cloudflare_waf_path_issues.json + ... timeout_seconds=30 + TRY + ${issue_list}= Evaluate json.loads(r'''${issues.stdout}''') json + EXCEPT + Log Failed to parse JSON for path aggregation task, defaulting to empty list. WARN + ${issue_list}= Create List + END + IF len(@{issue_list}) > 0 + FOR ${issue} IN @{issue_list} + RW.Core.Add Issue + ... severity=${issue['severity']} + ... expected=Path aggregation should parse normalized JSON successfully + ... actual=${issue['details']} + ... title=${issue['title']} + ... reproduce_hint=${result.cmd} + ... details=${issue['details']} + ... next_steps=${issue['next_steps']} + END + END + + RW.Core.Add Pre To Report WAF hostname/path breakdown (${CLOUDFLARE_ZONE_ID}):\n${result.stdout} + +Evaluate WAF Volume and Spike Thresholds for Zone `${CLOUDFLARE_ZONE_ID}` + [Documentation] Compares sampled totals and dominant buckets against operator thresholds plus optional primary-vs-prior spike ratios, emitting structured remediation hints when exceeded. + [Tags] Cloudflare WAF thresholds access:read-only data:metrics + + ${result}= RW.CLI.Run Bash File + ... bash_file=evaluate-waf-thresholds.sh + ... env=${env} + ... include_in_history=false + ... timeout_seconds=120 + ... show_in_rwl_cheatsheet=false + ... cmd_override=./evaluate-waf-thresholds.sh + + ${issues}= RW.CLI.Run Cli + ... cmd=cat cloudflare_waf_threshold_issues.json + ... timeout_seconds=30 + TRY + ${issue_list}= Evaluate json.loads(r'''${issues.stdout}''') json + EXCEPT + Log Failed to parse JSON for threshold evaluation task, defaulting to empty list. WARN + ${issue_list}= Create List + END + IF len(@{issue_list}) > 0 + FOR ${issue} IN @{issue_list} + RW.Core.Add Issue + ... severity=${issue['severity']} + ... expected=WAF sampled volumes should remain below configured operational thresholds during steady-state traffic + ... actual=${issue['details']} + ... title=${issue['title']} + ... reproduce_hint=${result.cmd} + ... details=${issue['details']} + ... next_steps=${issue['next_steps']} + END + END + + RW.Core.Add Pre To Report Threshold evaluation (${CLOUDFLARE_ZONE_ID}):\n${result.stdout} + +Produce Consolidated WAF Correlation Report for Zone `${CLOUDFLARE_ZONE_ID}` + [Documentation] Prints a human-readable rollup referencing upstream aggregation artifacts so incidents can be handed off quickly alongside structured telemetry paths. + [Tags] Cloudflare WAF report access:read-only data:metrics + + ${result}= RW.CLI.Run Bash File + ... bash_file=report-waf-correlation-summary.sh + ... env=${env} + ... include_in_history=false + ... timeout_seconds=90 + ... show_in_rwl_cheatsheet=false + ... cmd_override=./report-waf-correlation-summary.sh + + ${issues}= RW.CLI.Run Cli + ... cmd=cat cloudflare_waf_report_issues.json + ... timeout_seconds=30 + TRY + ${issue_list}= Evaluate json.loads(r'''${issues.stdout}''') json + EXCEPT + Log Failed to parse JSON for consolidated report task, defaulting to empty list. WARN + ${issue_list}= Create List + END + IF len(@{issue_list}) > 0 + FOR ${issue} IN @{issue_list} + RW.Core.Add Issue + ... severity=${issue['severity']} + ... expected=Summary rendering should complete without faults once upstream artifacts exist + ... actual=${issue['details']} + ... title=${issue['title']} + ... reproduce_hint=${result.cmd} + ... details=${issue['details']} + ... next_steps=${issue['next_steps']} + END + END + + RW.Core.Add Pre To Report Consolidated report (${CLOUDFLARE_ZONE_ID}):\n${result.stdout} + +*** Keywords *** +Suite Initialization + TRY + ${cloudflare_api_token}= RW.Core.Import Secret cloudflare_api_token + ... type=string + ... description=Cloudflare API token with Analytics read plus Firewall/WAF scopes for GraphQL queries + ... pattern=\w* + Set Suite Variable ${cloudflare_api_token} ${cloudflare_api_token} + EXCEPT + Log cloudflare_api_token secret missing — Cloudflare tasks cannot authenticate until configured. WARN + Set Suite Variable ${cloudflare_api_token} ${EMPTY} + END + + ${CLOUDFLARE_ZONE_ID}= RW.Core.Import User Variable CLOUDFLARE_ZONE_ID + ... type=string + ... description=Cloudflare zone identifier (zone tag) scoped for firewall analytics queries. + ... pattern=\w+ + ${CLOUDFLARE_ACCOUNT_ID}= RW.Core.Import User Variable CLOUDFLARE_ACCOUNT_ID + ... type=string + ... description=Optional Cloudflare account identifier retained for future dataset filters. + ... pattern=^[\w-]*$ + ... default= + ${WAF_LOOKBACK_MINUTES}= RW.Core.Import User Variable WAF_LOOKBACK_MINUTES + ... type=string + ... description=Primary analytics window length in minutes. + ... pattern=^\d+$ + ... default=60 + ${WAF_COMPARE_LOOKBACK_MINUTES}= RW.Core.Import User Variable WAF_COMPARE_LOOKBACK_MINUTES + ... type=string + ... description=Minutes for the comparison window immediately before the primary window (0 disables spike ratio logic). + ... pattern=^\d+$ + ... default=60 + ${WAF_TOTAL_EVENTS_ISSUE_THRESHOLD}= RW.Core.Import User Variable WAF_TOTAL_EVENTS_ISSUE_THRESHOLD + ... type=string + ... description=Raise issues when sampled primary-window rows exceed this count. + ... pattern=^\d+$ + ... default=500 + ${WAF_TOP_ENTITY_ISSUE_THRESHOLD}= RW.Core.Import User Variable WAF_TOP_ENTITY_ISSUE_THRESHOLD + ... type=string + ... description=Raise issues when any dominant bucket (rule/path/IP) exceeds this sampled count. + ... pattern=^\d+$ + ... default=100 + ${WAF_SPIKE_RATIO_THRESHOLD}= RW.Core.Import User Variable WAF_SPIKE_RATIO_THRESHOLD + ... type=string + ... description=Minimum primary/prior sampled-count ratio before spike issues emit (0 disables). + ... pattern=^\d+(\.\d+)?$ + ... default=0 + ${WAF_REPORT_TOP_N}= RW.Core.Import User Variable WAF_REPORT_TOP_N + ... type=string + ... description=Top-N entities listed in correlation tables. + ... pattern=^\d+$ + ... default=15 + ${WAF_FETCH_PAGE_LIMIT}= RW.Core.Import User Variable WAF_FETCH_PAGE_LIMIT + ... type=string + ... description=Maximum firewallEventsAdaptive rows requested per GraphQL page while paginating. + ... pattern=^\d+$ + ... default=800 + ${WAF_FETCH_MAX_PAGES}= RW.Core.Import User Variable WAF_FETCH_MAX_PAGES + ... type=string + ... description=Safety cap on pagination iterations for sampled-event retrieval. + ... pattern=^\d+$ + ... default=25 + + Set Suite Variable ${CLOUDFLARE_ZONE_ID} ${CLOUDFLARE_ZONE_ID} + Set Suite Variable ${CLOUDFLARE_ACCOUNT_ID} ${CLOUDFLARE_ACCOUNT_ID} + Set Suite Variable ${WAF_LOOKBACK_MINUTES} ${WAF_LOOKBACK_MINUTES} + Set Suite Variable ${WAF_COMPARE_LOOKBACK_MINUTES} ${WAF_COMPARE_LOOKBACK_MINUTES} + Set Suite Variable ${WAF_TOTAL_EVENTS_ISSUE_THRESHOLD} ${WAF_TOTAL_EVENTS_ISSUE_THRESHOLD} + Set Suite Variable ${WAF_TOP_ENTITY_ISSUE_THRESHOLD} ${WAF_TOP_ENTITY_ISSUE_THRESHOLD} + Set Suite Variable ${WAF_SPIKE_RATIO_THRESHOLD} ${WAF_SPIKE_RATIO_THRESHOLD} + Set Suite Variable ${WAF_REPORT_TOP_N} ${WAF_REPORT_TOP_N} + Set Suite Variable ${WAF_FETCH_PAGE_LIMIT} ${WAF_FETCH_PAGE_LIMIT} + Set Suite Variable ${WAF_FETCH_MAX_PAGES} ${WAF_FETCH_MAX_PAGES} + + ${env}= Create Dictionary + ... CLOUDFLARE_ZONE_ID=${CLOUDFLARE_ZONE_ID} + ... CLOUDFLARE_ACCOUNT_ID=${CLOUDFLARE_ACCOUNT_ID} + ... WAF_LOOKBACK_MINUTES=${WAF_LOOKBACK_MINUTES} + ... WAF_COMPARE_LOOKBACK_MINUTES=${WAF_COMPARE_LOOKBACK_MINUTES} + ... WAF_TOTAL_EVENTS_ISSUE_THRESHOLD=${WAF_TOTAL_EVENTS_ISSUE_THRESHOLD} + ... WAF_TOP_ENTITY_ISSUE_THRESHOLD=${WAF_TOP_ENTITY_ISSUE_THRESHOLD} + ... WAF_SPIKE_RATIO_THRESHOLD=${WAF_SPIKE_RATIO_THRESHOLD} + ... WAF_REPORT_TOP_N=${WAF_REPORT_TOP_N} + ... WAF_FETCH_PAGE_LIMIT=${WAF_FETCH_PAGE_LIMIT} + ... WAF_FETCH_MAX_PAGES=${WAF_FETCH_MAX_PAGES} + Set Suite Variable ${env} ${env} + + RW.Core.Add Pre To Report Cloudflare GraphQL reference:\n- Firewall Events tutorial: https://developers.cloudflare.com/analytics/graphql-api/tutorials/querying-firewall-events/\n- Sampling guidance: https://developers.cloudflare.com/analytics/graphql-api/sampling/\n- Token scopes: https://developers.cloudflare.com/analytics/graphql-api/getting-started/authentication/api-token-auth/ diff --git a/codebundles/cloudflare-zone-waf-report/sli-cloudflare-waf-score.sh b/codebundles/cloudflare-zone-waf-report/sli-cloudflare-waf-score.sh new file mode 100755 index 00000000..3398033d --- /dev/null +++ b/codebundles/cloudflare-zone-waf-report/sli-cloudflare-waf-score.sh @@ -0,0 +1,72 @@ +#!/usr/bin/env bash +# Lightweight Cloudflare GraphQL probe used by sli.robot — emits one-line JSON scores on stdout. +set -euo pipefail + +: "${CLOUDFLARE_ZONE_ID:?Must set CLOUDFLARE_ZONE_ID}" + +TOKEN="${CLOUDFLARE_API_TOKEN:-${cloudflare_api_token:-}}" +GRAPHQL_URL="${CLOUDFLARE_GRAPHQL_URL:-https://api.cloudflare.com/client/v4/graphql}" +LOOKBACK="${SLI_WAF_LOOKBACK_MINUTES:-15}" +MAX_ROWS="${SLI_WAF_MAX_SAMPLE_ROWS:-400}" +thr="${SLI_WAF_MAX_EVENTS:-250}" + +api_ok=0 +volume_ok=1 + +if [[ -z "${TOKEN}" ]]; then + jq -n \ + --argjson api_ok 0 \ + --argjson volume_ok 0 \ + --argjson rows 0 \ + --argjson thr "${thr}" \ + '{api_ok:$api_ok, volume_ok:$volume_ok, primary_rows:$rows, threshold:$thr}' + exit 0 +fi + +end_iso=$(date -u +%Y-%m-%dT%H:%M:%SZ) +start_iso=$(date -u -d "-${LOOKBACK} minutes" +%Y-%m-%dT%H:%M:%SZ) + +body="$(jq -n \ + --arg z "${CLOUDFLARE_ZONE_ID}" \ + --arg ds "$start_iso" \ + --arg de "$end_iso" \ + --argjson lim "${MAX_ROWS}" \ + '{ + query: "query ($zoneTag: string, $filter: FirewallEventsAdaptiveFilter_InputObject!, $limit: int!) { viewer { zones(filter: { zoneTag: $zoneTag }) { firewallEventsAdaptive(filter: $filter, limit: $limit, orderBy: [datetime_DESC]) { datetime action source clientIP } } } }", + variables: { + zoneTag: $z, + filter: { datetime_geq: $ds, datetime_leq: $de }, + limit: $lim + } + }' \ + | curl -sS --max-time 45 \ + "$GRAPHQL_URL" \ + -H "Authorization: Bearer ${TOKEN}" \ + -H "Content-Type: application/json" \ + -d @-)" + +rows=$(echo "$body" | jq '[ .data.viewer.zones[0].firewallEventsAdaptive[]? ] | length') +err_count=$(echo "$body" | jq '.errors | length // 0') +zones_len=$(echo "$body" | jq '[ .data.viewer.zones[]? ] | length') + +if [[ "${err_count}" -gt 0 ]]; then + api_ok=0 +elif [[ "${zones_len}" -eq 0 ]]; then + api_ok=0 +else + api_ok=1 +fi + +volume_ok=0 +if [[ "${api_ok}" -eq 1 ]]; then + if [[ "${rows}" -le "${thr}" ]]; then + volume_ok=1 + fi +fi + +jq -n \ + --argjson api_ok "${api_ok}" \ + --argjson volume_ok "${volume_ok}" \ + --argjson rows "${rows}" \ + --argjson thr "${thr}" \ + '{api_ok:$api_ok, volume_ok:$volume_ok, primary_rows:$rows, threshold:$thr}' diff --git a/codebundles/cloudflare-zone-waf-report/sli.robot b/codebundles/cloudflare-zone-waf-report/sli.robot new file mode 100644 index 00000000..bc05d67c --- /dev/null +++ b/codebundles/cloudflare-zone-waf-report/sli.robot @@ -0,0 +1,102 @@ +*** Settings *** +Documentation Measures Cloudflare zone WAF GraphQL reachability and sampled-event intensity across two binary dimensions then averages them into a 0–1 score aligned with the zone firewall analytics bundle thresholds. +Metadata Author rw-codebundle-agent +Metadata Display Name Cloudflare Zone WAF Quick Score SLI +Metadata Supports Cloudflare WAF Analytics SLI + +Library BuiltIn +Library RW.Core +Library RW.CLI +Library RW.platform + +Suite Setup Suite Initialization + +*** Keywords *** +Suite Initialization + TRY + ${cloudflare_api_token}= RW.Core.Import Secret cloudflare_api_token + ... type=string + ... description=Cloudflare API token with Analytics read scope for lightweight firewall sampling. + ... pattern=\w* + Set Suite Variable ${cloudflare_api_token} ${cloudflare_api_token} + EXCEPT + Log cloudflare_api_token secret missing — SLI will score zero until configured. WARN + Set Suite Variable ${cloudflare_api_token} ${EMPTY} + END + + ${CLOUDFLARE_ZONE_ID}= RW.Core.Import User Variable CLOUDFLARE_ZONE_ID + ... type=string + ... description=Cloudflare zone tag targeted by GraphQL analytics probes. + ... pattern=\w+ + ${SLI_WAF_LOOKBACK_MINUTES}= RW.Core.Import User Variable SLI_WAF_LOOKBACK_MINUTES + ... type=string + ... description=Short lookback window for SLI sampling (keep <=15 minutes for sub-30s runtime). + ... pattern=^\d+$ + ... default=15 + ${SLI_WAF_MAX_SAMPLE_ROWS}= RW.Core.Import User Variable SLI_WAF_MAX_SAMPLE_ROWS + ... type=string + ... description=Maximum rows pulled during SLI GraphQL probe (bounds latency). + ... pattern=^\d+$ + ... default=400 + ${SLI_WAF_MAX_EVENTS}= RW.Core.Import User Variable SLI_WAF_MAX_EVENTS + ... type=string + ... description=Maximum acceptable sampled hits inside SLI window before volume sub-score fails. + ... pattern=^\d+$ + ... default=250 + + Set Suite Variable ${CLOUDFLARE_ZONE_ID} ${CLOUDFLARE_ZONE_ID} + Set Suite Variable ${SLI_WAF_LOOKBACK_MINUTES} ${SLI_WAF_LOOKBACK_MINUTES} + Set Suite Variable ${SLI_WAF_MAX_SAMPLE_ROWS} ${SLI_WAF_MAX_SAMPLE_ROWS} + Set Suite Variable ${SLI_WAF_MAX_EVENTS} ${SLI_WAF_MAX_EVENTS} + + ${env}= Create Dictionary + ... CLOUDFLARE_ZONE_ID=${CLOUDFLARE_ZONE_ID} + ... SLI_WAF_LOOKBACK_MINUTES=${SLI_WAF_LOOKBACK_MINUTES} + ... SLI_WAF_MAX_SAMPLE_ROWS=${SLI_WAF_MAX_SAMPLE_ROWS} + ... SLI_WAF_MAX_EVENTS=${SLI_WAF_MAX_EVENTS} + Set Suite Variable ${env} ${env} + + Set Suite Variable ${score_api} 0 + Set Suite Variable ${score_volume} 0 + +*** Tasks *** +Score Cloudflare GraphQL Firewall Sampling Reachability + [Documentation] Runs the bundled bash probe against firewallEventsAdaptive to verify credentials and schema compatibility before averaging scores. + [Tags] Cloudflare WAF sli access:read-only data:metrics + + ${result}= RW.CLI.Run Bash File + ... bash_file=sli-cloudflare-waf-score.sh + ... env=${env} + ... secret__cloudflare_api_token=${cloudflare_api_token} + ... include_in_history=false + ... timeout_seconds=60 + ... show_in_rwl_cheatsheet=false + ... cmd_override=./sli-cloudflare-waf-score.sh + + TRY + ${payload}= Evaluate json.loads(r'''${result.stdout}''') json + ${api}= Evaluate int(${payload}.get('api_ok') or 0) + ${vol}= Evaluate int(${payload}.get('volume_ok') or 0) + ${rows}= Evaluate int(${payload}.get('primary_rows') or 0) + EXCEPT + Log Failed to parse SLI probe JSON — defaulting scores to zero. WARN + ${api}= Set Variable 0 + ${vol}= Set Variable 0 + ${rows}= Set Variable 0 + END + + Set Suite Variable ${score_api} ${api} + Set Suite Variable ${score_volume} ${vol} + + RW.Core.Push Metric ${api} sub_name=waf_graphql_ok + RW.Core.Push Metric ${vol} sub_name=waf_volume_ok + RW.Core.Add To Report SLI probe rows=${rows}; api_ok=${api}; volume_ok=${vol} + +Generate Aggregate Cloudflare WAF SLI Score + [Documentation] Averages API reachability and sampled-volume binary scores into the aggregate SLI metric expected by the platform (mean of two dimensions). + [Tags] Cloudflare WAF sli access:read-only data:metrics + + ${health_score}= Evaluate (${score_api} + ${score_volume}) / 2.0 + ${health_score}= Convert To Number ${health_score} 2 + RW.Core.Add To Report Cloudflare WAF SLI score: ${health_score} (api=${score_api}, volume=${score_volume}) + RW.Core.Push Metric ${health_score}