agesignals
v0.3.4
Published
Does your Android build handle the new app-store age signals? Static scanner for Texas SB 2420, Brazil ECA Digital and related laws. Runs locally; your build never leaves your machine.
Maintainers
Readme
agesignals
A two-tier scanner that tells an Android developer whether their app handles the new app-store age signals — and, if not, exactly what is wrong. In force today in Brazil and Texas; more jurisdictions follow.
It is the free wedge for a paid remediation service, not a product in itself.
node cli.js app-release.apk # scan a build (any size)
node server.js # http://localhost:3000
node test/run-tests.js # 184 assertions, no install step
node test/check-live.js # RUN THIS FIRST — verifies parsing against 13 real store listingsNo binary is ever uploaded
The shallow tier is a web page. The deep scan is a CLI that runs on the developer's own machine. There is no upload endpoint, and that is a product decision rather than a technical limitation.
Asking a company to send its unreleased binary to a third-party service triggers legal and security review at any organisation large enough to be worth selling to — NDA terms, source-protection policy, sometimes export control. That blocker is completely independent of file size: a 40 MB banking app hits the same review as a 2 GB game, so no upload cap addresses it. The population who can freely upload is the population with no review process, which is also the population least likely to pay. Meanwhile "runs locally, nothing leaves your network" clears the same review in minutes.
Deleting the endpoint also deleted the only code here that could exhaust memory
or corrupt a binary in transit: the multipart parser, the memory-budgeted
admission control, the 512 MB cap, the 413 path, and the 2 GiB Buffer ceiling
as a concept. /api/deep now answers 410 with a pointer to the CLI.
agesignals app-release.apk
agesignals app-release.apk --mapping build/outputs/mapping/release/mapping.txt --pdf report.pdf
npx agesignals@latest app-release.apk --fail-on high --quiet # in CI, always currentExit codes are a CI contract and are asserted in the tests: 0 nothing at or
above --fail-on (default critical), 1 findings at or above it, 2
could not scan. So a pipeline breaks the build the day someone removes the
age-signal call. --json - writes the report to stdout and suppresses all
other output, so it stays parseable.
check-live.js is the verification that could not be done in the build sandbox
(outbound requests to play.google.com were blocked there). It hits real listings
and prints, field by field, what came back. Run it on a machine with normal
internet before trusting the shallow tier.
Node 18+. Zero dependencies — no npm install, no lockfile, no supply chain.
Everything (ZIP reading, binary-XML parsing, DEX scanning, PDF writing) is
implemented against Node built-ins so it can be dropped onto any host or
serverless runtime. The one caveat is throttling: both limiters hold state in
process memory and are only meaningful on a long-lived single instance. See
The outbound gate below.
The two tiers, and why they are split this way
Shallow — paste a store URL. Reads only what the stores already publish on a public listing. Never downloads an app binary. This is a deliberate constraint, not a limitation of effort: mass-retrieving APKs to scan apps that never asked you to would breach Play's terms, doesn't work on iOS at all, and would leave you holding thousands of companies' weaknesses that they never handed over. The shallow tier is shareable and legally clean.
Deep — the developer runs agesignals against their own build. Consent is
implicit because they run it themselves, and the binary never moves. See No
binary is ever uploaded above for why this is not a web upload.
What it can and cannot prove
This is the load-bearing design rule and it is enforced in the report wording.
Reliable (absence), with one stated condition. "The Play Age Signals library is not in this build" comes from a DEX string search. Same for "no Play Integrity." These are the findings that sell.
The condition is name obfuscation — which is not the same thing as
minification, and the distinction matters in client-facing text. R8 shrinks,
optimises and renames independently, and large apps routinely shrink while
keeping names so their crash reports stay readable. Only renaming affects what
this scanner can prove: a class renamed to A reads exactly like one that was
never there. Calling such a build "minified" in a report is checkably false to
the developer reading it, so the report never says it.
In practice the condition is mild, though not for the reason once written here.
This file used to say that Play Age Signals ships consumer keep rules which
preserve its entry points. It does not: age-signals 0.0.4 contains no
proguard.txt at all, verified against the artifact Google publishes on
2026-08-05.
What actually holds is narrower and sufficient. R8 renames method names far
more readily than class names, and this scanner matches on class and package
names. Measured on a real shipping build that carries the library: every class
name survived intact — AgeSignalsManager, AgeSignalsResult — while every
accessor on them had been renamed to a single letter. Detection reads the names
that survive.
A library bundled by a build that renames aggressively could still be present
and go unreported, which is what mapping.txt is for.
measureObfuscation in lib/apk.js infers this from class-name shape, and the
report states which case it is rather than claiming a flat "definitive
negative" either way. Do not restore that phrasing — it is the sentence a
client's counsel would quote back. A test asserts the word "minif…" never
appears in a report at all.
Both branches are verified against real builds:
| build | ratio | called | reality |
|---|---|---|---|
| 301 MB, R8-renamed | 0.58 | obfuscated | one of two Facebook markers renamed away; entry-point marker survived and matched |
| 19 MB, shrunk but named | 0.04 | not obfuscated | 9,749 readable com/hulu/… classes; libraries intact too |
The threshold is 0.2 rather than something higher because the count is diluted by references to platform and library classes, which are never renamed.
The tool knows how old its own advice is
lib/signatures.js carries the exact call that disables profiling for each SDK
— AdSettings.setMixedAudience(true) and so on. Vendors rename these between
major versions; eleven entries are already flagged verify: true for that
reason.
The danger is not the data ageing. It is ageing silently. npm pins by default, so someone who installs today can still be running this build in a year, getting an exact method name printed into a compliance deliverable and believing it because the tool stated it plainly.
So SIGNATURES_VERIFIED in lib/signatures.js records when the calls were last
checked against vendor docs, and confidence degrades on a clock:
| age of the data | behaviour |
|---|---|
| under 90 days | normal — calls stated plainly |
| 90–180 days | an info finding saying the data is old; calls still stated plainly |
| over 180 days | a medium finding, and every SDK is treated as verify: true regardless of its own flag |
The calls are never withheld, only marked — a test asserts that. This reuses the
existing per-SDK verify mechanism rather than inventing a second one, so the
CLI, the report and the PDF already render it. An SDK's own verifyNote is
never overwritten by the generic one.
An unparseable date is treated as maximally stale. Failing open there would defeat the purpose.
Bump SIGNATURES_VERIFIED when you re-check the vendor docs — not when the
scanner changes. It is a claim about the data. Publish a new version when it
moves, and old installs start warning on their own.
mapping.txt — optional, and the scan never depends on it
Pass --mapping and every SDK marker is searched under its original name
and under the renamed form the mapping gives it.
The design rule is that it must never be a gate. No mapping, a mapping for the wrong build, or a text file that is not a mapping at all — the scan runs and produces findings in every case. Only the wording of absence findings changes:
| supplied | absence claim | |---|---| | mapping | "searched under the renamed form as well as the original. Absent under both. This negative is definitive." | | none, names obfuscated | "…this negative is strong — but supply your mapping.txt to make it definitive." | | none, names intact | "class names are intact, so this negative is reliable." |
A file that parses to zero classes is rejected with a warning and the scan continues without it — silently accepting it would hand the report a definitive claim it has not earned.
Verified against the real 301 MB obfuscated build: with a mapping renaming
com.appsflyer.AppsFlyerLib to a class that build actually contains, AppsFlyer
went from undetected to detected, and the report named it as the SDK that would
otherwise have been missed. That is the whole value of the feature in one line.
lib/mapping.js parses it. It is never required: a missing, wrong, or
unparseable mapping leaves the scan running exactly as it would without one.
Reliable (presence of an SDK). "AppsFlyer is compiled into this app" is solid.
NOT determinable statically, and never claimed:
- whether
checkAgeSignals()is actually called - whether each ad SDK's restricted-mode flag is set before that SDK initialises — the ordering requirement that is the most common real failure
- whether app behaviour actually branches for a minor
Those three require a runtime check, and they are precisely what the paid manual pass covers. The tool can prove non-compliance; it can never certify compliance.
Layout
| file | role |
|---|---|
| lib/zip.js | Minimal ZIP reader (ZIP64, buffer or file source), no deps |
| lib/axml.js | Android binary-XML string-pool parser (UTF-8 and UTF-16) |
| lib/signatures.js | SDK signature DB + per-SDK remediation call |
| lib/agesignals-api.js | What the Play Age Signals API returns, and the runtime checks it implies |
| lib/apk.js | APK inventory: SDKs, permissions, ABIs, frameworks |
| lib/shallow.js | Store-URL scan (iTunes lookup API / Play listing) |
| lib/rules.js | Findings engine + statute references + disclaimer |
| lib/pdf.js | Minimal PDF writer + report renderer (no deps) |
| lib/ratelimit.js | Fixed-window per-client limiter |
| lib/outbound.js | Global gate on outbound store requests |
| lib/leads.js | Email capture, append-only JSONL |
| lib/notify.js | Lead delivery to an HTTPS webhook |
| lib/mapping.js | R8/ProGuard mapping.txt reader (optional upload) |
| cli.js | Command-line scanner, CI exit codes |
| server.js | HTTP server, /api/shallow, /api/report.pdf, /api/lead |
| public/index.html | Single-page UI |
Currently detects 27 SDKs across ads, analytics, attribution, engagement,
diagnostics and compliance, plus 6 app frameworks. Adding one is a single
object in lib/signatures.js.
Known unverified areas — read before shipping
The Google Play HTML scraper is validated against live markup as of 3 Aug 2026 — 10/10 real listings parsed cleanly via
check-live.js. Play's page is JS-heavy and its markup still changes without notice, so re-runcheck-live.jsbefore each engagement. It degrades honestly (reportssignalQuality: 'low'and a warning) rather than inventing findings. If it ever comes back empty, either parse the embeddedAF_initDataCallbackJSON blobs or render the page headlessly.The iTunes lookup API path is verified working against a live response. But Apple exposes no SDK inventory and no privacy labels through it, so iOS shallow scanning is structurally much weaker than Android. The report says so rather than padding it out.
restrictedModeApivalues markedverify: trueinsignatures.jsare SDKs whose child/COPPA API was renamed or deprecated across versions (AppLovin, Vungle, Pangle, Mintegral, InMobi, Chartboost, AdColony, Singular, Kochava, Segment, Braze). Confirm against the version actually bundled before putting these in a client deliverable. The UI already flags them.Package name is read from the manifest's
packageattribute and labelleddeclared. The old most-common-prefix heuristic survives only as a fallback for an unreadable tree, labelledheuristic. It was wrong on real input: Call of Duty's manifest carries morecom.google.androidstrings thancom.activisionones, so it reported the game ascom.google.android.gms..aabbundles are rejected with a message telling the user to export a universal APK. Bundles have no single merged manifest or DEX set.iOS deep scanning is not implemented. IPAs are FairPlay-encrypted and not practically obtainable. If you need it, the path is asking the developer for the
.xcarchiveor the unencrypted build, then readingPrivacyInfo.xcprivacyand theFrameworks/directory.
Legal references (verified 3 Aug 2026)
- Brazil ECA Digital (Lei 15.211/2025) — in force since 17 March 2026,
with no grace period or phased rollout. Enforceable earlier than any US
statute here. Reaches any provider of services targeted at minors or merely
likely to be accessed by them, including companies outside Brazil. Requires
reliable age verification and expressly prohibits mere self-declaration —
which bears directly on Play Age Signals, whose
TIER_Aresponses are self-declared. Under-16 accounts must be linked to a legal guardian. Penalties reach 10% of Brazilian revenue, suspension, or prohibition from operating. Unconfirmed: whether developers get a safe harbour for relying on app-store signals, as Texas and Utah allow. Establish this before telling anyone a store signal alone suffices in Brazil. - Texas SB 2420 — in force since 4 June 2026, after the Fifth Circuit stayed a preliminary injunction. Google states Play began returning age signals for Texas users whose accounts were created after 28 May 2026.
- Utah — developer obligations postponed to 7 May 2027. Provides a safe harbour for developers relying on app-store age data.
- Louisiana — delayed to 2027; sources report both 6 May and 1 July 2027. Confirm the operative date before relying on it. Notably gives developers no safe harbour for relying on app-store age data.
- Play Age Signals:
com.google.android.play:age-signals, packagecom.google.android.play.agesignals, entry pointAgeSignalsManagerFactory.create(...).checkAgeSignals(...).
Sources:
- Texas App Store Age-Verification Law (SB 2420) Takes Effect
- Texas App Store Law Takes Effect After Fifth Circuit Stays Injunction — Morrison Foerster
- Use Play Age Signals API (beta) — Android Developers
- Louisiana Delays App Store Accountability Effective Date — Alston & Bird
- Apps and Minors: Louisiana, Utah, and Texas — Bass, Berry & Sims
- Countdown to Jan 1 2026: Mobile Developers Must Adopt Apple, Google APIs
- Enforcement of Brazil's ECA Digital introduces new obligations for companies — Mayer Brown
- Brazil Regulates the Children and Adolescents Online Safety Act (Digital ECA) — Baker McKenzie
- Use Play Age Signals API — testing with FakeAgeSignalsManager
- Age Assurance in 2026: UK and EU — Lewis Silkin
- Apple DeclaredAgeRange framework
- Deliver age-appropriate experiences in your app — WWDC25
- Understand Age Signals responses — tiers, statuses, install ID
Scope-limited regimes — in force, but they do not bind every app
These bind by service type, not by shipping an app. The scope line matters more than the date, and the report prints it above the note for that reason.
- EU Digital Services Act, Article 28 — in force since 17 February 2024, Commission guidelines 14 July 2025. Prohibits targeting minors with personalised advertising — a prohibition, not a duty to act on a signal, and stricter than any US statute here. Binds "online platforms" as the DSA defines them: a social or UGC app very likely is one, a single-player game with ads is not.
- UK Online Safety Act 2023 — age assurance duties since 25 July 2025, enforced by Ofcom. Binds user-to-user services, search services, and services publishing pornography. Ofcom's accepted methods are photo-ID matching, facial age estimation, Open Banking, digital identity and mobile-operator checks — an app-store age signal is not among them, so it may not discharge this duty on its own. That is the opposite of the Texas/Utah safe-harbour position.
iOS has its own API
Apple introduced the DeclaredAgeRange framework in iOS 26
(AgeRangeService.requestAgeRange(...)), the counterpart to Play Age Signals.
This scanner cannot check for it — IPAs are FairPlay-encrypted — but the iOS
report names it, so a thin iOS result is not mistaken for "nothing to do on iOS".
This tool does not give legal advice and does not certify compliance. Statute dates are moving — several were enjoined, stayed or postponed in the last year. Re-check them before each engagement.
PDF export, email capture, rate limiting
PDF export (lib/pdf.js) is a PDF 1.4 writer written against the standard-14
fonts, so nothing is embedded and there is still no dependency. The client posts
the report back to POST /api/report.pdf and gets the file. That round trip is
deliberate: the deep scan's binary is discarded the moment it is analysed, so
the report cannot be regenerated server-side, and keeping one around to enable
that would break the promise the upload panel makes.
Text is WinAnsi-encoded, wrapped against real AFM advance widths, and hard-broken
when a single token (a long API call) is wider than the column. Unrepresentable
codepoints become ? rather than silently vanishing.
Email capture (lib/leads.js) appends JSONL to data/leads.jsonl
(LEADS_FILE to relocate). It stores the address, what was scanned, and the
severity tally — never the findings, never the SDK inventory, never an IP. This
is the same rule that shapes the shallow tier: a list of companies annotated
with their specific weaknesses is a liability the moment it exists. A record
saying "this person asked about this package" is a sales note; one saying "this
package ships six ad SDKs and no age signal" is a target list. There is a test
asserting the record's exact key set so it stays that way.
Rate limiting (lib/ratelimit.js) is a fixed window per client per route —
shallow 20, deep 10, pdf 30, lead 5, each per 10 minutes, all overridable by
env. Rejections carry Retry-After. Invalid input still counts against the
limit, otherwise malformed requests are a free bypass. X-Forwarded-For is
only trusted when TRUST_PROXY=1, since otherwise any caller can spoof it.
The outbound gate — read this before tuning anything
lib/ratelimit.js and lib/outbound.js do different jobs, and conflating them
is how the scanner dies.
The per-client limiter stops one visitor abusing the tool. It does not protect the Play scraper, because Google does not see visitors — it sees this server. A hundred people each politely staying under their own 20-scan limit still adds up to one address making thousands of requests, which is exactly what gets a scraper blocked. A per-client limit is counting the wrong thing.
So lib/outbound.js gates every store request globally, regardless of who
asked: one call per second (OUTBOUND_MIN_GAP_MS), at most 20 waiting
(OUTBOUND_MAX_QUEUE). Past that depth callers get an immediate 503 with
Retry-After rather than a held connection, because the browser would give up
on that wait anyway and a fast honest refusal is worth more than a slow
timeout. Gates are per host — Play and iTunes have separate quotas, so making
one wait behind the other would cost latency and buy no protection.
This is the load-bearing one. Without it a modest traffic spike gets the scraper blocked and the whole free tier stops working. The per-client limit is merely nice to have.
Note check-live.js now takes ~17s rather than ~4s. That is the gate doing its
job, not a regression.
Caveat, and the same one applies to both limiters: state is per process and
resets on restart. That is correct on a single box. Behind more than one
instance the per-client limit should move to the edge (Cloudflare, or nginx
limit_req) rather than into shared storage in Node — and note the outbound
gate would then allow one call per second per instance, so OUTBOUND_MIN_GAP_MS
has to be scaled by the instance count. On a serverless runtime, where
processes are ephemeral and numerous, neither limiter is meaningful; the
scanner itself deploys there fine, the throttling does not.
The lead form is a queue you have to work
No mail is dispatched. The form records to data/leads.jsonl, logs a
[lead] … — reply owed line, and — if LEAD_WEBHOOK_URL is set — POSTs the
lead to an HTTPS endpoint of your choosing (Slack, Discord, Zapier, an email
API). A human still writes the reply; the webhook only guarantees the lead
reaches one.
Set that variable before deploying. Container filesystems are ephemeral, so
without it the JSONL file is gone on the next redeploy and the form's promise
is not merely unautomated but broken. The server logs a loud warning at boot
when it is unset. See DEPLOY.md.
The copy is written to match. It says a person replies and to allow a day, the button settles on "Requested" rather than "Sent", and the confirmation reads "we will be in touch" rather than "we will send" — because someone who is told an email is on its way, and then waits for one that no code will ever produce, has been lied to by the tool that just asked them to trust it with their build.
If you wire up delivery, change the copy back. Under-promising once mail actually sends is its own small cost.
Things not built yet
- Automated delivery of the checklist (see above — the copy is honest about this, so it is a feature gap rather than a broken promise)
- Play HTML parsing is validated but unpinned; re-run
check-live.jsper engagement - Runtime verification — whether the age signal is actually requested, whether each SDK's restricted-mode flag is set before that SDK initialises, and whether anything changes for a minor. None of it is statically decidable, and this repository does not attempt it.
