Plugins

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).

FieldTypeRequiredNotes
schema_versionintegerrequiredMust be 1. Any other value is rejected as an unsupported schema version.
publisherstringrequiredNamespace segment. Charset [a-z0-9-], first/last char alphanumeric, non-empty, ≤ 64 bytes.
idstringrequiredPack id, same charset rules as publisher.
versionstringrequiredOpaque string (never parsed as semver). Non-empty, ≤ 32 bytes.
min_rutken_versionstringrequiredMinimum engine version. Opaque, non-empty, ≤ 32 bytes.
rulesarrayrequired1–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:

FieldTypeRequiredNotes
idstringrequiredLocal, un-namespaced. Charset [a-z0-9-], alphanumeric edges, ≤ 64 bytes. The fully-qualified id becomes <publisher>/<pack-id>/<rule-id>.
titlestringrequiredShort human title. Non-empty, ≤ 200 bytes.
severityenumrequiredOne of Info, Low, Medium, High, Critical (PascalCase).
confidenceenumrequiredOne of Low, Medium, High (PascalCase).
matcherobjectrequiredThe matcher expression tree (see below).
descriptionstringoptionalLonger explanation, ≤ 4096 bytes.
taxonomyobjectoptionalStandards 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"); a 0 is rejected.
  • masvs — array of strings, each of which must start with MASVS-, 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.

opShapeMeaning
"predicate"a leaf; the predicate's own fields sit alongside opA 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.

kindMatches 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:

sourceModelled source APIs
intent-extraIntent.getStringExtra, getIntExtra, getBooleanExtra, getData, getExtras, getParcelableExtra; Bundle.getString, getInt.
ipc-inputParcel.readString, readInt, readBundle.
user-inputEditText.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;):

FamilyClass · methods
LoggingLandroid/util/Log; · v, d, i, w, e, wtf
FileLjava/io/File; · <init>
WebViewLandroid/webkit/WebView; · loadData, loadDataWithBaseURL, loadUrl
JDK networkLjava/net/URL; · <init>; Ljava/net/URLConnection; and Ljava/net/HttpURLConnection; · setRequestProperty, addRequestProperty; Ljava/net/Socket; · <init>
OkHttp 3Lokhttp3/Request$Builder; · url; Lokhttp3/RequestBody; · create
Other HTTP stacksLegacy 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:

LimitValue
Manifest file size256 KiB (checked before parsing)
Rules per pack512
Matcher nesting depth32
Matcher total nodes4096
Direct children of one and/or64
String-match needle length1–256 bytes
taint_reaches source/sink length128 bytes
Evaluation step budget4096 (one step per node visited)
Publisher / id / rule-id length64 bytes
version / min_rutken_version length32 bytes
Title length200 bytes
Description length4096 bytes
Taxonomy entries per field / string length32 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

  1. Fork Rutken-Company/rutken-plugins.
  2. Add your pack as a JSON file under packs/.
  3. Validate it locally — the PR will be checked the same way:
    rutken packs validate packs/your-pack.json
  4. Open a PR. CI re-runs validation on your pack automatically.
  5. A maintainer reviews and approves it, crediting you as the author.
  6. 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.