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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 11 additions & 3 deletions src/lib/components/TagInput.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -6,16 +6,19 @@
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('')

let inputEl = $state<HTMLInputElement | undefined>()

// 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
Expand Down Expand Up @@ -45,14 +48,15 @@
{#each tags as tag, i (tag)}
<span class="chip">
<span class="chip-label">{tag}</span>
<button type="button" class="chip-remove" aria-label={`Remove ${tag}`} onclick={(e) => { e.stopPropagation(); remove(i) }}>
<button type="button" class="chip-remove" aria-label={`Remove ${tag}`} {disabled} onclick={(e) => { e.stopPropagation(); remove(i) }}>
<i class="fa-solid fa-xmark"></i>
</button>
</span>
{/each}
<input
bind:this={inputEl}
{id}
{disabled}
class="chip-field"
bind:value={draft}
placeholder={tags.length === 0 ? placeholder : ''}
Expand Down Expand Up @@ -104,11 +108,15 @@
transition: background-color var(--timing-faster) ease, color var(--timing-faster) ease;
}

.chip-remove:hover {
.chip-remove:hover:not(:disabled) {
background-color: hsl(var(--hsl-content) / 0.12);
color: hsl(var(--hsl-content));
}

.chip-remove:disabled {
cursor: default;
}

.chip-field {
flex: 1;
min-width: 6rem;
Expand Down
39 changes: 32 additions & 7 deletions src/lib/server/mock.ts
Original file line number Diff line number Diff line change
Expand Up @@ -162,7 +162,10 @@ const locations = [
cname: 'rcf2.deploys.app.',
cpuAllocatable: ['100m', '250m', '500m', '1000m', '2000m'],
memoryAllocatable: ['128Mi', '256Mi', '512Mi', '1Gi', '2Gi'],
features: { workloadIdentity: true, disk: {}, waf: {}, cache: {}, transform: {} },
// Only the seed location offers managed rules (OWASP CRS); sg1 keeps the
// bare waf flag so the manage page's "Not available in this location"
// card state is reachable in dev.
features: { workloadIdentity: true, disk: {}, waf: { managedRules: true }, cache: {}, transform: {} },
createdAt: CREATED_AT
},
{
Expand Down Expand Up @@ -517,6 +520,17 @@ const wafZone = {
createdBy: USER_EMAIL
}

// Managed-rules (OWASP CRS) block for the seed zone. Session-mutable so the
// manage page's card round-trips (enable → save → reload) within a dev
// session; null = never configured / cleared by a set that omitted it.
let wafSeedManagedRules: object | null = {
enabled: true,
mode: 'enforce',
paranoiaLevel: 1,
anomalyThreshold: 5,
excludedRules: [942100]
}

const wafRangeSeconds: Record<string, number> = { '1h': 3600, '6h': 21600, '12h': 43200, '1d': 86400, '7d': 604800, '30d': 2592000 }

// Synthetic match metrics for the seed zone, so the firewall index sparkline +
Expand Down Expand Up @@ -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<string, { description: string, polls: number }>()
const wafConfigured = new Map<string, { description: string, polls: number, managedRules?: object | null }>()

/**
* Build a WAF zone for a session-created location. `advance` simulates the
Expand All @@ -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,
Expand Down Expand Up @@ -1671,7 +1686,9 @@ const handlers: Record<string, (args: any) => 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
Expand Down Expand Up @@ -2158,24 +2175,32 @@ const handlers: Record<string, (args: any) => 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))
}
return ok({ project: 'acme', items })
},
'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))
}
return err('api: waf zone not found')
},
'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({})
},
Expand Down
96 changes: 96 additions & 0 deletions src/lib/waf/managed.ts
Original file line number Diff line number Diff line change
@@ -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
}
}
33 changes: 31 additions & 2 deletions src/routes/(auth)/(project)/waf/+page.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -140,6 +140,7 @@
<th>Description</th>
<th>Rules</th>
<th>Limits</th>
<th>CRS</th>
<th>Matches (24h)</th>
<th class="is-collapse is-align-right"></th>
</tr>
Expand Down Expand Up @@ -177,6 +178,17 @@
</td>
<td>{fw.rules?.length ?? 0}</td>
<td>{fw.limits?.length ?? 0}</td>
<td>
<!-- Mirrors the api Table() CRS column: on = managed rules
enabled, off = disabled but tuning kept, — = never set. -->
{#if fw.managedRules?.enabled}
<span class="crs-badge is-on">on</span>
{:else if fw.managedRules}
<span class="crs-badge">off</span>
{:else}
<span class="text-content/40">—</span>
{/if}
</td>
<td>
<!-- Reserve the loaded size in every state so the row/column keeps
its dimensions while the async sparkline fills in (no layout shift). -->
Expand Down Expand Up @@ -213,15 +225,15 @@
</tr>
{/each}
<NoDataRow
span={7}
span={8}
list={firewalls}
icon="fa-shield-halved"
message="No firewalls yet"
hint="Create a firewall to start filtering traffic in a location."
ctaLabel="Create firewall"
ctaHref={`/waf/create?project=${project}`}
{error} />
<ErrorRow span={7} {error} />
<ErrorRow span={8} {error} />
</tbody>
</table>
</div>
Expand Down Expand Up @@ -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);
}
</style>
9 changes: 6 additions & 3 deletions src/routes/(auth)/(project)/waf/edit/+page.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -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))
Expand Down Expand Up @@ -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 })
Expand Down
12 changes: 8 additions & 4 deletions src/routes/(auth)/(project)/waf/limit/+page.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -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))
Expand Down Expand Up @@ -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 })
Expand Down
Loading
Loading