Custom Rules
A custom rule is a detection you write in Cypher. It runs after every sync, alongside the built-in rules, and each matching row becomes a finding on the same Rules page. Built-in rules cover a shared baseline. A custom rule is how you encode a policy of your own: a tag every production instance must carry, a bucket that must stay private, a migration you want a live punch-list for.
The rule shows a custom badge. Accepting a result, disabling the rule, filtering, exporting, and the finding notifications all work the same way as for any other rule. That lifecycle is covered in Rules and Compliance. Creating, editing, and deleting a custom rule requires the operator role. Deleting a rule removes it and every finding it produced, including accepted ones.
Findings from a rule you just saved appear on the next findings build.
Title, id, and description
Title is the name on the Rules page. It is required.
Id is derived from the title when you create the rule: lowercase, and every run of characters other than letters and digits becomes a single hyphen. User accounts missing MFA becomes user-accounts-missing-mfa. The id has to be unique across built-in and custom rules, so a title that lands on a built-in id has to change before the rule can be created. After that the id is fixed. Renaming the rule changes the title only.
Description is required. It is the line under the title.
Tags and references
Tags are comma-separated labels. They feed the same tag filter as built-in rules, and the weekly findings summary groups by them. The word custom is reserved for the badge and is not stored as a tag.
References are links shown on the rule. Each one has a URL and an optional title. With no title, the URL is the label. Use them for the remediation guide or the internal runbook a reader should open next.
Queries
A rule has one or more queries. Each query is its own detection, and the findings from all of them belong to the one rule. A "public object storage" rule can have one query for S3 and another for GCS.
A query runs read-only against the graph Cartography has synced. It has to follow three rules:
- No writes.
CREATE,MERGE,DELETE,SET, andREMOVEare rejected. The graph is a copy of your environment, and a custom rule runs with read-only credentials. Changes to infrastructure belong in the provider. - A
LIMITclause is required, so a broad match cannot run unbounded. - Every returned column has an explicit
ASalias. Those aliases are the finding's fields, and they are the columns on the rule's results table. Numbers, booleans, lists, and times are stored as text.
Columns on the rule are the union of every query's aliases. A column only one query returns is empty on the other query's rows.
Each query is stored under an id built from the rule id and the query's position, such as public-buckets-0. Moving a query, or inserting one ahead of another, gives every query that changes position a new id. The findings that belonged to an old id are closed and new ones are opened. Acceptance on those findings, and the issue triage attached to them, do not carry over.
The graph view of a finding uses the same query with its last RETURN widened to the matched nodes, so the surrounding topology is visible. The editor writes that query for you.
Affected asset
The affected asset is the node the finding is about, and the node it links to in inventory and on the graph.
It is a label the query binds, plus a column returned as exactly that variable's id. u.id AS id qualifies. u.arn AS id does not, and neither does an id computed from somewhere else. When the query offers one such pair, that pair is selected. When it offers several, you choose which node the finding is about. A query that returns no node id cannot be saved: return one, for example u.id AS id.
The affected asset can sit outside the identity fields. A finding keeps its identity across syncs while the resource it points at is replaced.
Identity fields
Identity fields decide whether a row is the same finding as last sync. A new row that shares the previous values of every identity field updates that finding. Any column outside the identity can change, and the finding stays.
With none chosen, every returned column is part of the identity. That is the strict default: a change to any column's value closes the old finding and opens a new one. Adding or removing a returned column does the same, because the identity is the full set of columns.
Choose the columns that define the condition and stay put while the finding is the same finding. An aggregate such as count or collect changes while the underlying finding does not, so leave it out of the identity. When a node's id is already returned, leave that node's other properties out too. The id already pins the node.
The set of columns is what matters. Their order in the RETURN does not, once you have chosen them.
Two identical rows are one finding. The same relationship is sometimes stored twice in the graph, and those paths collapse. Two rows that share an identity and differ in anything else fail the rule. There is no single finding to write. The test run shows the two rows and the columns that differ. Fix it by adding a differing column to the identity, by returning one row with DISTINCT, or by collapsing the extra values with collect().
Changing the identity of a query that already has findings closes those findings and opens new ones. Acceptance and issue triage do not carry over. Changing the display field, or any column that is not part of the identity, updates the existing findings in place.
Display field
The display field is the column used as the finding's name. The default is the affected asset's id. Choose a name, hostname, or email when that is what a person should read in the list.
The test run
A rule is run before it is saved, the same way the findings sync will run it, against the graph as it is right then. Each query reports on its own.
- A query that passes reports how long it took.
- A query that fails the checks above reports the problem and is not executed. The other queries still run.
- A query that runs and fails reports the error and a hint for fixing it. A type the query cannot store, a query Neo4j rejects, two rows that resolve to the same finding, and a missing asset all land here.
- A query that only exceeds the editor's time limit can still be saved. The scheduled sync runs without that limit. A query that fails for any other reason cannot be saved.
Saving runs this check again. If the graph changed after the test run, the save is refused and the problems are the ones to fix.
A rule that later fails during a sync keeps the findings it already had. Its status becomes Error, with the reason and the hint, and those findings stay a mix of the last clean run and whatever the failed run wrote until the rule runs cleanly again.
Contributing a rule
By default, SubImage may review the rule, anonymize it, and propose it to the Cartography project. The proposal is the query and its settings. Nothing from your graph is included.
You can keep the rule private to your organization. Editing the rule changes the choice either way.
Examples
A bucket that must stay private
After a bucket was made public, this is the rule that lights up the moment any bucket is anonymously accessible again.
MATCH (b:AWSS3Bucket)
WHERE b.anonymous_access = true
OR (b.anonymous_actions IS NOT NULL AND size(b.anonymous_actions) > 0)
RETURN b.id AS id,
b.name AS name,
b.region AS region
LIMIT 200The affected asset is AWSS3Bucket, through b.id AS id. The identity is id, so a bucket that stays public is one finding while its region changes. The display field is name.
Accept the buckets that are public on purpose, with a reason and a date, the same way as any other rule. Anything that appears in Results after that is new.
A device that must be on the tailnet
Devices come from an MDM and Tailscale syncs separately. The ontology Device node is what both attach to, so the rule survives a change of MDM.
MATCH (d:Device)
WHERE NOT (d)-[:OBSERVED_AS]->(:TailscaleDevice)
RETURN d.id AS id,
d.hostname AS hostname,
d.platform AS platform
LIMIT 500The affected asset is Device, through d.id AS id. The identity is id. The display field is hostname, and platform can change without closing the finding. Wire the rule to the first-time-enabled notification if the rollout should show up the moment the first device is missing.
From an agent
Operators and admins can read and write custom rules over MCP. See Connect via MCP.
subimageListRuleswithview="custom_definitions"returns the tenant's definitions.exec_status="ERROR"lists rules whose last run failed, with the reason and the hint.subimageCreateCustomRulecreates a rule. The id has to be unique across built-in and custom rules. The call runs the draft the way the findings sync will, and refuses it when a query would fail. The error names the query, the failure, and how to fix it. Correct the body and call again.subimageUpdateCustomRulereplaces an existing definition. Read it first and send the whole rule back. The id cannot change, and an omitted optional field resets to its default. The same check runs, and the same refusal applies. Changing a query's identity, or the columns it returns while identity is left as every column, closes that query's findings and opens new ones.
The graph query on each fact is required by the tool. It is the detection query with its last RETURN widened so the matched nodes come back, which is what the graph view draws. The editor fills it in. It does not decide which rows become findings.