Documentation

Remediation, signing & reporting

Finding a problem is half the job. This page covers the other half: Rutken's end-to-end command-line workflow for fixing what the scanner finds, signing the rebuilt app with Rutken's own APK Signature Scheme v2 signer, verifying signatures for trust gating, and turning results into reports and CI/CD gates. Every mutating verb fails closed: it never leaves a partial or corrupt APK behind.

← Back to the main docs

Overview: the remediation loop

Rutken closes the loop from detection to a fixed, installable build entirely from the command line. A typical cycle looks like this:

# 1. Scan and see what's wrong
rutken app.apk scan

# 2. List the suggested fixes (read-only)
rutken app.apk fix

# 3. Apply the safe manifest fixes and re-sign in one shot
rutken app.apk fix --apply --out fixed.apk \
    --keystore release.p12 --keystore-pass-file ./keystore-pass.txt

# 4. Confirm the rebuilt app is validly signed
rutken fixed.apk verify-signature

# 5. Re-scan to confirm the findings are gone
rutken fixed.apk scan

The verbs split into two layers. The high-level ones (fix) map findings to fixes and rebuild for you; the low-level ones (patch, sign, edit-code) do exactly one surgical thing each. All of them share one safety contract:

  • Fail closed. No verb ever writes a partial or corrupt APK. If any step of a rebuild cannot complete, nothing is written.
  • Unsigned unless you ask. Omit --keystore and the output is left unsigned (useful for inspection); pass it and the output is signed with Rutken's in-house v2 signer.
  • No passwords on argv. There is deliberately no --keystore-password flag — passwords come from an environment variable or a file so they never show up in ps.

rutken fix — the auto-remediation loop

fix is the one-command remediation entry point. It runs the default rule set, maps each finding to zero or more concrete fixes, and — with --apply — rebuilds and re-signs the app.

Read-only by default

Run with no flags and fix only lists suggestions; it touches nothing on disk. This is the safe way to preview what an --apply run would do.

rutken app.apk fix
rutken app.apk fix --format json

Each finding is mapped to fixes by the remediation catalog (the binary remediation catalog), which currently covers seven mappable rule ids. Fixes come in two kinds, and the distinction matters for --apply:

  • Manifest-shaped fixes — e.g. disabling android:debuggable, allowBackup, or cleartext traffic. These can be applied automatically.
  • Advisory DEX code fixes — source/bytecode changes that need a human. These are always listed and never applied, even under --apply.

--apply: rebuild and re-sign

--apply --out <PATH> is opt-in. It translates the manifest subset of the selected fixes into a single merged patch plan, rebuilds the APK through the writer, and — if a keystore is given — re-signs it. The translation step lives in the CLI layer, not the engine: the CLI is what turns findings into the merged plan the patch subsystem then executes.

# Apply every safe fix in one merged patch, unsigned output
rutken app.apk fix --apply --out fixed.apk

# Apply and sign the result with the in-house v2 signer
rutken app.apk fix --apply --out fixed-signed.apk \
    --keystore release.p12 --keystore-pass-file ./keystore-pass.txt

Expected output (text): each applied fix is listed with the rule it addresses, the advisory DEX fixes are printed as still-manual, and on a signed run the signing scheme and the certificate's SHA-256 fingerprint are echoed.

All-or-nothing gating

--apply is strictly all-or-nothing. The run refuses — writing nothing — if any selected fix either:

  • fails to translate into a patch plan, or
  • is behaviour-changing and you did not pass --include-behaviour-changing.

Behaviour-changing fixes are the ones that can break a working app: disabling android:exported on a component or android:grantUriPermissions on a provider. To include them you must opt in explicitly, and Rutken prints the corresponding risk warning when it applies one:

rutken app.apk fix --apply --include-behaviour-changing \
    --out fixed-signed.apk \
    --keystore release.p12 --keystore-pass-file ./keystore-pass.txt

Flags

  • --format text|json — output shape (default text).
  • --apply — opt in to writing a rebuilt APK.
  • --out <PATH> — output path; required by and only meaningful with --apply.
  • --overwrite — allow --apply to overwrite an existing file at --out.
  • --include-behaviour-changing — also apply exported/grant-uri fixes (otherwise they trip the gate).
  • Signing flags (--keystore, --keystore-pass-env / --keystore-pass-file, --key-alias, --signing-scheme) — see Signing flags.

Gotcha: fix uses --out (no short form), whereas the lower-level patch/sign/edit-code verbs use -o / --output. Advisory DEX fixes never get applied here — if you need a bytecode change, use rutken edit-code (width-preserving Tier C editing).

rutken patch — the patch subsystem

patch is the surgical, one-fix-per-run remediation verb. Where fix --apply bundles the whole manifest subset, patch applies exactly one safe manifest remediation per invocation — there is no bulk "apply everything" mode here by design.

Under the hood it finds a finding matching the requested flag, builds the matching transform, and rebuilds the APK through the writer. The AXML and DEX edits it performs are width-preserving: structures are rewritten in place without disturbing surrounding offsets, so the rebuild is lossless apart from the intended change.

The five manifest transforms

Pick exactly one per run:

  • --disable-debuggable — clear android:debuggable.
  • --disable-allow-backup — clear android:allowBackup.
  • --disable-cleartext-traffic — clear android:usesCleartextTraffic. Only applies when the manifest sets it explicitly to true; the inferred-default (attribute absent) case has no attribute to patch.
  • --disable-exported <NAME> — clear android:exported on the named component. May break legitimate functionality — review first.
  • --disable-grant-uri <NAME> — clear android:grantUriPermissions on the named provider. May break legitimate functionality — review first.

Examples

# Unsigned output
rutken app.apk patch --disable-debuggable -o patched.apk

# Signed output (in-house v2)
rutken app.apk patch --disable-debuggable -o patched-signed.apk \
    --keystore release.p12 --keystore-pass-file ./keystore-pass.txt

rutken app.apk patch --disable-allow-backup -o patched.apk
rutken app.apk patch --disable-cleartext-traffic -o patched.apk
rutken app.apk patch --disable-exported com.example.app.SomeActivity -o patched.apk
rutken app.apk patch --disable-grant-uri com.example.app.SomeProvider -o patched.apk

Expected output: a one-line confirmation of the applied transform and the output path; on a signed run, the signing scheme and certificate fingerprint. The --disable-exported/--disable-grant-uri variants additionally print a risk warning.

Width-preserving edits, and native targets

For advanced bytecode surgery, rutken edit-code performs a width-preserving instruction patch ("Tier C"): the replacement must occupy the same number of 16-bit code units and reference only existing registers/ids, guaranteeing binary correctness (not semantic correctness). patch also has a native arm (Linux ELF today) for metadata transforms, which is consent-gated.

Gotcha: patch requires -o / --output (not --out). Exactly one disable-flag is honored per run; passing none that matches a finding is a no-op refusal, not a silent empty write.

rutken sign — Rutken's in-house v2 signer

sign (re)signs an APK with no other change. The signer is Rutken's own implementation of APK Signature Scheme v2 — it is not a wrapper around apksigner or the Android build tools. That matters in practice: Rutken resigns repacked, obfuscated, and split apps, and Android installs the result like any other correctly signed APK.

# Re-sign only (no patch), choosing the key by alias
rutken app.apk sign -o signed.apk \
    --keystore release.p12 --key-alias release

# Password from a file instead of the environment
rutken app.apk sign -o signed.apk \
    --keystore release.p12 --keystore-pass-file ./keystore-pass.txt

Expected output: on success, both sign and patch print the signing scheme used and the signing certificate's SHA-256 fingerprint — the same fingerprint you would later pass to verify-signature --expect-signer.

Signing flags (shared by patch, sign, fix --apply, embed-gadget, edit-code)

  • --keystore <path> — a PKCS#12 keystore (.p12/.pfx, or a .jks-named file that is actually PKCS#12 — format is sniffed by magic bytes, never by extension). Legacy JKS files are rejected with a keytool conversion hint. Required for sign; optional elsewhere (absent ⇒ unsigned output).
  • --keystore-pass-env <VAR> — name of the environment variable holding the password. Default RUTKEN_KEYSTORE_PASS.
  • --keystore-pass-file <path> — read the password from a file instead (trailing newline trimmed). Takes precedence over --keystore-pass-env.
  • --key-alias <alias> — which private-key entry to sign with. Defaults to the keystore's sole private-key entry; required (with available aliases listed in the error) when there is more than one. Also determines the META-INF/<NAME>.SF/.RSA entry names for v1 signing.
  • --signing-scheme auto|v1|v2|v1+v2 — which scheme(s) to emit. auto (default) mirrors apksigner's own default: v1 (JAR) + v2 when the target's minSdkVersion is absent or below 24, v2-only otherwise. v1/v2 force one scheme (refusing if the target can't install with it); v1+v2 forces both.

Why no --keystore-password flag? Passwords on argv are visible to any other process via ps, so they're deliberately read from an env var or a file. PKCS#12 uses one password for both the store and every key in it, so there is one password flag pair — not separate store/key passwords. Set it up once in CI:

export RUTKEN_KEYSTORE_PASS='…'          # default env var name
rutken app.apk sign -o signed.apk --keystore release.p12

rutken verify-signature — our verifier

verify-signature cryptographically verifies an APK's own signature and, optionally, checks the signer against fingerprints you trust. It reports two independent claims:

  • Integrity (always checked) — recomputes the content digest for the highest present scheme (v3 > v2 > v1) and verifies the signer's signature. Proves the APK is byte-for-byte unmodified since signing and that the signer held the private key. Says nothing about who the signer is.
  • Trust (only with --expect-signer) — compares each verified signer's leaf-certificate SHA-256 against the supplied set. Without the flag, trust is reported as not checked; Rutken never invents an expected signer.
  • No X.509 chain validation (never, by design) — APK signing certs are self-signed by convention, which is the normal, correct state and is never flagged.

Trust gating with --expect-signer

--expect-signer <SHA256> is repeatable and accepts the common apksigner/keytool presentation forms (upper or lower case, :- or space-separated groups). Every signer's leaf cert must match one of the supplied fingerprints for a MATCHED verdict; any single non-match is a MISMATCH (no partial credit for multi-signer APKs).

# Integrity only
rutken app.apk verify-signature

# Trust gating against a known-good release cert
rutken app.apk verify-signature \
    --expect-signer AA:BB:CC:DD:EE:FF:00:11:22:33:44:55:66:77:88:99:…

# Machine-readable
rutken app.apk verify-signature --format json

Expected text output:

Signature: v2
Integrity: VERIFIED
Trust:     MATCHED a1b2c3...
Signers:
  1. sha256=a1b2c3...  self-signed=yes
     subject: CN=...
     issuer:  CN=...
     valid:   1690000000 .. 2020000000
Warnings:
  - weak digest algorithm used: SHA-1

--format json emits the full SigningInfo (including verification) alongside the APK's sha256 and package.

Exit codes

This verb has its own exit convention, independent of rules --fail-on:

  • 0 — every state except a tripped signature problem. This includes an UNVERIFIABLE integrity result and a NOT CHECKED / unsigned trust result: "could not check" is never treated as a policy-gate failure.
  • 2 — Integrity is FAILED, or Trust is MISMATCH.

The same verification powers rutken app.apk info's appended Integrity: line (always on, empty anchor set) and the opt-in rules --verify-signature / --expect-signer rules (apk-signature-invalid / apk-signature-untrusted).

Report formats

The analysis verbs (rules/scan, compliance, sca, correlate, batch, image, cicd) share one --format switch:

--format text | json | sarif | markdown | html | pdf
       | gitlab-codequality | junit | ndjson
  • text — human output, one line per finding; the default.
  • json — a {schema_version, tool, findings, coverage} envelope.
  • sarif — SARIF 2.1.0 for GitHub Code Scanning. Pair with --artifact-uri to add a physicalLocation (Code Scanning silently drops results that carry only logicalLocations). Coverage lands under runs[0].properties.analysisCoverage.
  • markdown — a rendered Markdown report (see below).
  • html / pdf — rendered documents; PDF is binary, so send it to --out or redirect.
  • gitlab-codequality — GitLab Code Quality JSON (requires --artifact-uri).
  • junit — JUnit XML, one testcase per finding.
  • ndjson — line-delimited SIEM events (batch --format ndjson is the fuller-context path).

The Markdown report

--format markdown renders a self-contained Markdown document — the format of choice for a PR description, a wiki page, or a $GITHUB_STEP_SUMMARY. HTML and PDF render the same content as shareable documents.

rutken app.apk rules --format markdown
rutken app.apk rules --format html > report.html
rutken app.apk rules --format pdf > report.pdf

HTML and PDF are deliberately open. They are rendered documents on purpose, not locked-down exports — you are expected to post-process, re-style, or embed them in your own reporting pipeline.

CI gating with --fail-on

--fail-on <critical|high|medium|low|info> turns a scan into a build gate. If any finding on the unfiltered set is at or above the given level, the command exits 2. The gate verdict is written to stderr only; stdout (your report) is unchanged, so you still get the full artifact on a tripped gate.

# Exit 2 (non-zero) if any High or Critical finding exists
rutken app.apk rules --fail-on high || exit 1

# Gate while still producing the SARIF artifact
rutken app.apk rules --fail-on high --format sarif --out rutken.sarif

Related gating flags:

  • --baseline <path> / --baseline-out <path> / --baseline-delta-out <path> (rules, APK only) — gate only on NEW ∪ severity-escalated findings versus a committed baseline; --baseline-out writes a fresh one.
  • --min-severity <level> — floors the text view only. Machine formats and --fail-on always see the full set.
  • On correlate, --fail-on is AND-ed with --fail-on-status any|corroborated.

Exit-code map: 0 = clean (or gate not tripped), 1 = tool/usage error, 2 = policy gate tripped. Keep these distinct in CI: a 1 is a broken run, a 2 is a real finding.

Accepting known findings — baselines

# 1. Establish a baseline once, from a known-good scan
rutken app.apk rules --baseline-out rutken-baseline.json

# 2. In CI from then on: gate only on NEW or escalated findings
rutken app.apk rules --baseline rutken-baseline.json --fail-on high

CI/CD platform actions

Rutken ships first-party integrations for the major CI systems. Each wraps a rutken rules … (or rutken batch --apk-dir …) invocation, surfaces --fail-on's exit code as the step's own outcome, and writes the report artifact before exiting so a tripped gate never costs you the artifact.

  • GitHub Actions — a composite action, .github/actions/rutken-scan, wrapping rutken rules --format sarif --out <file> plus a Markdown $GITHUB_STEP_SUMMARY. Pairs with github/codeql-action/upload-sarif@v3; this is what makes --artifact-uri worth setting.
  • GitLab CI — a CI/CD Component, templates/rutken-scan.yml (GitLab 16.6+), emitting Code Quality JSON (works on every tier, including Free). gate-allow-failure: true makes a tripped gate (exit 2) non-blocking via native allow_failure: exit_codes: [2], while a tool/usage error (exit 1) still fails the pipeline.
  • Azure Pipelines — a steps template, templates/azure-pipelines/rutken-scan.yml, emitting SARIF (consumed via the "SARIF SAST Scans Tab" extension) with an optional JUnit fallback. gateAllowFailure: true maps a tripped gate to SucceededWithIssues.
  • Jenkins — a shared-library global step, templates/jenkins/vars/rutkenScan.groovy, with SARIF consumed by the Warnings Next Generation plugin. gateAllowFailure: true marks the build UNSTABLE rather than FAILURE.

Each ships its own documentation alongside it with the full input/output reference and both consumption modes.

Putting the reason in front of a reviewer

The platform actions surface the gate as an exit code and a report artifact, but not the reason inline on a PR. rutken pr comment closes that last mile for GitHub: it reads an already-computed baseline delta and upserts a single marked comment on the pull request. It is fail-soft — a comment failure never changes the gate verdict (warns, exits 0) unless you pass --require-comment.

# In CI, after the gated scan step:
rutken app.apk rules --baseline base.json --baseline-delta-out delta.json \
    --format sarif --out rutken.sarif --fail-on high || GATE=$?
rutken pr comment --delta delta.json --repo me/app --pr 42
exit ${GATE:-0}

Gotchas & FAQ

Why wasn't my fix applied?
Three common reasons. (1) It's an advisory DEX code fix — those are always listed, never auto-applied; use edit-code. (2) It's behaviour-changing (exported/grant-uri) and you didn't pass --include-behaviour-changing, which trips the all-or-nothing gate and refuses the entire run. (3) No finding matched the requested transform.
Is Rutken's signer just apksigner under the hood?
No. It is Rutken's own implementation of APK Signature Scheme v2. It resigns repacked, obfuscated, and split apps, and Android installs the result normally. Signing is RSA / RSASSA-PKCS1-v1_5 with SHA2-256.
How do I pass my keystore password?
Via --keystore-pass-env (default env var RUTKEN_KEYSTORE_PASS) or --keystore-pass-file. There is deliberately no --keystore-password argv flag — passwords on argv leak via ps. A PKCS#12 keystore uses one password for both the store and its keys.
I get INSTALL_FAILED_VERIFICATION_FAILURE on a signed APK.
A validly signed but modified app can still be rejected by Play Protect on a stock device (apksigner verify passes, but the on-device verifier blocks it). Turn the adb-install verifier off to sideload: adb shell settings put global verifier_verify_adb_installs 0, install, then restore it. That is the verifier, not a signing bug.
Why did verify-signature exit 0 when it couldn't verify?
By design: an UNVERIFIABLE integrity result and a NOT CHECKED/unsigned trust result both exit 0 — "could not check" is never a policy-gate failure. Only a FAILED integrity or a MISMATCH trust exits 2.
What's the difference between fix --apply and patch?
fix --apply bundles the whole manifest subset of a finding set into one merged plan (all-or-nothing, uses --out). patch applies exactly one named manifest transform per run (uses -o). Both rebuild through the same width-preserving writer and sign with the same flags.

Only remediate and resign apps you own or are explicitly authorized to modify. Mutating verbs fail closed and leave the original untouched, but the output is your responsibility.