Writing a Rutken plugin
A Rutken plugin is a detection rule pack: a single JSON file that declares one or more rules, each with a matcher that the engine evaluates against an app and, on a match, emits a finding. Packs are pure data — there is no code to run, no regex engine, and every matcher is bounded, so a pack can be distributed and evaluated safely. This guide documents the exact manifest schema and matcher grammar from the engine source, with worked examples you can copy.
What a plugin is
A plugin is a rule pack — a JSON document that Rutken loads alongside its built-in rules. Each rule names a matcher expression; when the matcher evaluates to true against the app under analysis, the rule emits a single whole-app finding (it is not tied to one line of code). Packs are deliberately constrained so that evaluating an untrusted pack can never hang, recurse without bound, or execute arbitrary logic:
- Data, not code. A pack is JSON. There is no scripting and no embedded binary.
- No regular expressions. String matching is limited to literal / prefix / suffix / contains — a deliberate rejection of a backtracking regex engine.
- Bounded. Matcher depth, node count, fan-out, and evaluation steps are all capped (see Bounds & limits).
- Closed vocabulary. The predicate set is a closed enum; an unknown predicate is a parse error, not a silent no-op.
The matcher questions a pack can ask fall into two families:
manifest facts (permissions, components, SDK
levels, flags from AndroidManifest.xml) and
analysis facts (method calls, string constants,
taint flows, and class-hierarchy relationships derived by the
engine). Both are covered in the
predicate reference.
The pack manifest
A pack is a JSON object. The top-level fields are below; field names are the exact names the engine deserializes (snake_case, no renaming).
| Field | Type | Required | Notes |
|---|---|---|---|
schema_version | integer | required | Must be 1. Any other value is rejected as an unsupported schema version. |
publisher | string | required | Namespace segment. Charset [a-z0-9-], first/last char alphanumeric, non-empty, ≤ 64 bytes. |
id | string | required | Pack id, same charset rules as publisher. |
version | string | required | Opaque string (never parsed as semver). Non-empty, ≤ 32 bytes. |
min_rutken_version | string | required | Minimum engine version. Opaque, non-empty, ≤ 32 bytes. |
rules | array | required | 1–512 rule declarations. Duplicate local rule ids are rejected. |
There is no top-level name, author,
category, or description on the manifest
itself. Those human-facing labels live in the
registry index entry, not in the pack file.
Severity, confidence, and description are set per rule.
Rule declarations
Each entry in rules is an object:
| Field | Type | Required | Notes |
|---|---|---|---|
id | string | required | Local, un-namespaced. Charset [a-z0-9-], alphanumeric edges, ≤ 64 bytes. The fully-qualified id becomes <publisher>/<pack-id>/<rule-id>. |
title | string | required | Short human title. Non-empty, ≤ 200 bytes. |
severity | enum | required | One of Info, Low, Medium, High, Critical (PascalCase). |
confidence | enum | required | One of Low, Medium, High (PascalCase). |
matcher | object | required | The matcher expression tree (see below). |
description | string | optional | Longer explanation, ≤ 4096 bytes. |
taxonomy | object | optional | Standards mapping — see below. |
Taxonomy
The optional taxonomy object maps a rule to security
standards. Every field is an array (default empty), each limited
to 32 entries of ≤ 64 bytes:
cwe— array of bare numbers, e.g.[798](not"CWE-798"); a0is rejected.masvs— array of strings, each of which must start withMASVS-, e.g."MASVS-CRYPTO-1".cis,ssdf,slsa— arrays of open ASCII tokens (no whitespace or control characters).
A complete, valid pack
This pack parses, validates, and builds a rule. It fires when the
AWS access-key-id prefix AKIA appears in any app
string constant:
{
"schema_version": 1,
"publisher": "acme",
"id": "mobile-baseline",
"version": "1.2.0",
"min_rutken_version": "0.2.3",
"rules": [
{
"id": "hardcoded-aws-key",
"title": "Hardcoded AWS access key id (AKIA...)",
"severity": "High",
"confidence": "High",
"description": "An AWS access-key-id prefix AKIA is embedded in the app.",
"taxonomy": {
"cwe": [798],
"masvs": ["MASVS-STORAGE-1"]
},
"matcher": {
"op": "predicate",
"predicate": "constant_matches",
"pattern": { "kind": "prefix", "value": "AKIA" }
}
}
]
}
The matcher grammar
A matcher is an expression tree. Every node is a JSON
object with an "op" key. There are four node types.
op | Shape | Meaning |
|---|---|---|
"predicate" | a leaf; the predicate's own fields sit alongside op | A single fact test (see predicate reference). |
"and" | {"op":"and","children":[ ... ]} | True iff every child is true. An empty list is vacuously true. |
"or" | {"op":"or","children":[ ... ]} | True iff at least one child is true. An empty list is vacuously false. |
"not" | {"op":"not","expr":{ ... }} | Negates its single child. |
Note the field names: and/or take a named
children array (not a bare array), and not
takes a named expr. Here is a rule that fires when an
app requests READ_SMS but does not target
Android 11 or later:
{
"op": "and",
"children": [
{
"op": "predicate",
"predicate": "has_permission",
"name": { "kind": "literal", "value": "android.permission.READ_SMS" }
},
{
"op": "not",
"expr": {
"op": "predicate",
"predicate": "target_sdk_at_least",
"value": 30
}
}
]
}
String matching
Wherever a predicate matches text, it takes a string match
object with a "kind" and a "value". There
are four kinds and no others — there is no regex, alternation, or
backreference. The value must be non-empty and
≤ 256 bytes.
kind | Matches when |
|---|---|
"literal" | the haystack equals value exactly |
"prefix" | the haystack starts with value |
"suffix" | the haystack ends with value |
"contains" | the haystack contains value |
Predicate reference
A predicate leaf is {"op":"predicate","predicate":"<name>", ...}
where the remaining fields depend on the predicate. There are
thirteen predicates, split into manifest-fact predicates and
analysis-fact predicates. An unrecognised predicate name is a
parse error.
Manifest-fact predicates
Evaluated against the parsed AndroidManifest.xml.
has_permission — field name (string match)
Matches a declared <uses-permission>. Pass the full permission name.
{ "op": "predicate", "predicate": "has_permission",
"name": { "kind": "literal", "value": "android.permission.REQUEST_INSTALL_PACKAGES" } }
declares_permission — field name (string match)
Matches a custom <permission> declaration.
{ "op": "predicate", "predicate": "declares_permission",
"name": { "kind": "prefix", "value": "com.acme." } }
application_debuggable — field value (boolean)
Matches android:debuggable equal to value.
{ "op": "predicate", "predicate": "application_debuggable", "value": true }
allow_backup — field value (boolean)
Matches android:allowBackup. Only matches when the attribute is explicitly present (an absent attribute never matches true or false).
{ "op": "predicate", "predicate": "allow_backup", "value": true }
uses_cleartext_traffic — field value (boolean)
Matches android:usesCleartextTraffic; same explicit-only semantics as allow_backup.
{ "op": "predicate", "predicate": "uses_cleartext_traffic", "value": true }
has_network_security_config — field value (boolean)
Whether android:networkSecurityConfig is present.
{ "op": "predicate", "predicate": "has_network_security_config", "value": false }
min_sdk_at_least / target_sdk_at_least — field value (integer)
True when minSdk (respectively targetSdk) is ≥ value. An unknown SDK level never matches.
{ "op": "predicate", "predicate": "min_sdk_at_least", "value": 23 }
component_matches — field filter (object)
True if at least one declared component matches the filter. The
filter object fields are kind (optional;
one of Activity, ActivityAlias,
Service, Receiver, Provider),
name (optional string match), and
exported_only (optional boolean, default
false — when true, only components with
android:exported="true" match).
{ "op": "predicate", "predicate": "component_matches",
"filter": { "kind": "Receiver", "exported_only": true } }
Analysis-fact predicates
These require the engine's live analysis (call graph, string
pool, taint, hierarchy). Class names use the bare JNI internal
form (java/lang/Runtime), not the DEX
L…; descriptor.
calls_method — fields class and method (both string matches)
True when some invoke target's class and method both match. Method names are bare (exec, start, <init>).
{ "op": "predicate", "predicate": "calls_method",
"class": { "kind": "literal", "value": "java/lang/Runtime" },
"method": { "kind": "literal", "value": "exec" } }
constant_matches — field pattern (string match)
Matches any app string constant (the DEX string pool plus resource strings).
{ "op": "predicate", "predicate": "constant_matches",
"pattern": { "kind": "contains", "value": "PRIVATE KEY-----" } }
taint_reaches — fields source and sink (plain strings)
True when tainted data from a source category reaches a
sink. Unlike other predicates these are plain strings, not string
matches. The recognised source categories are exactly
intent-extra, ipc-input, and
user-input; any other source never matches. The
sink is matched against the flow's sink class/method.
Each string is ≤ 128 bytes.
{ "op": "predicate", "predicate": "taint_reaches",
"source": "user-input", "sink": "android/content/Intent" }
implements_transitively — fields class and interface (both string matches)
True when class transitively implements or extends interface.
{ "op": "predicate", "predicate": "implements_transitively",
"class": { "kind": "prefix", "value": "com/acme/" },
"interface": { "kind": "literal", "value": "javax/net/ssl/X509TrustManager" } }
Casing is the most common mistake.
op, predicate, and string-match
kind values are snake_case
("and", "has_permission",
"literal"). But severity,
confidence, and the component kind are
PascalCase ("High",
"Service"). Mixing them up is the single most
error-prone part of hand-writing a pack.
Taint source/sink catalog
The taint_reaches predicate is backed by the engine's
taint analysis, which tracks values from a fixed set of
sources to a fixed set of sinks.
A pack can only reference what the engine actually models — both
catalogs are closed. When a pack's taint_reaches
runs, the engine solves taint over the sink catalog below and then
keeps only the flows whose sink string you gave
matches: the string is compared for exact equality against the
matched flow's sink class (e.g. Landroid/webkit/WebView;)
or its sink method (e.g. loadUrl). Naming a
sink that isn't in the catalog never matches.
Sources (the source field)
The source is one of three category keywords, each
mapping to a group of modelled source APIs:
source | Modelled source APIs |
|---|---|
intent-extra | Intent.getStringExtra, getIntExtra, getBooleanExtra, getData, getExtras, getParcelableExtra; Bundle.getString, getInt. |
ipc-input | Parcel.readString, readInt, readBundle. |
user-input | EditText.getText, TextView.getText (the device user's own typed input). |
Sinks (the sink field)
The sink catalog is a closed list the engine's own
taint-reaches-sink rule uses. Match against a sink's
method name (bare, e.g. loadUrl) or its class
descriptor (e.g. Landroid/net/URL;):
| Family | Class · methods |
|---|---|
| Logging | Landroid/util/Log; · v, d, i, w, e, wtf |
| File | Ljava/io/File; · <init> |
| WebView | Landroid/webkit/WebView; · loadData, loadDataWithBaseURL, loadUrl |
| JDK network | Ljava/net/URL; · <init>; Ljava/net/URLConnection; and Ljava/net/HttpURLConnection; · setRequestProperty, addRequestProperty; Ljava/net/Socket; · <init> |
| OkHttp 3 | Lokhttp3/Request$Builder; · url; Lokhttp3/RequestBody; · create |
| Other HTTP stacks | Legacy OkHttp, Volley, Cronet, and Apache HttpClient send-paths are also modelled in the same network-send family. |
A worked rule — untrusted IPC input reaching a WebView load:
{
"op": "predicate",
"predicate": "taint_reaches",
"source": "ipc-input",
"sink": "loadUrl"
}
Two honest limitations. Pack taint runs the
default intraprocedural path only — the opt-in
interprocedural summaries are not threaded into pack taint
facts, so a flow that only appears across method boundaries may
be missed (an under-approximation, never a false positive). And
a user-input flow into an ordinary sink is often
normal behaviour, not a weakness — weigh it by whether the sink
makes the device's own user a plausible attacker.
How to find what to match
A calls_method or constant_matches rule
is only as good as the class, method, or string you put in it. The
fastest way to get those exactly right is to point Rutken at a
representative APK and read what it actually contains, then write
the rule against what you see. The loop is: inspect, write,
confirm.
1. Inspect the app
These read-only target-form verbs surface the invoked APIs and constants (see the CLI reference):
# Find where a class or API name appears
rutken app.apk search "Runtime"
# Disassemble matching methods and read the invoke targets directly
rutken app.apk disasm "onCreate"
# Navigate by security concept: the catalog, then where each lands here
rutken app.apk concepts --list
rutken app.apk concepts
rutken app.apk concepts --callers # also show what calls into a concept
# The reachable call graph from a method
rutken app.apk callgraph "Lcom/example/App;->onCreate"
# Hunt for a constant you want a constant_matches rule for
rutken app.apk strings --grep AKIA
The disassembly and search output name call targets in DEX
descriptor form, e.g.
Ljava/lang/Runtime;->exec. To put that in a
calls_method rule, use the bare JNI internal form:
drop the leading L and trailing ; from
the class (java/lang/Runtime) and keep the method
name bare (exec). The adapter strips the descriptor
for you, so a literal match is written as the bare
form.
2. Write the rule
Turn what you read into a matcher. Say disasm showed
a call to Ljava/lang/Runtime;->exec:
{
"op": "predicate",
"predicate": "calls_method",
"class": { "kind": "literal", "value": "java/lang/Runtime" },
"method": { "kind": "literal", "value": "exec" }
}
…or, for a string you found with strings --grep,
a constant_matches rule (see the AWS-key example in
the pack manifest).
3. Confirm it fires
Drop the rule into a pack and run it against the same APK. If the finding appears, your matcher is pointing at the right thing:
rutken app.apk rules --pack mine.json
Add --no-installed-packs to see only your pack's
findings while you iterate, and --rule <id> to
focus on a single rule.
Bounds & limits
Every pack is validated against hard limits before it is ever evaluated. These are the enforced values:
| Limit | Value |
|---|---|
| Manifest file size | 256 KiB (checked before parsing) |
| Rules per pack | 512 |
| Matcher nesting depth | 32 |
| Matcher total nodes | 4096 |
Direct children of one and/or | 64 |
| String-match needle length | 1–256 bytes |
taint_reaches source/sink length | 128 bytes |
| Evaluation step budget | 4096 (one step per node visited) |
| Publisher / id / rule-id length | 64 bytes |
| version / min_rutken_version length | 32 bytes |
| Title length | 200 bytes |
| Description length | 4096 bytes |
| Taxonomy entries per field / string length | 32 entries / 64 bytes |
The node cap (4096) is guaranteed to be ≤ the step budget, so any
pack that validates is always evaluable within budget;
and/or short-circuit, so skipped children
cost nothing. Validation runs in two stages: a structural pass
(size and DoS bounds) and a semantic pass (per-matcher depth/node
checks and taxonomy shape). Both fail closed.
Validate & run locally
While authoring, validate the pack and run it against a real APK without any signing or registry round-trip.
Validate
rutken packs validate ./acme-mobile-baseline.json
This parses the manifest, runs the full semantic validation (fail-closed), and builds the rules. If it reports clean, the pack is well-formed and every matcher is within bounds.
Run it against an app
Load the pack directly into a scan with --pack
(repeatable). A pack loaded this way is validated but
unsigned — it shows as local-dev and is never
treated as trusted:
rutken app.apk rules --pack ./acme-mobile-baseline.json
To scan with only your pack and skip the installed
registry packs, add --no-installed-packs:
rutken app.apk rules --pack ./acme-mobile-baseline.json --no-installed-packs
Pack findings appear in the same output as built-in findings and
honour the same --format,
--min-severity, and (on CI-gate builds)
--fail-on flags — see the
CLI reference.
Installing signed packs
Published packs are distributed through a signed registry.
Installing one verifies an Ed25519 signature against a key pinned
into the engine, so a tampered pack or index is rejected. The
--url flag below requires a registry-enabled build.
# Refresh the signed index from the registry container root
rutken packs update --url https://www.rutken.com/registry
# See what is available
rutken packs list
# Install a pack by its fully-qualified id (publisher/id)
rutken packs install rutken/high-risk-permissions
Installed packs load automatically on your next scan. The
--url value is the registry container root,
not a path to an index file: the client fetches the index from
there, verifies its signature, then re-verifies each pack's hash
and signature on install before placing it in the local store.
Submit your plugin
Share a pack by opening a pull request against the public plugins repository: github.com/Rutken-Company/rutken-plugins. You never hold a signing key — signing happens only in the repository's CI after a maintainer approves your PR.
Steps
- Fork Rutken-Company/rutken-plugins.
- Add your pack as a JSON file under
packs/. - Validate it locally — the PR will be checked
the same way:
rutken packs validate packs/your-pack.json - Open a PR. CI re-runs validation on your pack automatically.
- A maintainer reviews and approves it, crediting you as the author.
- The signed pipeline publishes it: CI signs each approved pack and the registry index with the Ed25519 key (held in a cloud key vault, never on a contributor machine) and uploads the signed result.
Once published, anyone can install your pack with
rutken packs install <publisher>/<id> and
the engine verifies its signature before trusting it. Author
credit, the human-facing name, category, and description travel
with the published entry.
Enum reference
Quick reference for the closed vocabularies used above.
- Severity (PascalCase):
Info,Low,Medium,High,Critical. - Confidence (PascalCase):
Low,Medium,High. - Component kind (PascalCase, in
component_matches):Activity,ActivityAlias,Service,Receiver,Provider. - Matcher op (snake_case):
predicate,and,or,not. - String-match kind (snake_case):
literal,prefix,suffix,contains. - Predicate (snake_case):
has_permission,declares_permission,application_debuggable,allow_backup,uses_cleartext_traffic,has_network_security_config,min_sdk_at_least,target_sdk_at_least,component_matches,calls_method,constant_matches,taint_reaches,implements_transitively. - Taint source categories:
intent-extra,ipc-input,user-input.
For the built-in rules that ship with the engine, see the Detection rules catalog.