diff --git a/src/lib/components/TagInput.svelte b/src/lib/components/TagInput.svelte index ce40ee93..6ac52bb5 100644 --- a/src/lib/components/TagInput.svelte +++ b/src/lib/components/TagInput.svelte @@ -6,9 +6,11 @@ placeholder?: string /** id for the inner input (label association) */ id?: string + /** freeze the control — chips stay visible but nothing is editable */ + disabled?: boolean } - let { tags = $bindable([]), placeholder = '', id }: Props = $props() + let { tags = $bindable([]), placeholder = '', id, disabled = false }: Props = $props() let draft = $state('') @@ -16,6 +18,7 @@ // Commit the current draft as a chip: trim, ignore empties, de-dupe. function commit () { + if (disabled) return const v = draft.trim() draft = '' if (!v) return @@ -45,7 +48,7 @@ {#each tags as tag, i (tag)} {tag} - @@ -53,6 +56,7 @@ = { '1h': 3600, '6h': 21600, '12h': 43200, '1d': 86400, '7d': 604800, '30d': 2592000 } // Synthetic match metrics for the seed zone, so the firewall index sparkline + @@ -601,7 +615,7 @@ function wafLimitMetrics (timeRange?: string) { // times the zone has been read while pending; the deployer is simulated by // flipping the zone from pending → success after the first poll, so the index // spinner visibly resolves on its own. Lets create → manage flow work too. -const wafConfigured = new Map() +const wafConfigured = new Map() /** * Build a WAF zone for a session-created location. `advance` simulates the @@ -623,6 +637,7 @@ function wafConfiguredZone (location: string, advance = false) { description: entry.description, rules: [], limits: [], + managedRules: entry.managedRules ?? null, status, action: 'create', createdAt: CREATED_AT, @@ -1671,7 +1686,9 @@ const handlers: Record object> = { }, 'location.list': () => list(locations), - 'location.get': (args) => ok(locations.find((l) => l.id === args?.location) ?? locations[0]), + // Callers pass the location as `id` (matching the real API); `location` is + // kept as a legacy fallback. + 'location.get': (args) => ok(locations.find((l) => l.id === (args?.id ?? args?.location)) ?? locations[0]), 'deployment.list': () => list(deployments.map(toDeploymentListItem)), // revision > 0 returns that revision's historical spec (like the real @@ -2158,7 +2175,7 @@ const handlers: Record object> = { // wafConfiguredZone), so the index spinner resolves on its own. waf.set and // waf.delete keep the index/create/manage flows coherent within a session. 'waf.list': () => { - const items = [{ ...wafZone, location: LOCATION_ID }] + const items = [{ ...wafZone, location: LOCATION_ID, managedRules: wafSeedManagedRules }] for (const location of wafConfigured.keys()) { items.push(wafConfiguredZone(location, true)) } @@ -2166,7 +2183,7 @@ const handlers: Record object> = { }, 'waf.get': (args) => { const location = args?.location ?? LOCATION_ID - if (location === LOCATION_ID) return ok({ ...wafZone, location: LOCATION_ID }) + if (location === LOCATION_ID) return ok({ ...wafZone, location: LOCATION_ID, managedRules: wafSeedManagedRules }) if (wafConfigured.has(location)) { return ok(wafConfiguredZone(location)) } @@ -2174,8 +2191,16 @@ const handlers: Record object> = { }, 'waf.set': (args) => { const location = args?.location - if (location && location !== LOCATION_ID) { - wafConfigured.set(location, { description: args?.description ?? '', polls: 0 }) + // waf.set replaces the whole zone: an omitted managedRules clears the + // block (whole-replace semantics), so ?? null — not "keep". + if (location === LOCATION_ID) { + wafSeedManagedRules = args?.managedRules ?? null + } else if (location) { + wafConfigured.set(location, { + description: args?.description ?? '', + polls: 0, + managedRules: args?.managedRules ?? null + }) } return ok({}) }, diff --git a/src/lib/waf/managed.ts b/src/lib/waf/managed.ts new file mode 100644 index 00000000..fa62d753 --- /dev/null +++ b/src/lib/waf/managed.ts @@ -0,0 +1,96 @@ +// Shared managed-rules (OWASP CRS) helpers for the firewall manage page — +// form ⇄ api normalization, mirroring limits.ts. The block is typed knobs +// only: the platform generates the SecLang document server-side, so the form +// never handles raw rules, just ints and CRS rule ids. + +export interface ManagedForm { + enabled: boolean + mode: 'enforce' | 'detect' + paranoiaLevel: number + anomalyThreshold: number + // Chip strings as typed; parsed/validated via excludedRuleError below. + excludedRules: string[] +} + +export const DEFAULT_PARANOIA_LEVEL = 1 +export const DEFAULT_ANOMALY_THRESHOLD = 5 + +// Exclusion bounds mirror the api contract: only CRS detection-rule ids — +// below the floor sit the platform SecActions + CRS setup, at/above the +// ceiling the anomaly scoring machinery. +export const EXCLUDED_RULE_ID_MIN = 911100 +export const EXCLUDED_RULE_ID_MAX = 948999 +export const MAX_EXCLUDED_RULES = 50 + +export const paranoiaLevels: { value: number, label: string, description: string }[] = [ + { value: 1, label: '1', description: 'Baseline, minimal false positives' }, + { value: 2, label: '2', description: 'Elevated — some false positives' }, + { value: 3, label: '3', description: 'Strict — expect to tune exclusions' }, + { value: 4, label: '4', description: 'Paranoid, expect false positives' } +] + +export const modeOptions: { value: 'enforce' | 'detect', label: string }[] = [ + { value: 'enforce', label: 'Enforce (block)' }, + { value: 'detect', label: 'Detect only (log, never block)' } +] + +/** + * Map an API managed-rules block (or its absence) to form state, rendering + * the server-side defaults (0 = PL1 / threshold 5) explicitly. + */ +export function managedForm (m?: Api.WafManagedRules | null): ManagedForm { + return { + enabled: m?.enabled ?? false, + mode: m?.mode === 'detect' ? 'detect' : 'enforce', + paranoiaLevel: m?.paranoiaLevel || DEFAULT_PARANOIA_LEVEL, + anomalyThreshold: m?.anomalyThreshold || DEFAULT_ANOMALY_THRESHOLD, + excludedRules: (m?.excludedRules ?? []).map((id) => String(id)) + } +} + +/** + * Validation message for one excluded-rule chip, or '' when valid. + */ +export function excludedRuleError (raw: string): string { + const trimmed = raw.trim() + if (!/^\d+$/.test(trimmed)) return `${raw} is not a CRS rule id` + const id = Number(trimmed) + if (id < EXCLUDED_RULE_ID_MIN || id > EXCLUDED_RULE_ID_MAX) { + return `${raw} is out of range (${EXCLUDED_RULE_ID_MIN}–${EXCLUDED_RULE_ID_MAX})` + } + return '' +} + +/** + * First validation problem across the whole form, or '' when saveable. + */ +export function managedFormError (f: ManagedForm): string { + if (f.excludedRules.length > MAX_EXCLUDED_RULES) { + return `At most ${MAX_EXCLUDED_RULES} excluded rules` + } + for (const raw of f.excludedRules) { + const err = excludedRuleError(raw) + if (err) return err + } + const threshold = Number(f.anomalyThreshold) + if (!Number.isInteger(threshold) || threshold < 1 || threshold > 100) { + return 'Anomaly threshold must be 1–100' + } + return '' +} + +/** + * Map form state back to the API block. Only call on a valid form + * (managedFormError === ''). Exclusions are deduped and sorted so repeated + * saves are byte-identical. + */ +export function toApiManaged (f: ManagedForm): Api.WafManagedRules { + const ids = [...new Set(f.excludedRules.map((raw) => Number(raw.trim())))].sort((a, b) => a - b) + return { + enabled: f.enabled, + mode: f.mode === 'detect' ? 'detect' : 'enforce', + paranoiaLevel: Number(f.paranoiaLevel) || DEFAULT_PARANOIA_LEVEL, + anomalyThreshold: Number(f.anomalyThreshold) || DEFAULT_ANOMALY_THRESHOLD, + excludedRules: ids + } +} diff --git a/src/routes/(auth)/(project)/waf/+page.svelte b/src/routes/(auth)/(project)/waf/+page.svelte index f176f92e..9f8610d4 100644 --- a/src/routes/(auth)/(project)/waf/+page.svelte +++ b/src/routes/(auth)/(project)/waf/+page.svelte @@ -140,6 +140,7 @@ Description Rules Limits + CRS Matches (24h) @@ -177,6 +178,17 @@ {fw.rules?.length ?? 0} {fw.limits?.length ?? 0} + + + {#if fw.managedRules?.enabled} + on + {:else if fw.managedRules} + off + {:else} + + {/if} + @@ -213,7 +225,7 @@ {/each} - + @@ -251,4 +263,21 @@ .matches-link:hover { color: hsl(var(--hsl-primary)); } + + .crs-badge { + display: inline-flex; + align-items: center; + padding: 0.125rem 0.625rem; + border-radius: 9999px; + font-size: 0.75rem; + font-weight: 600; + line-height: 1.5; + color: hsl(var(--hsl-content) / 0.55); + background-color: hsl(var(--hsl-content) / 0.06); + } + + .crs-badge.is-on { + color: hsl(var(--hsl-positive)); + background-color: hsl(var(--hsl-positive) / 0.12); + } diff --git a/src/routes/(auth)/(project)/waf/edit/+page.svelte b/src/routes/(auth)/(project)/waf/edit/+page.svelte index affb108a..ef30f5fe 100644 --- a/src/routes/(auth)/(project)/waf/edit/+page.svelte +++ b/src/routes/(auth)/(project)/waf/edit/+page.svelte @@ -33,9 +33,11 @@ // The whole loaded zone's rules (ordered) are held in memory so Save can // rewrite the entire zone with the edited rule in place. const rules = untrack(() => normalizeRules(data.zone?.rules)) - // waf.set replaces the whole zone, so the zone's limits must be echoed back - // untouched — otherwise saving a rule would wipe them. + // waf.set replaces the whole zone, so the zone's limits AND managed-rules + // block must be echoed back untouched — otherwise saving a rule would wipe + // them (an omitted managedRules means "disabled and cleared" server-side). const limits = untrack(() => normalizeLimits(data.zone?.limits)) + const managedRules = untrack(() => data.zone?.managedRules ?? null) const description = untrack(() => data.zone?.description ?? '') // Index of the rule being edited, or -1 when adding a brand-new rule. const editIndex = untrack(() => (data.ruleId ? rules.findIndex((r) => r.id === data.ruleId) : -1)) @@ -108,7 +110,8 @@ location, description, rules: toApiRules(nextRules), - limits: toApiLimits(limits) + limits: toApiLimits(limits), + managedRules: managedRules ?? undefined }, fetch) if (!resp.ok) { modal.error({ error: resp.error }) diff --git a/src/routes/(auth)/(project)/waf/limit/+page.svelte b/src/routes/(auth)/(project)/waf/limit/+page.svelte index a7eb1f72..5fe5c116 100644 --- a/src/routes/(auth)/(project)/waf/limit/+page.svelte +++ b/src/routes/(auth)/(project)/waf/limit/+page.svelte @@ -44,11 +44,14 @@ { value: 503, label: '503 Service Unavailable' } ] - // The whole loaded zone (rules + limits) is held in memory so Save can - // rewrite the entire zone with the edited limit in place — waf.set replaces - // the zone, so rules must be echoed back untouched. + // The whole loaded zone (rules + limits + managed rules) is held in memory + // so Save can rewrite the entire zone with the edited limit in place — + // waf.set replaces the zone, so rules and the managed-rules block must be + // echoed back untouched (an omitted managedRules means "disabled and + // cleared" server-side). const rules = untrack(() => normalizeRules(data.zone?.rules)) const limits = untrack(() => normalizeLimits(data.zone?.limits)) + const managedRules = untrack(() => data.zone?.managedRules ?? null) const description = untrack(() => data.zone?.description ?? '') // Index of the limit being edited, or -1 when adding a brand-new limit. const editIndex = untrack(() => (data.limitId ? limits.findIndex((l) => l.id === data.limitId) : -1)) @@ -136,7 +139,8 @@ location, description, rules: toApiRules(rules), - limits: toApiLimits(nextLimits) + limits: toApiLimits(nextLimits), + managedRules: managedRules ?? undefined }, fetch) if (!resp.ok) { modal.error({ error: resp.error }) diff --git a/src/routes/(auth)/(project)/waf/manage/+page.svelte b/src/routes/(auth)/(project)/waf/manage/+page.svelte index 65d7643b..dafa278e 100644 --- a/src/routes/(auth)/(project)/waf/manage/+page.svelte +++ b/src/routes/(auth)/(project)/waf/manage/+page.svelte @@ -8,16 +8,20 @@ import GuardedButton from '$lib/components/GuardedButton.svelte' import WafTestPanel from '$lib/components/WafTestPanel.svelte' import WafCopyModal from '$lib/components/WafCopyModal.svelte' + import Select from '$lib/components/Select.svelte' + import TagInput from '$lib/components/TagInput.svelte' import type { RuleForm } from '$lib/waf/rules' import { actionLabels, normalizeRules, toApiRules } from '$lib/waf/rules' import type { LimitForm } from '$lib/waf/limits' import { describeKey, keyRowToApi, modeLabels, normalizeLimits, toApiLimits } from '$lib/waf/limits' import { wafListRefs } from '$lib/waf/expression' + import { managedForm, managedFormError, modeOptions, paranoiaLevels, toApiManaged } from '$lib/waf/managed' const { data }: { data: PageData } = $props() const project = $derived(data.project) const location = $derived(data.location) + const managedRulesSupported = $derived(data.managedRulesSupported) let copyModal = $state() @@ -26,6 +30,11 @@ let description = $state(untrack(() => data.zone?.description ?? '')) let rules = $state(untrack(() => normalizeRules(data.zone?.rules))) let limits = $state(untrack(() => normalizeLimits(data.zone?.limits))) + let managed = $state(untrack(() => managedForm(data.zone?.managedRules))) + // Last-saved managed block, echoed on every rule/limit write so an unsaved + // card edit never rides along a rule save (waf.set replaces the whole zone, + // so SOMETHING must always be sent for managed rules — the server state). + let savedManaged = $state(untrack(() => data.zone?.managedRules ?? null)) let loadedLocation = untrack(() => data.location ?? '') @@ -40,6 +49,8 @@ description = next.zone?.description ?? '' rules = normalizeRules(next.zone?.rules) limits = normalizeLimits(next.zone?.limits) + managed = managedForm(next.zone?.managedRules) + savedManaged = next.zone?.managedRules ?? null } }) }) @@ -66,18 +77,24 @@ description = zone?.description ?? '' rules = normalizeRules(zone?.rules) limits = normalizeLimits(zone?.limits) + managed = managedForm(zone?.managedRules) + savedManaged = zone?.managedRules ?? null } // Persist the whole zone (priority follows row order). waf.set replaces the - // entire zone, so rules and limits must always travel together. On error, - // surface it and reload from the server so the UI matches reality. - async function persistZone (nextRules: RuleForm[], nextLimits: LimitForm[] = limits) { + // entire zone, so rules, limits, and the managed-rules block must always + // travel together. On error, surface it and reload from the server so the + // UI matches reality. + async function persistZone (nextRules: RuleForm[], nextLimits: LimitForm[] = limits, nextManaged: Api.WafManagedRules | null = savedManaged) { const resp = await api.invoke('waf.set', { project, location, description, rules: toApiRules(nextRules), - limits: toApiLimits(nextLimits) + limits: toApiLimits(nextLimits), + // Omitted (undefined) = cleared server-side, which is exactly the + // state a null savedManaged mirrors. + managedRules: nextManaged ?? undefined }, fetch) if (!resp.ok) { modal.error({ error: resp.error }) @@ -87,6 +104,45 @@ return true } + let savingManaged = $state(false) + const managedError = $derived(managedFormError(managed)) + // Nothing to save while the block is off and the server has none — saving + // would only persist untouched defaults as tuning. + const managedSaveDisabled = $derived((!managed.enabled && savedManaged == null) || managedError !== '') + + async function saveManagedRules () { + if (savingManaged || managedSaveDisabled) return + savingManaged = true + try { + const block = toApiManaged(managed) + if (await persistZone(rules, limits, block)) { + savedManaged = block + managed = managedForm(block) + } + } finally { + savingManaged = false + } + } + + // A location can lose the managed-rules feature flag while a zone still + // carries an ENABLED block (ops un-flagging, or a block set via CLI). The + // server rejects any save echoing enabled:true in an un-flagged location, + // which would make every rule/limit save on this page fail — so offer the + // one write the gate does accept: enabled:false with the tuning kept. + async function disableManagedRules () { + if (savingManaged || !savedManaged) return + savingManaged = true + try { + const block = { ...savedManaged, enabled: false } + if (await persistZone(rules, limits, block)) { + savedManaged = block + managed = managedForm(block) + } + } finally { + savingManaged = false + } + } + async function moveRule (i: number, dir: -1 | 1) { const j = i + dir if (j < 0 || j >= rules.length) return @@ -319,6 +375,125 @@

+
+
Managed rules (OWASP Core Rule Set)
+

+ Curated signatures for SQL injection, XSS, path traversal, and + scanner traffic. Runs after your rules, before rate limits — a + managed-rule block never consumes rate budget. +

+
+ + {#if !managedRulesSupported} +
+ + Not available in this location +
+ {#if savedManaged} +

+ This zone still carries a stored managed-rules block: + {savedManaged.enabled ? 'enabled' : 'disabled'} + · {savedManaged.mode === 'detect' ? 'detect' : 'enforce'} + · paranoia level {savedManaged.paranoiaLevel || 1} + · threshold {savedManaged.anomalyThreshold || 5} + {#if savedManaged.excludedRules?.length} + · {savedManaged.excludedRules.length} excluded rule{savedManaged.excludedRules.length === 1 ? '' : 's'} + {/if} +

+ {#if savedManaged.enabled} +

+ While the block stays enabled, saves in this location are + rejected. Disable it (your tuning is kept) to keep managing + rules and rate limits here. +

+
+ + Disable managed rules + +
+ {/if} + {/if} + {:else} + + + {#if !managed.enabled && savedManaged != null} +

+ Disabled — your tuning is kept for re-enable. +

+ {/if} + +
+
+ + +
+

+ Each critical match scores 5; lower = stricter. Default 5. +

+
+ + +
+ Paranoia level +
+ {#each paranoiaLevels as pl (pl.value)} + + {/each} +
+

+ {paranoiaLevels.find((pl) => pl.value === managed.paranoiaLevel)?.description} +

+
+ +
+ +
+ +
+

+ CRS rule ids to disable for false-positive relief + (CRS rule reference). +

+ {#if managedError} +

{managedError}

+ {/if} +
+ +
+ + Save managed rules + +
+ {/if} + +
+
+
+
Rate limits
@@ -481,4 +656,58 @@ .list-chip:hover { background-color: hsl(var(--hsl-primary) / 0.18); } + + /* Managed rules aren't offered here — an informative dead-end, not an error. */ + .managed-unavailable { + display: flex; + align-items: center; + gap: 0.625rem; + padding: 0.75rem 1rem; + border-radius: 0.625rem; + border: 1px dashed hsl(var(--hsl-line)); + color: hsl(var(--hsl-content) / 0.55); + font-size: 0.875rem; + } + + /* Segmented 1–4 picker, visually matching RangeSwitch (which is hardwired + to the metrics time ranges, so it can't be reused directly). */ + .paranoia-switch { + display: inline-flex; + padding: 0.1875rem; + gap: 0.125rem; + border-radius: 0.625rem; + background: hsl(var(--hsl-base-200)); + border: 1px solid hsl(var(--hsl-line) / 0.7); + width: fit-content; + } + + .paranoia-btn { + padding: 0.3125rem 0.875rem; + border-radius: 0.4375rem; + font-size: 0.75rem; + font-weight: 600; + font-variant-numeric: tabular-nums; + color: hsl(var(--hsl-content) / 0.6); + transition: color var(--timing-fastest) ease, background var(--timing-fastest) ease; + } + + .paranoia-btn:hover:not(:disabled) { + color: hsl(var(--hsl-content) / 0.9); + } + + .paranoia-btn.is-active { + color: hsl(var(--hsl-primary-content)); + background: hsl(var(--hsl-primary)); + } + + .paranoia-btn:disabled { + cursor: not-allowed; + opacity: 0.5; + } + + /* The TagInput itself is disabled while the block is off (mouse AND + keyboard); this only mutes the kept-but-frozen tuning visually. */ + .managed-excluded.is-disabled { + opacity: 0.55; + } diff --git a/src/routes/(auth)/(project)/waf/manage/+page.ts b/src/routes/(auth)/(project)/waf/manage/+page.ts index b7aeef2e..a76d59a8 100644 --- a/src/routes/(auth)/(project)/waf/manage/+page.ts +++ b/src/routes/(auth)/(project)/waf/manage/+page.ts @@ -10,7 +10,16 @@ export const load: PageLoad = async ({ url, parent, fetch }) => { redirect(302, `/waf?project=${project}`) } - const res = await api.invoke('waf.get', { project, location }, fetch) + // location.get pre-flight: managed rules (OWASP CRS) are gated per location + // (features.waf.managedRules), so the card renders a disabled "Not available + // in this location" state instead of letting the user fill it in and hit the + // server-side reject on save. Same convention as the me.authorized/can() + // permission pre-flight. Best-effort: a lookup failure fails closed to the + // unavailable state (the server still enforces the gate). + const [res, locRes] = await Promise.all([ + api.invoke('waf.get', { project, location }, fetch), + api.invoke('location.get', { project, id: location }, fetch) + ]) // You only reach manage for a configured location — a missing zone means the // firewall isn't configured here, so send the user back to the index. if (!res.ok) { @@ -22,6 +31,7 @@ export const load: PageLoad = async ({ url, parent, fetch }) => { return { project, location, - zone: res.result + zone: res.result, + managedRulesSupported: (locRes.ok && locRes.result?.features?.waf?.managedRules) ?? false } } diff --git a/src/types/api.d.ts b/src/types/api.d.ts index ebabf6bd..9a790f7d 100644 --- a/src/types/api.d.ts +++ b/src/types/api.d.ts @@ -170,7 +170,10 @@ declare namespace Api { features: { workloadIdentity?: boolean disk?: Record - waf?: Record + // Non-null = the location supports WAF zones. managedRules gates + // the managed-rules (OWASP CRS) block on waf.set — legacy "waf": {} + // means supported without managed rules. + waf?: { managedRules?: boolean } cache?: Record transform?: Record } @@ -780,12 +783,32 @@ declare namespace Api { filter?: string } + // Managed signature ruleset (OWASP Core Rule Set via the parapet Coraza + // engine, evaluated after the zone's CEL rules and before its rate limits). + // waf.set follows the zone's whole-replace semantics: omitting the field + // clears the block; enabled:false keeps the tuning stored for re-enable. + export type WafManagedRules = { + enabled: boolean + // '' or 'enforce' = block over-threshold requests (403); + // 'detect' = rules evaluate and log but never block. + mode?: 'enforce' | 'detect' | '' + // 0 = default (1); 1..4. Higher = stricter, more false positives. + paranoiaLevel?: number + // 0 = default (5); 1..100. Each critical match scores 5. + anomalyThreshold?: number + // CRS detection-rule ids to disable (911100..948999, max 50). + excludedRules?: number[] + } + export type WafZone = { project: string location: string description: string rules: WafRule[] limits: WafLimit[] + // null/absent = never configured (or cleared); a disabled block keeps + // its tuning and round-trips through waf.get → edit → waf.set. + managedRules?: WafManagedRules | null status: 'pending' | 'success' | 'error' action: 'create' | 'delete' createdAt: string diff --git a/tests/waf-managed.spec.js b/tests/waf-managed.spec.js new file mode 100644 index 00000000..b70edd94 --- /dev/null +++ b/tests/waf-managed.spec.js @@ -0,0 +1,359 @@ +import { test, expect, setMocks, getRequestLog, pickSelect } from './helpers.js' +import { defaultLocation } from './fixtures/mocks.js' + +// A managed-rules-capable location vs a WAF-only one: the manage page's +// loader pre-flights location.get and renders the card disabled ("Not +// available in this location") when features.waf.managedRules is false. +const crsLocation = { ...defaultLocation, id: 'gke', features: { waf: { managedRules: true } } } +const wafOnlyLocation = { ...defaultLocation, id: 'gke', features: { waf: {} } } + +/** Build a WAF zone fixture. */ +function wafZone (overrides = {}) { + return { + project: 'test-project', + location: 'gke', + description: '', + rules: [], + limits: [], + status: 'success', + action: 'create', + createdAt: '2024-01-01T00:00:00Z', + createdBy: '[email protected]', + ...overrides + } +} + +/** Fetch the parsed body of the first waf.set request, polling until it exists. */ +async function firstWafSetBody () { + await expect.poll(async () => { + const log = await getRequestLog() + return log.some((r) => r.path === '/waf.set') + }).toBe(true) + const setReq = (await getRequestLog()).find((r) => r.path === '/waf.set') + return JSON.parse(setReq?.body ?? '{}') +} + +test.describe('managed rules card', () => { + test('renders disabled "Not available in this location" without the feature flag', async ({ page }) => { + await setMocks({ + 'waf.get': { ok: true, result: wafZone() }, + 'location.get': { ok: true, result: wafOnlyLocation } + }) + + await page.goto('/waf/manage?project=test-project&location=gke') + const main = page.locator('.content-wrapper') + + await expect(main.getByRole('heading', { name: 'Managed rules' })).toBeVisible() + await expect(main.getByText('Not available in this location')).toBeVisible() + // No form controls in the unavailable state. + await expect(page.locator('#managed-enabled')).toHaveCount(0) + await expect(page.getByRole('button', { name: 'Save managed rules' })).toHaveCount(0) + }) + + test('enable, tune, and save sends the managedRules block on waf.set', async ({ page }) => { + await setMocks({ + 'waf.get': { ok: true, result: wafZone() }, + 'location.get': { ok: true, result: crsLocation }, + 'waf.set': { ok: true, result: {} } + }) + + await page.goto('/waf/manage?project=test-project&location=gke') + + // Never configured + off → nothing to save yet, controls frozen. + const save = page.getByRole('button', { name: 'Save managed rules' }) + await expect(save).toBeDisabled() + await expect(page.locator('#managed-mode')).toBeDisabled() + await expect(page.locator('#managed-threshold')).toBeDisabled() + + await page.locator('#managed-enabled').check() + await expect(save).toBeEnabled() + + // Tune: paranoia 2, threshold 7, exclude one CRS id. + await page.locator('.paranoia-switch').getByRole('button', { name: '2', exact: true }).click() + await page.locator('#managed-threshold').fill('7') + await page.locator('#managed-excluded').fill('942100') + await page.locator('#managed-excluded').press('Enter') + + await save.click() + + const body = await firstWafSetBody() + expect(body.location).toBe('gke') + expect(body.managedRules).toEqual({ + enabled: true, + mode: 'enforce', + paranoiaLevel: 2, + anomalyThreshold: 7, + excludedRules: [942100] + }) + // waf.set replaces the whole zone — rules/limits are echoed alongside. + expect(body.rules).toEqual([]) + expect(body.limits).toEqual([]) + }) + + test('detect mode round-trips through the mode select', async ({ page }) => { + await setMocks({ + 'waf.get': { ok: true, result: wafZone() }, + 'location.get': { ok: true, result: crsLocation }, + 'waf.set': { ok: true, result: {} } + }) + + await page.goto('/waf/manage?project=test-project&location=gke') + + await page.locator('#managed-enabled').check() + await pickSelect(page, 'managed-mode', 'Detect only (log, never block)') + await page.getByRole('button', { name: 'Save managed rules' }).click() + + const body = await firstWafSetBody() + expect(body.managedRules.mode).toBe('detect') + }) + + test('a stored zone pre-fills the card and reload keeps it (round-trip)', async ({ page }) => { + const stored = { + enabled: true, + mode: 'enforce', + paranoiaLevel: 3, + anomalyThreshold: 10, + excludedRules: [920420, 942100] + } + await setMocks({ + 'waf.get': { ok: true, result: wafZone({ managedRules: stored }) }, + 'location.get': { ok: true, result: crsLocation } + }) + + await page.goto('/waf/manage?project=test-project&location=gke') + + await expect(page.locator('#managed-enabled')).toBeChecked() + await expect(page.locator('.paranoia-switch').getByRole('button', { name: '3', exact: true })) + .toHaveAttribute('aria-pressed', 'true') + await expect(page.locator('#managed-threshold')).toHaveValue('10') + await expect(page.getByText('920420')).toBeVisible() + await expect(page.getByText('942100')).toBeVisible() + + // A reload re-runs the loader against the same server state — the card + // re-seeds identically (the enable→save→reload contract). + await page.reload() + await expect(page.locator('#managed-enabled')).toBeChecked() + await expect(page.locator('#managed-threshold')).toHaveValue('10') + }) + + test('disabled-but-tuned block keeps fields populated and frozen', async ({ page }) => { + await setMocks({ + 'waf.get': { + ok: true, + result: wafZone({ + managedRules: { enabled: false, mode: 'enforce', paranoiaLevel: 2, anomalyThreshold: 5, excludedRules: [941100] } + }) + }, + 'location.get': { ok: true, result: crsLocation }, + 'waf.set': { ok: true, result: {} } + }) + + await page.goto('/waf/manage?project=test-project&location=gke') + + await expect(page.locator('#managed-enabled')).not.toBeChecked() + await expect(page.getByText('Disabled — your tuning is kept for re-enable.')).toBeVisible() + // Tuning stays visible but not editable while off — including for the + // keyboard (a real disabled attribute, not just pointer-events). + await expect(page.locator('#managed-mode')).toBeDisabled() + await expect(page.locator('#managed-threshold')).toBeDisabled() + await expect(page.locator('.paranoia-switch').getByRole('button', { name: '2', exact: true })).toBeDisabled() + await expect(page.locator('#managed-excluded')).toBeDisabled() + await expect(page.getByText('941100')).toBeVisible() + + // Saving while off persists enabled:false WITH the tuning (so re-enable + // restores the exclusion list) — not a cleared block. + const save = page.getByRole('button', { name: 'Save managed rules' }) + await expect(save).toBeEnabled() + await save.click() + + const body = await firstWafSetBody() + expect(body.managedRules).toEqual({ + enabled: false, + mode: 'enforce', + paranoiaLevel: 2, + anomalyThreshold: 5, + excludedRules: [941100] + }) + }) + + test('rejects out-of-range excluded rule ids client-side', async ({ page }) => { + await setMocks({ + 'waf.get': { ok: true, result: wafZone() }, + 'location.get': { ok: true, result: crsLocation } + }) + + await page.goto('/waf/manage?project=test-project&location=gke') + + await page.locator('#managed-enabled').check() + // 949110 is the anomaly-blocking rule — above the excludable ceiling. + await page.locator('#managed-excluded').fill('949110') + await page.locator('#managed-excluded').press('Enter') + + await expect(page.getByText('949110 is out of range (911100–948999)')).toBeVisible() + await expect(page.getByRole('button', { name: 'Save managed rules' })).toBeDisabled() + }) + + test('rule edits echo the stored managed block unchanged (whole-replace)', async ({ page }) => { + const stored = { enabled: true, mode: 'detect', paranoiaLevel: 1, anomalyThreshold: 5, excludedRules: [942100] } + await setMocks({ + 'waf.get': { + ok: true, + result: wafZone({ + managedRules: stored, + rules: [{ id: 'rule-a', description: '', expression: 'request.path == "/a"', action: 'log', priority: 0 }] + }) + }, + 'location.get': { ok: true, result: crsLocation }, + 'waf.set': { ok: true, result: {} } + }) + + await page.goto('/waf/manage?project=test-project&location=gke') + + // Delete the rule; the resulting waf.set must carry the SERVER's managed + // block — an unrelated write never drops or mutates managed rules. + await page.getByRole('button', { name: 'Remove rule' }).click() + await page.locator('#app-modal-confirm').click() + + const body = await firstWafSetBody() + expect(body.rules).toEqual([]) + expect(body.managedRules).toEqual(stored) + }) + + test('never-configured zone omits managedRules on unrelated writes', async ({ page }) => { + await setMocks({ + 'waf.get': { + ok: true, + result: wafZone({ + rules: [{ id: 'rule-a', description: '', expression: 'request.path == "/a"', action: 'log', priority: 0 }] + }) + }, + 'location.get': { ok: true, result: crsLocation }, + 'waf.set': { ok: true, result: {} } + }) + + await page.goto('/waf/manage?project=test-project&location=gke') + + await page.getByRole('button', { name: 'Remove rule' }).click() + await page.locator('#app-modal-confirm').click() + + const body = await firstWafSetBody() + // Whole-replace: omitted = stays cleared. Sending {} would persist tuning. + expect(body).not.toHaveProperty('managedRules') + }) + + test('rule editor save echoes the stored managed block (whole-replace)', async ({ page }) => { + const stored = { enabled: true, mode: 'enforce', paranoiaLevel: 2, anomalyThreshold: 7, excludedRules: [942100] } + await setMocks({ + 'waf.get': { + ok: true, + result: wafZone({ + managedRules: stored, + rules: [{ id: 'rule-a', description: '', expression: 'request.path == "/a"', action: 'log', priority: 0 }] + }) + }, + 'location.get': { ok: true, result: crsLocation }, + 'waf.set': { ok: true, result: {} } + }) + + await page.goto('/waf/edit?project=test-project&location=gke&rule=rule-a') + + // Saving a rule rewrites the WHOLE zone — the stored managed block must + // ride along untouched, or the server clears it (omitted = cleared). + await page.getByRole('button', { name: 'Save', exact: true }).click() + + const body = await firstWafSetBody() + expect(body.rules).toHaveLength(1) + expect(body.managedRules).toEqual(stored) + }) + + test('limit editor save echoes the stored managed block (whole-replace)', async ({ page }) => { + const stored = { enabled: true, mode: 'detect', paranoiaLevel: 1, anomalyThreshold: 5, excludedRules: [] } + await setMocks({ + 'waf.get': { + ok: true, + result: wafZone({ + managedRules: stored, + limits: [{ + id: 'limit-a', + description: '', + key: ['ip'], + rate: 100, + window: '1m', + algorithm: 'fixed', + mode: 'enforce', + status: 429, + message: 'Too Many Requests' + }] + }) + }, + 'location.get': { ok: true, result: crsLocation }, + 'waf.set': { ok: true, result: {} } + }) + + await page.goto('/waf/limit?project=test-project&location=gke&limit=limit-a') + + await page.getByRole('button', { name: 'Save', exact: true }).click() + + const body = await firstWafSetBody() + expect(body.limits).toHaveLength(1) + expect(body.managedRules).toEqual(stored) + }) + + test('unsupported location with a stored ENABLED block offers a disable exit', async ({ page }) => { + // A location can be un-flagged while a zone still carries an enabled + // block. The server rejects saves echoing enabled:true there, so the + // card must surface the stored state and the one accepted write: + // enabled:false with tuning kept. + const stored = { enabled: true, mode: 'enforce', paranoiaLevel: 2, anomalyThreshold: 7, excludedRules: [942100] } + await setMocks({ + 'waf.get': { ok: true, result: wafZone({ managedRules: stored }) }, + 'location.get': { ok: true, result: wafOnlyLocation }, + 'waf.set': { ok: true, result: {} } + }) + + await page.goto('/waf/manage?project=test-project&location=gke') + + await expect(page.getByText('Not available in this location')).toBeVisible() + // Stored state is shown read-only — no editable card controls. + await expect(page.locator('#managed-enabled')).toHaveCount(0) + await expect(page.getByText('This zone still carries a stored managed-rules block')).toBeVisible() + + await page.getByRole('button', { name: 'Disable managed rules' }).click() + + const body = await firstWafSetBody() + expect(body.managedRules).toEqual({ ...stored, enabled: false }) + + // The disable action is gone once the block is off; the read-only + // summary reflects the new state. + await expect(page.getByRole('button', { name: 'Disable managed rules' })).toHaveCount(0) + }) +}) + +test.describe('firewall list CRS column', () => { + test('shows on / off / — per zone', async ({ page }) => { + await setMocks({ + 'waf.list': { + ok: true, + result: { + project: 'test-project', + items: [ + wafZone({ location: 'gke', managedRules: { enabled: true, paranoiaLevel: 1 } }), + wafZone({ location: 'sgp', managedRules: { enabled: false, paranoiaLevel: 2 } }), + wafZone({ location: 'blr' }) + ] + } + }, + 'waf.metrics': { ok: true, result: { series: [], total: 0 } } + }) + + await page.goto('/waf?project=test-project') + const main = page.locator('.content-wrapper') + + await expect(main.getByRole('columnheader', { name: 'CRS' })).toBeVisible() + await expect(main.getByRole('row', { name: /gke/ }).getByText('on', { exact: true })).toBeVisible() + await expect(main.getByRole('row', { name: /sgp/ }).getByText('off', { exact: true })).toBeVisible() + // The never-configured zone's CRS cell (6th column) renders the em dash; + // scope to the cell since the description and matches cells use it too. + await expect(main.getByRole('row', { name: /blr/ }).locator('td').nth(5)).toHaveText('—') + }) +})