Custom Posture Rules¶
The built-in CSPM pack is not the ceiling. A rules-typed
cloudsec_policy record lets an organization
author its own posture detections — and retune, re-severity, or switch off the
built-in ones — in exactly the same format the built-in pack uses.
There is no separate rule language to learn: a posture rule is a real D&R
detection, in the same op / path / value dictionary vocabulary as the
detection & response rules you already write, evaluated against a cloud resource
presented as a cloud_resource.<ResourceType> event whose body is the resource's
normalized properties.
Authored as Hive records
Unlike classification, coverage, exclusions, and suppression, custom rules
have no console editor. They are written as Hive JSON through
limacharlie hive set (or the Hive API), which also makes them the easiest
policy to keep in a git repository and push to a fleet — see
Automation & IaC.
A first rule¶
{
"policy_type": "rules",
"rules": {
"rules": [
{
"id": "custom-public-bucket-outside-cdn",
"name": "Public bucket outside the CDN convention",
"resource_type": "DataStore",
"finding_class": "public_exposure",
"severity": "HIGH",
"title": "Storage bucket is readable by anyone on the internet",
"detect": {
"op": "and",
"rules": [
{"op": "is", "path": "event/store_kind", "value": "bucket"},
{"op": "is", "path": "event/is_public", "value": true},
{"op": "starts with", "path": "event/name", "value": "cdn-", "not": true}
]
},
"meta": {
"description": "A bucket granting read access to anyone, outside the cdn- naming convention used for deliberately public assets.",
"rationale": "Anonymous read on a bucket exposes every object in it, and our public assets are all published under the cdn- prefix.",
"references": ["https://cloud.google.com/storage/docs/access-control/making-data-public"],
"false_positives": "A newly created public asset bucket that has not been renamed to the cdn- convention yet."
}
}
]
}
}
limacharlie hive set --hive-name cloudsec_policy --key my-rules \
--oid $OID --input-file rules.json --enabled
The record is validated synchronously. The Hive compiles every detect block
on the real detection engine and checks every vocabulary, so a rule that could
never run is rejected while you are still looking at the error — never accepted
and then silently skipped hours later inside a projection you cannot see.
An organization may hold many rules records; they compose into one policy in
record-name order.
The record¶
rules is an object with two independent lists, rules (your own detections) and
overrides (retunes of rules that already exist). At least one must be non-empty
— a record that adds nothing and overrides nothing is rejected rather than saved
as a no-op.
Rule fields¶
| Field | Required | Meaning |
|---|---|---|
id |
✔ | Stable identifier, kebab-case, must start with custom-, max 64 characters. Never rename it — see Rule ids are permanent. |
resource_type |
✔ | Which resources the rule is evaluated against; routes it to cloud_resource.<type> events. |
finding_class |
✔ | misconfig or public_exposure — the whole authorable set (see below). |
severity |
✔ | CRITICAL | HIGH | MEDIUM | LOW | INFO. |
title |
✔ | What the operator reads on the finding. State the risk, not the setting: "Bucket is readable by anyone on the internet", never "uniform bucket-level access is disabled". Max 256 characters. |
detect |
✔ | The detection block (see Writing the detection). |
name |
A short internal label, for your own bookkeeping. | |
subject_path |
A top-level property key — a single lookup, not a detection path — used as the finding's subject instead of the resource URN. email, never event/email. Max 256 characters. |
|
criticality_mult |
Risk multiplier applied to lc_risk, 0 to 1.6. 0 means unset (1.0). |
|
meta |
Authoring metadata: description, rationale, references, no_framework, false_positives. Inert at evaluation time — it exists so the reasoning travels with the rule. |
resource_type must be one of:
Account · AIService · Application · ComputeInstance · ConfigStore ·
DataStore · DNSZone · EnrollmentKey · HasPermission · Identity ·
Network · TelemetryOutput
Only two finding classes, on purpose
A custom rule may emit misconfig or public_exposure and nothing else. The
other classes in the finding-class
enum are produced by other engines and carry
lifecycle behavior a single-resource rule cannot honor: vulnerability,
malware, secret, and scan_finding are asserted by scanners;
ciem_risk, privilege_escalation, and toxic_combination come from grant
expansion and attack paths; the coverage-gap classes drive their own
carry-forward semantics. A finding in one of those classes is read by
consumers that assume an evidence source a posture rule does not have.
Rule ids are permanent¶
Once a rule has produced findings, its id is never renamed. The id is load-bearing in two places:
- The finding fingerprint. A finding's dedup key is derived from the rule id
and the resources it names. Rename the id and every finding the rule ever
produced closes and a brand-new one opens: a create/close storm through the
cloud_finding.*feed, every operator disposition (accepted / false positive) orphaned, and every linked ticket pointing at a dead finding. - The compliance join. Framework catalogs map controls to rules by id.
The corollary is worth internalising: severity is safe to change, detection is not. Severity is not a fingerprint input, so retuning it updates findings in place. Changing what a rule detects while keeping its id re-purposes every historical finding underneath it — if you need different semantics, ship a new id and retire the old one.
Why custom-¶
Every customer-authored id must start with custom-, enforced at write time and
again when the pack is resolved:
rule 0: id "public-bucket" must start with "custom-" — custom rule ids share the
finding fingerprint space and the compliance join with the built-in rules, so
they are namespaced to keep them from ever colliding
Your ids land in the same fingerprint space and the same compliance keyspace as the built-ins, so the namespace makes a collision impossible by construction — and it keeps the built-in id space free to grow without ever asking whether some tenant already took a name.
Writing the detection¶
detect is the same nested dictionary a D&R rule's detect block uses:
{"op": …, "path": …, "value": …} leaves, composed with
{"op": "and" | "or", "rules": [ … ]}, and negated by adding "not": true to
any node.
Paths are rooted at event/, and the event body is the resource's normalized
properties. limacharlie cloudsec resource get "lcrn:..." prints a real
resource, which is the fastest way to see the exact property names for a type.
A few facets are computed at evaluation time
Some properties a rule can match on are derived when the rule runs rather
than stored on the resource, so they do not appear in resource get:
- Network —
covers_all_ports(the rule's port range spans the whole port space) andports_effective(the explicitly listed ports plus every well-known port a listed range contains). Both exist because a declarative rule cannot expand a port range itself; the firewall example below uses them. - Identity —
dormant,mfa_known/mfa_enabled,credential_recently_used, andservice_linked(an AWS service-linked role). - HasPermission —
effective_public_grant: the grant really does expose the target to the internet, after accounting for adenystatement and for a target whose own public-access block neutralizes it. Match on this rather than onpublic_principal, which is only half the question.
Everything else you can match on is a stored property, visible in
resource get.
The operator allowlist¶
A rule may use only these operators, anywhere in its detect tree:
and · cidr · contains · ends with · exists · is · is greater than ·
is lower than · is older than · matches · or · scope · starts with
This is a safety boundary, not a curated convenience list. The detection engine is shared with the endpoint product, and most of its other operators reach for things a cloud resource does not have — sensor platform, tags, process trees, or a service resolved through callbacks the posture evaluator does not supply. Anything outside the list is rejected at write time:
rule 0 ("custom-tagged-prod"): detect uses operator(s) is tagged, which a posture
rule may not use (allowed: and, cidr, contains, ends with, exists, is, is greater
than, is lower than, is older than, matches, or, scope, starts with)
is tagged is excluded even though it is harmless, because it reads sensor
tags a cloud resource never has: it would install cleanly and never match, which
is worse than being told no.
Value syntaxes and paths that are rejected¶
| Rejected | Why |
|---|---|
[[name]] in any value |
A sensor-variable reference. It resolves through a callback the posture evaluator does not supply — a cloud resource has no sensor variables. Use a literal value or an event/ path. |
{{ … }} in any value |
Templates can carry a pattern the regex budgets cannot see. Their only extra capability here is secret, which the posture evaluator does not supply either. |
* or ? in a path |
A wildcard makes the engine walk the resource's entire property tree for every row, so the cost is set by your data rather than by the rule (measured at 187× a literal path). Name the field explicitly. |
Path redirection (<<event/other_field>>) is fine — it is a pure extractor over
the same event the rule already addresses.
rule 0 ("custom-any-public"): detect uses the wildcard path(s) event/*/is_public
— a wildcard makes the engine walk the resource's ENTIRE props tree for every
row, so the cost is set by the data rather than by the rule (measured at 187x a
literal path). Name the field explicitly
scope — the trap that manufactures false positives¶
Whenever two conditions must hold on the same element of a list, they must be
inside a scope. This is the single most common way a hand-written posture
rule goes wrong.
Without scope, the engine flattens the list and the conditions are free to match
across different elements. A firewall allowing [{udp, 22}, {tcp, 443}] will
satisfy "protocol is tcp AND port is 22" — one condition from each element — and
report SSH open to the world on a host where it is not.
Inside a scope, the sub-rule's paths are relative to the scoped element
(protocol, not event/ingress_rule/protocols/protocol):
{
"policy_type": "rules",
"rules": {
"rules": [
{
"id": "custom-postgres-open-to-internet",
"resource_type": "Network",
"finding_class": "public_exposure",
"severity": "CRITICAL",
"title": "PostgreSQL (5432) is reachable from the entire internet",
"detect": {
"op": "and",
"rules": [
{"op": "or", "rules": [
{"op": "is", "path": "event/network_kind", "value": "firewall_rule"},
{"op": "is", "path": "event/network_kind", "value": "aws_security_group"},
{"op": "is", "path": "event/network_kind", "value": "azure_nsg"}
]},
{"op": "is", "path": "event/ingress_rule/allow", "value": true},
{"op": "is", "path": "event/ingress_rule/disabled", "value": true, "not": true},
{"op": "or", "rules": [
{"op": "is", "path": "event/ingress_rule/source_cidrs/v", "value": "0.0.0.0/0"},
{"op": "is", "path": "event/ingress_rule/source_cidrs/v", "value": "::/0"}
]},
{"op": "scope", "path": "event/ingress_rule/protocols", "rule": {
"op": "and",
"rules": [
{"op": "or", "rules": [
{"op": "is", "path": "protocol", "value": "tcp"},
{"op": "is", "path": "protocol", "value": "all"}
]},
{"op": "or", "rules": [
{"op": "exists", "path": "ports", "not": true},
{"op": "is", "path": "covers_all_ports", "value": true},
{"op": "is", "path": "ports_effective/v", "value": "5432"}
]}
]
}}
]
}
}
]
}
}
Two related path rules the example above also demonstrates:
- Lists of scalars are addressed through
/v. The resource→event adapter reshapes arrays of plain values into arrays of objects, so a list of CIDRs is matched asevent/ingress_rule/source_cidrs/v. - Absence is not a value.
{"op": "exists", "path": "ports", "not": true}is how you match "no ports listed" — and in the firewall family that absence means all ports, the worst case, not a benign default.
Never fire on silence¶
A rule must assert on an observed bad state, never on the absence of a fact.
The collectors use observation-gated properties: a boolean is absent when the collector could not determine it, and only an explicit value is stamped. A rule written as "encryption disabled" against a field that is merely missing fires on every resource the collector could not fully inspect — and a partial sweep then reads as an estate-wide breach. If a rule genuinely means "this setting is missing", pair it with a positive signal that the resource was successfully inspected.
Two more write-time checks worth knowing¶
subject_path is a top-level property key resolved by a single lookup, not a
detection path — writing it in event/… form is rejected rather than silently
resolving to nothing:
rule 0 ("custom-public-bucket"): subject_path "event/name" looks like a detection
path, but it is a top-level props key (a single map lookup) — it would resolve to
nothing and the finding would silently fall back to the resource urn
And an override that changes nothing is rejected, because the failure mode is an operator walking away believing they turned a rule off:
Overriding a built-in¶
An overrides entry retunes a rule that already exists — built-in or one of your
own — for your organization only:
{
"policy_type": "rules",
"rules": {
"overrides": [
{"rule_id": "bucket-uniform-access-disabled", "disabled": true},
{"rule_id": "open-admin-port-to-internet", "severity": "CRITICAL"}
]
}
}
An override can set disabled and/or severity. It deliberately cannot
change a rule's detection, resource type, or finding class: those decide what a
finding means, and redefining a built-in while keeping its id would corrupt the
compliance join — a control pointing at that id would be evidenced by a detection
the framework never asked for.
To change what a rule detects, author a custom- rule and disable the built-in in
the same record:
{
"policy_type": "rules",
"rules": {
"rules": [
{
"id": "custom-sa-key-older-than-30d",
"resource_type": "Identity",
"finding_class": "misconfig",
"severity": "HIGH",
"title": "Service-account credential has not been rotated in 30 days",
"subject_path": "email",
"criticality_mult": 1.2,
"detect": {
"op": "and",
"rules": [
{"op": "or", "rules": [
{"op": "is", "path": "event/kind", "value": "service_account"},
{"op": "is", "path": "event/kind", "value": "app_integration"}
]},
{"op": "is greater than", "path": "event/oldest_key_age_days", "value": 30}
]
},
"meta": {
"rationale": "Our key-rotation standard is 30 days; the built-in rule only fires at 90."
}
}
],
"overrides": [
{"rule_id": "stale-user-managed-sa-key", "disabled": true}
]
}
}
Precedence, exactly¶
- The built-in pack is the base.
- Valid custom rules are appended. An invalid one is dropped and reported.
- A duplicate custom id, or one shadowing a built-in, is dropped and reported — first wins, deterministic because records compose in sorted record-name order.
- Overrides apply last, over the union, so they can retune a built-in or a custom rule. Disable wins over severity.
- An override naming an id that is not in the pack is dropped and reported — a typo must never read as a successfully-disabled rule.
How evaluation works¶
A saved record reaches the engine at the end of the next collection sweep,
and is applied on the projection after it. Change the provider's sync_now nonce
to a new value to trigger a sweep immediately instead of waiting for the refresh
cadence.
Once it is there, evaluation is stateless and per resource: each rule is
tested against every resource of its resource_type, on every projection pass,
using only that resource's own properties. Two consequences matter in practice:
- A new rule applies to your whole existing estate, not just to resources that change afterwards. Nothing has to be backfilled — the first projection after the policy lands re-derives findings across everything already in inventory.
- Findings close by themselves. When the condition stops being true, the finding closes on the next pass, exactly like a built-in one.
Findings from custom rules are ordinary findings. They land in the same
risk-ranked worklist, carry lc_risk and its breakdown, and support the full
lifecycle: dispositions, owners, tickets, cases, suppression policy,
remediation SLAs, cause grouping, CSV export, and the
cloud_finding.* event feed. There is no
rule-id filter on the worklist, but free-text search matches the finding body, so
limacharlie cloudsec finding list -q custom-public-bucket-outside-cdn pulls up
everything one rule produced.
Evidence is rendered from the affected resource type, using the same
renderers the built-in rules use, so a custom finding carries a real
offending-configuration detail. Rule-specific evidence wording and the
ready-to-apply remediation snippets are authored per built-in rule, so a custom
rule's findings may carry none — which is the practical reason to write the
title as the action an operator must take.
Measure a broad rule before you trust it
A rule that matches a quarter of its resource type is a rule that buries the
rest of your worklist. Before relying on a new rule, count what it actually
produced — the worklist has no rule-id facet, so search for the id with
limacharlie cloudsec finding list -q custom-your-rule-id. A single
near-universal condition can produce six-figure finding counts on a large
estate, and past the per-pass budget below it costs you every custom
finding, not just its own. If the condition is real but the unit is wrong
(an account-level setting reported once per bucket), express it at the level
it is actually fixed, or ship it at LOW/INFO so it stays out of the
default worklist while remaining queryable.
Compliance interaction¶
Disabling a rule affects compliance, and it does so honestly. A control whose
only evidence comes from rules you disabled is reported NOT_ASSESSED — never
a pass.
A zero-violation result from a detector that did not run means nothing, so it is
reported as what it is: not assessed. This is the same verdict the platform gives
a control no detector covers at all, and it keeps NOT_ASSESSED out of the
compliance score's denominator rather than inflating it into a green check. See
Compliance.
The reverse direction is worth knowing before you write a broad rule. Your own
rules can never make a control pass — a control passes when nothing proves a
violation, and a rule only ever adds findings. They can, however, make one
fail: framework catalogs join controls to detections two ways, by rule id
(no catalog names a custom- id) and by finding class, scoped to the
control's resource types. public_exposure is a class several frameworks join
on, so a public_exposure rule over a resource type such a control scopes to
attaches to it as evidence and flips it to FAIL. misconfig is not a class-join
key in any shipped catalog, so a misconfig rule never moves a compliance
verdict.
Bounds¶
The projector that evaluates rules is shared infrastructure, so the limits below are availability bounds, not just per-tenant quotas.
| Bound | Value |
|---|---|
| Rules per organization | 200 |
| Rules on any one resource type | 25 |
| Overrides per organization | 1000 |
Composed policy size (all rules records together) |
512 KiB |
| Size of one record | 256 KiB |
detect size per rule |
8 KiB |
Total detect budget across the organization |
64 KiB, with a rule containing a scope charged 10× its size |
scope operators per rule |
2, never nested |
detect nesting depth |
32 |
Compiled size of one regex (matches pattern) |
1000 instructions |
| Compiled regex size, summed per rule and per organization | 5000 instructions |
| Rule id / title length | 64 / 256 characters |
criticality_mult |
0 – 1.6 |
| Custom findings kept in one projection pass | 25,000 per evaluator — the pass runs two, so roughly 50,000 per organization |
The 25-rules-per-resource-type bound is the one that surprises people, and it is the one that matters most: every rule is evaluated against every row of its type and resolves its own path independently, so per-row cost is (rules on the type) × (elements at the path) — a product neither the rule count nor the byte budget bounds. Spread rules across resource types the way the built-in pack does.
The regex bounds are program size, not pattern length
If you use the matches operator, the budget is charged against the
compiled program size of the pattern, not how long the pattern is.
Nothing about a pattern's length bounds its program: a 253-byte alternation
compiles to 7 instructions, while a 230-byte nested-group pattern compiles to
over 200,000. A byte limit would accept the expensive one and reject the
cheap one, so the check counts instructions instead — which is why the number
in a rejection message will not correspond to anything you can see by looking
at the pattern. Flatten nested groups and large bounded repeats, or express
the same set as an or of is / starts with leaves, which is far cheaper.
Reject per record, truncate across records
Within one record the Hive rejects: you are there, you can read the error, and a record you cannot save is better than one that saves and silently never applies. Across records the collector truncates, keeping the first entries in record-name order and reporting the rest — by then nobody is watching, and one runaway generator must not cost you the rules that were fine.
Because several records compose, an organization can exceed the per-type and org-wide bounds across records even though every individual record was legal. The excess is dropped, not applied, and reported on each sweep.
A bad rule cannot break your posture
Rule resolution never fails. Every rejection comes back as data: the built-in pack survives intact, and a rule that will not compile — or uses an operator outside the allowlist — is dropped rather than left to silently never match.
The finding budget is deliberately all-or-nothing: a pack that blows the 25,000-findings-per-pass cap has all of its custom findings dropped for that pass and reported, rather than an arbitrary subset kept. The cap is reached in row order, which is not stable between passes, so keeping a partial set would churn the finding feed forever.
What custom rules are not¶
- Not cross-resource correlation. A rule sees exactly one resource. "Public workload that can reach a sensitive database" is a relationship, not a property — that is what the security graph and attack paths are for, and a saved query on a schedule turns any graph query into a detection source with no new rule syntax.
- Not temporal. There is no "changed in the last hour" or "seen N times": evaluation is a stateless function of the resource's current properties.
- Not a new finding class.
misconfigandpublic_exposureare the whole authorable set. - Not a console feature. Authoring is Hive JSON through the CLI or API.