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.
+
+ 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.
+
+ Detect-only matches are currently visible to platform operators
+ only (ask support for your zone’s match report); per-project
+ match metrics arrive in a later release.
+
+
+
+
+
+
+
+
+
+ Each critical match scores 5; lower = stricter. Default 5.
+