Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 13 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -138,7 +138,7 @@ Checks an Android app against Google Play's Developer Program Policies and its p

Deadlines:

- Target API level. New apps and updates must target API 36 from August 31, 2026. Apps below API 35 already lose distribution to new users on newer devices. CRITICAL / HIGH
- Target API level. New apps and updates must target API 36 from August 31, 2026. Apps below API 35 already lose distribution to new users on newer devices. CRITICAL / HIGH. Play runs a separate schedule for Wear OS, Android TV, Automotive, and XR, so an app declaring one of those form factors gets a WARN naming its track instead of a blocking finding on the phone schedule.
- Play Billing Library. v7 and below lose support on August 31, 2026, and there is no direct v7 to v9 upgrade path. Versions reached through a variable or a version catalog `version.ref` are resolved. HIGH

Restricted permissions, each of which needs an approved use case or a declaration form:
Expand All @@ -162,9 +162,20 @@ Manifest and build:
- An ads SDK shipped without `com.google.android.gms.permission.AD_ID`, which silently returns a zeroed advertising ID
- Account creation without the required in-app *and* web deletion paths

`--apk` and `--aab` read the *merged* manifest, so they see permissions contributed by library manifests that a source scan structurally cannot. Every check above runs against a built artifact, plus native code:
`--apk` and `--aab` read the *merged* manifest, so they see permissions contributed by library manifests that a source scan structurally cannot. Every manifest and permission check above runs against a built artifact, plus the native code checks below.

The three checks that read the Gradle model (Play Billing version, ads-SDK detection, auth-SDK detection) cannot run on an archive, because a built artifact does not carry one. The scan reports that gap as a finding rather than staying silent, so a clean artifact scan is never mistaken for a clean scan of everything. Source and artifact are complementary; `preflight` accepts both at once:

```bash
greenlight preflight . --aab app-release.aab
```

Native code checks:

- 16 KB page size. Google Play requires apps targeting Android 15+ to support 16 KB memory pages. Greenlight checks ELF `LOAD` segment alignment on `arm64-v8a` libraries, 16 KB zip alignment of uncompressed libraries, and `GNU_RELRO` presence. CRITICAL / HIGH
- 64-bit requirement. Every 32-bit ABI must ship with its 64-bit counterpart, so `armeabi-v7a` without `arm64-v8a`, or `x86` without `x86_64`, is flagged. CRITICAL

In an AAB, native code is read from every module rather than `base/` alone, so a library that ships in a feature module is checked like any other.

Both formats are decoded in pure Go, an APK's compiled binary XML and an AAB's protobuf manifest alike, so no Android SDK, `aapt2`, or `bundletool` is required.

Expand Down
56 changes: 50 additions & 6 deletions internal/cli/preflight.go
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import (
"encoding/json"
"fmt"
"os"
"path/filepath"
"sort"
"strings"
"time"
Expand All @@ -17,6 +18,8 @@ import (

var (
preflightIPA string
preflightAPK string
preflightAAB string
preflightFormat string
preflightOutput string
preflightExitCode bool
Expand Down Expand Up @@ -63,6 +66,8 @@ Usage:

func init() {
preflightCmd.Flags().StringVar(&preflightIPA, "ipa", "", "path to .ipa file for binary inspection")
preflightCmd.Flags().StringVar(&preflightAPK, "apk", "", "path to a built .apk — adds the merged-manifest and native code checks")
preflightCmd.Flags().StringVar(&preflightAAB, "aab", "", "path to a built .aab — adds the merged-manifest and native code checks")
preflightCmd.Flags().StringVar(&preflightFormat, "format", "terminal", "output format: terminal, json, sarif")
preflightCmd.Flags().StringVar(&preflightOutput, "output", "", "write report to file (stdout if omitted)")
preflightCmd.Flags().BoolVar(&preflightExitCode, "exit-code", false, "exit non-zero on any CRITICAL or HIGH finding (or, with --verify, a failed flow) — for CI gating")
Expand Down Expand Up @@ -97,21 +102,38 @@ func runPreflight(cmd *cobra.Command, args []string) error {
}
}

// --apk and --aab both feed the same archive scanner, so only one can be set.
if preflightAPK != "" && preflightAAB != "" {
return fmt.Errorf("pass --apk or --aab, not both")
}
androidArtifact := preflightAPK
if androidArtifact == "" {
androidArtifact = preflightAAB
}
if androidArtifact != "" {
if _, err := os.Stat(androidArtifact); os.IsNotExist(err) {
return fmt.Errorf("Android artifact not found: %s", androidArtifact)
}
}

// Banner (suppressed for --format json so stdout stays valid JSON).
if f := strings.ToLower(preflightFormat); f != "json" && f != "sarif" {
purple.Println("\n greenlight preflight — every check, one command, zero uploads.")
fmt.Printf(" Project: %s\n", path)
if preflightIPA != "" {
fmt.Printf(" IPA: %s\n", preflightIPA)
}
if androidArtifact != "" {
fmt.Printf(" Android: %s\n", androidArtifact)
}
// Mirrors the gating in preflight.Run so the banner never advertises a
// scanner that will not run.
isIOS, isAndroid := preflight.DetectPlatforms(path)
var scanners []string
if isIOS || !isAndroid {
scanners = append(scanners, "metadata", "codescan", "privacy")
}
if isAndroid {
if isAndroid || androidArtifact != "" {
scanners = append(scanners, "playscan")
}
if preflightIPA != "" {
Expand All @@ -122,7 +144,7 @@ func runPreflight(cmd *cobra.Command, args []string) error {

// Run all checks
start := time.Now()
result, err := preflight.Run(path, preflightIPA, verbose)
result, err := preflight.Run(path, preflightIPA, androidArtifact, verbose)
if err != nil {
return fmt.Errorf("preflight failed: %w", err)
}
Expand Down Expand Up @@ -175,21 +197,24 @@ func runPreflight(cmd *cobra.Command, args []string) error {
}

// --verify: the static cycle runs, THEN Revyl runs the flows on a device.
//
// The platform follows the project rather than being fixed to iOS. An
// Android-only project verified as iOS would have its artifact rejected and
// its flows run against the wrong store's expectations.
verifyPlatform := verifyPlatformFor(path, preflightArtifact)
if preflightArtifact != "" {
if preflightBuildName == "" {
return fmt.Errorf("--artifact requires --build-name (to name or match the Revyl app for the uploaded build)")
}
// preflight's runtime tier is iOS-only (App Store), matching the
// hardcoded Platform: "ios" passed to verify.Run below.
if err := verify.ValidateArtifact(preflightArtifact, "ios"); err != nil {
if err := verify.ValidateArtifact(preflightArtifact, verifyPlatform); err != nil {
return err
}
}
vstart := time.Now()
vres, verr := verify.Run(verify.Config{
ProjectPath: path,
BuildName: preflightBuildName,
Platform: "ios",
Platform: verifyPlatform,
Vars: parseVars(preflightVarsRaw),
DeviceModel: preflightDeviceModel,
OSVersion: preflightOSVersion,
Expand Down Expand Up @@ -538,3 +563,22 @@ func writeCombinedJSON(w *os.File, result *preflight.Result, vres *verify.Result
enc.SetIndent("", " ")
return enc.Encode(combined)
}

// verifyPlatformFor picks the platform the runtime tier should target. An
// explicit artifact extension is authoritative, since the file itself says what
// it is; otherwise the project layout decides, defaulting to iOS for a
// cross-platform or unrecognised project because the App Store flows are the
// ones greenlight models most completely.
func verifyPlatformFor(projectPath, artifact string) string {
switch strings.ToLower(filepath.Ext(artifact)) {
case ".apk":
return "android"
case ".app":
return "ios"
}
isIOS, isAndroid := preflight.DetectPlatforms(projectPath)
if isAndroid && !isIOS {
return "android"
}
return "ios"
}
39 changes: 39 additions & 0 deletions internal/cli/preflight_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,8 @@ package cli

import (
"errors"
"os"
"path/filepath"
"testing"

"github.com/RevylAI/greenlight/internal/preflight"
Expand Down Expand Up @@ -50,3 +52,40 @@ func TestPreflightExit(t *testing.T) {
t.Errorf("clean static + passed runtime should not trip, got %v", err)
}
}

// --- review regression: runtime tier platform ---------------------------

// preflight --verify used to hardcode Platform: "ios", so an Android-only
// project had its flows run against the wrong store and its .apk rejected.
func TestVerifyPlatformFollowsProject(t *testing.T) {
androidOnly := t.TempDir()
if err := os.MkdirAll(filepath.Join(androidOnly, "app", "src", "main"), 0o755); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(filepath.Join(androidOnly, "app", "src", "main", "AndroidManifest.xml"),
[]byte(`<manifest package="com.example"><application/></manifest>`), 0o644); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(filepath.Join(androidOnly, "app", "build.gradle"),
[]byte("android { defaultConfig { targetSdk 36 } }"), 0o644); err != nil {
t.Fatal(err)
}

if got := verifyPlatformFor(androidOnly, ""); got != "android" {
t.Errorf("android-only project: platform = %q, want android", got)
}

// An explicit artifact extension wins over project detection: the file
// itself is unambiguous about what it is.
if got := verifyPlatformFor(androidOnly, "build/MyApp.app"); got != "ios" {
t.Errorf("explicit .app: platform = %q, want ios", got)
}
if got := verifyPlatformFor(t.TempDir(), "build/app-release.apk"); got != "android" {
t.Errorf("explicit .apk: platform = %q, want android", got)
}

// An empty or iOS project keeps the previous default.
if got := verifyPlatformFor(t.TempDir(), ""); got != "ios" {
t.Errorf("unrecognised project: platform = %q, want ios", got)
}
}
114 changes: 106 additions & 8 deletions internal/playscan/archive.go
Original file line number Diff line number Diff line change
Expand Up @@ -96,8 +96,9 @@ func ScanArchive(archivePath string) (*ScanResult, error) {

ctx := &ruleContext{
manifest: manifest,
// A built archive carries no Gradle state; rules that read it are
// source-only and correctly stay silent.
// A built archive carries no Gradle state, so rules that read it
// cannot fire here. That is a coverage gap rather than a pass, and
// gradleOnlyCoverageFinding below says so instead of staying silent.
gradle: &GradleInfo{},
targetSDK: result.TargetSDK,
manifestFile: manifestEntry,
Expand All @@ -107,13 +108,85 @@ func ScanArchive(archivePath string) (*ScanResult, error) {
}
}

// The Gradle-only gap exists whether or not the manifest decoded, so this
// sits outside the block above.
result.Findings = append(result.Findings, gradleOnlyCoverageFinding(archivePath))

libs := collectNativeLibs(&zr.Reader, kind)
result.NativeLibCount = len(libs)
result.Findings = append(result.Findings, checkPageAlignment(libs, kind)...)

// ABI parity is a property of the whole app. A config split legitimately
// carries one ABI, so running the rule against it would report a violation
// that does not exist in the release it belongs to.
isSplit := manifest != nil && strings.TrimSpace(manifest.Split) != ""
if !isSplit {
result.Findings = append(result.Findings, check64BitParity(libs)...)
}

return result, nil
}

// gradleOnlyCoverageFinding names the checks an artifact scan cannot perform.
// Play Billing version, ads-SDK detection, and auth-SDK detection all read the
// Gradle model, which a built archive does not carry. Reporting the gap keeps a
// clean artifact scan from reading as a clean scan of everything.
func gradleOnlyCoverageFinding(archivePath string) Finding {
return Finding{
Severity: sevInfo,
Policy: "Scan coverage",
Title: "Dependency-based checks were skipped for this artifact",
Detail: "A built archive carries no Gradle state, so these source-only checks did not run: " +
"Play Billing Library version, ads-SDK detection (the AD_ID permission check), and auth-SDK detection " +
"(the account-deletion requirement). Manifest, permission, and native code checks all ran normally.",
Fix: "Run `greenlight playscan .` against the project source as well. The two scans are complementary: source sees dependencies, the artifact sees the merged manifest.",
File: archivePath,
}
}

// abi64For maps each 32-bit ABI Play recognises to the 64-bit ABI that must
// ship alongside it.
var abi64For = map[string]string{
"armeabi-v7a": "arm64-v8a",
"x86": "x86_64",
}

// check64BitParity enforces Google Play's 64-bit requirement: an app that ships
// a 32-bit native ABI must ship the matching 64-bit ABI too. An app with no
// native code is unaffected, as is one that is already 64-bit only.
func check64BitParity(libs []nativeLib) []Finding {
if len(libs) == 0 {
return nil
}

present := make(map[string]bool)
for _, lib := range libs {
present[lib.ABI] = true
}

var missing []string
for abi32, abi64 := range abi64For {
if present[abi32] && !present[abi64] {
missing = append(missing, fmt.Sprintf("%s is present with no %s", abi32, abi64))
}
}
if len(missing) == 0 {
return nil
}
sort.Strings(missing)

return []Finding{{
Severity: sevCritical,
Policy: "64-bit requirement",
Title: "32-bit native code ships without its 64-bit counterpart",
Detail: "Google Play requires every app with native code to provide a 64-bit library for each 32-bit ABI it supports. " +
strings.Join(missing, "; ") + ". Play rejects a bundle that carries only 32-bit native code for an ABI.",
Fix: "Add the 64-bit ABIs to your NDK build (abiFilters or ndk.abiFilters), rebuild, and confirm every dependency ships 64-bit libraries too. " +
"Dropping the 32-bit ABI entirely also satisfies the requirement.",
Doc: doc64Bit,
}}
}

// classifyArchive determines the format and locates the manifest entry.
func classifyArchive(zr *zip.Reader) (ArchiveKind, string) {
var hasBundleLayout bool
Expand Down Expand Up @@ -148,15 +221,18 @@ func readZipEntry(zr *zip.Reader, name string) ([]byte, error) {

// collectNativeLibs finds every packaged .so and reads what the alignment
// checks need. Both archive layouts are handled.
//
// An APK holds native code at lib/<abi>/. An AAB holds it at <module>/lib/<abi>/,
// and a bundle has one module directory per feature module on top of "base", so
// scanning only base/ misses native code that ships in a feature module.
func collectNativeLibs(zr *zip.Reader, kind ArchiveKind) []nativeLib {
prefix := "lib/"
if kind == KindAAB {
prefix = "base/lib/"
}

var libs []nativeLib
for _, f := range zr.File {
if !strings.HasPrefix(f.Name, prefix) || !strings.HasSuffix(f.Name, ".so") {
if !strings.HasSuffix(f.Name, ".so") {
continue
}
prefix, ok := nativeLibPrefix(f.Name, kind)
if !ok {
continue
}
lib := nativeLib{
Expand Down Expand Up @@ -189,6 +265,28 @@ func collectNativeLibs(zr *zip.Reader, kind ArchiveKind) []nativeLib {
return libs
}

// nativeLibPrefix reports the "<...>lib/" prefix a packaged .so sits under, and
// whether the entry is native code at all. For an AAB any top-level module
// qualifies, so feature-module native code is scanned alongside base/.
func nativeLibPrefix(name string, kind ArchiveKind) (string, bool) {
if kind != KindAAB {
if strings.HasPrefix(name, "lib/") {
return "lib/", true
}
return "", false
}
// <module>/lib/<abi>/<file>.so
slash := strings.Index(name, "/")
if slash <= 0 {
return "", false
}
prefix := name[:slash+1] + "lib/"
if !strings.HasPrefix(name, prefix) {
return "", false
}
return prefix, true
}

func abiFromPath(name, prefix string) string {
rest := strings.TrimPrefix(name, prefix)
if i := strings.Index(rest, "/"); i > 0 {
Expand Down
Loading
Loading