Structured output for programmatic consumers (webapp, CI, downstream tools).
One JSON document per scan run — one bundle, one DNS list, or one --project-id invocation.
Authoritative machine-readable schema: openfirebase-scan.schema.json.
unauthandauthare siblings on the same finding, not separate top-level sections. This is the drift-proofing: there is no second formatter that can fall out of sync, because the data lives together.- Raw
status/securityare preserved exactly as the scanner emits them. A derivedverdictfield gives the webapp a stable, small vocab to key off so UI logic doesn't chase every newsecurityvalue the scanners add. - Transport errors go into
error, notsecurity. The scanner dict conflates the two today (CF writesTIMEOUTintosecurity); the JSON output separates them. The raw value stays visible — just also mirrored intoerror.kind. - Read and write are separate findings, not a merged result. RTDB, Firestore, and Storage probe both; Remote Config and Cloud Functions probe read only. Four severity buckets per URL: read-public-unauth, read-public-auth, write-public-unauth, write-public-auth.
- Extraction output is first-class. Service-account blobs and leaked PEM keys are both extraction artifacts and findings in their own right — the webapp can render them in two views without duplicating storage.
{
schema_version, tool_version, scan_id, started_at, finished_at,
input: { type, source, platform },
config: { check_with_auth, fuzz_collections, fuzz_functions, wordlist, ... },
auth: { used, identities[] },
extraction: { bundle?: {...}, dns?: {...} },
projects: [ { project_id, findings[] } ],
summary: { per_service: { firestore: {...}, ... } }
}
| service | status codes |
|---|---|
| RTDB | 200, 401, 403, 404, 423, 429 |
| Firestore | 200, 400, 401, 403, 404, 429 |
| Storage | 200, 400, 401, 403, 404 |
| Remote Config | 200, 401, 403, 404, 429 |
| Cloud Functions | 200, 400, 401, 403, 404, 405, 415, 429, 500 |
| transport | "0" (timeout / connection error) |
| service | values |
|---|---|
| RTDB | PUBLIC, PROTECTED, LOCKED, NOT_FOUND, WRITE_DENIED, RATE_LIMITED, UNKNOWN |
| Firestore | PUBLIC, PUBLIC_DB_NONEXISTENT_COLLECTION, DATASTORE_MODE, PROTECTED, NOT_FOUND, WRITE_DENIED, UNKNOWN |
| Storage | PUBLIC, PROTECTED, NOT_FOUND, WRITE_DENIED, RULES_VERSION_ERROR, UNKNOWN |
| Remote Config | PUBLIC, PROTECTED, NO_CONFIG, MISSING_CONFIG, NOT_FOUND, RATE_LIMITED, UNKNOWN |
| Cloud Functions | PUBLIC, PROTECTED, NOT_FOUND, SOURCE_LEAK, SKIPPED, TIMEOUT, CONNECTION_ERROR, ERROR, UNKNOWN |
PUBLIC, PUBLIC_AUTH (user token), PUBLIC_SA (service account), PROTECTED, APP_CHECK, NOT_FOUND, UNKNOWN.
unauth.verdict:public | protected | not_found | rate_limited | locked | error | unknownauth.verdict:public | still_protected | app_check | not_found | error | unknown
read— emitted by all five services.write— emitted only by RTDB, Firestore, Storage. Remote Config and Cloud Functions never emitwritefindings.
| service | fields set on resource |
|---|---|
| RTDB | origin |
| Firestore | collection, origin (extracted / fuzzed / default) |
| Storage | surface (Firebase Rules or GCS IAM), origin |
| Remote Config | origin |
| Cloud Functions | function_name, region, origin (extracted / fuzzed) |
Cloud Functions source bucket leaks (security: "SOURCE_LEAK") are emitted as regular findings under projects[].findings[], one per leaking region-bucket (gen1 uses gcf-sources-{num}-{region}, gen2 uses gcf-v2-sources-{num}-{region}). The url points at the GCS bucket listing endpoint rather than a function invocation URL.
- Multi-APK uploads: one scan document per APK. The webapp treats each upload as its own result tab. Cross-APK dedup is a UI concern, not a schema concern.
--project-id:input.type = "project_ids", noextraction.bundleblock, findings populated normally.- DNS list input:
input.type = "dns_list",extraction.dns.matched_project_idsholds the parsed project IDs, findings populated as usual.
{
"schema_version": "1.0",
"tool_version": "1.4.0",
"scan_id": "2026-04-20T09-12-44_app.apk",
"started_at": "2026-04-20T09:12:44Z",
"finished_at": "2026-04-20T09:14:02Z",
"input": { "type": "apk", "source": "app.apk", "platform": "android" },
"config": {
"check_with_auth": true,
"fuzz_collections": true,
"fuzz_functions": false,
"wordlist": "default",
"services": ["rtdb", "firestore", "storage", "remote_config", "cloud_functions"]
},
"auth": {
"used": true,
"identities": [
{ "kind": "service_account", "ref": "firebase-adminsdk-xyz@my-proj.iam.gserviceaccount.com" }
]
},
"extraction": {
"bundle": {
"type": "apk",
"path": "app.apk",
"package_name": "com.example.app",
"signatures": { "sha1": ["AA:BB:CC:..."] },
"items": [
{ "type": "Google_API_Key", "value": "AIzaSy...", "source": "strings.xml" },
{ "type": "Firebase_Project_ID", "value": "my-proj", "source": "strings.xml" },
{ "type": "Google_App_ID", "value": "1:000000000000:android:abcdef", "source": "strings.xml" },
{ "type": "Firebase_Storage_Old", "value": "my-proj.appspot.com", "source": "strings.xml" },
{ "type": "Firestore_Collection_Name", "value": "users", "source": "dex" },
{ "type": "Cloud_Functions_Callable_Name", "value": "sendEmail", "source": "dex" }
],
"service_accounts": [
{
"client_email": "firebase-adminsdk-xyz@my-proj.iam.gserviceaccount.com",
"project_id": "my-proj",
"private_key": "-----BEGIN PRIVATE KEY-----\nMIIE...\n-----END PRIVATE KEY-----\n",
"file_path": "assets/service-account.json"
}
],
"leaked_private_keys": [
{
"pem_type": "RSA PRIVATE KEY",
"pem": "-----BEGIN RSA PRIVATE KEY-----\nMIIE...\n-----END RSA PRIVATE KEY-----\n",
"source": "dex"
}
]
}
},
"projects": [{
"project_id": "my-proj",
"package_names": ["com.example.app"],
"started_at": "2026-04-20T09:12:50Z",
"finished_at": "2026-04-20T09:14:01Z",
"findings": [
{
"service": "firestore",
"url": "https://firestore.googleapis.com/v1/projects/my-proj/databases/(default)/documents/users",
"probe": "read",
"resource": { "collection": "users", "origin": "extracted" },
"unauth": {
"status": "403", "security": "PROTECTED", "message": "Permission denied",
"response_content": "...", "verdict": "protected"
},
"auth": {
"status": "200", "security": "PUBLIC_SA", "message": "Public access (service account)",
"response_content": "{\"documents\":[...]}",
"verdict": "public",
"identity": {
"kind": "service_account",
"ref": "firebase-adminsdk-xyz@my-proj.iam.gserviceaccount.com"
}
},
"error": null
},
{
"service": "firestore",
"url": "https://firestore.googleapis.com/v1/projects/my-proj/databases/(default)/documents/users",
"probe": "write",
"resource": { "collection": "users", "origin": "extracted" },
"unauth": {
"status": "403", "security": "WRITE_DENIED", "message": "Write denied",
"verdict": "protected"
},
"auth": null,
"error": null
},
{
"service": "storage",
"url": "https://firebasestorage.googleapis.com/v0/b/my-proj.appspot.com/o",
"probe": "read",
"resource": { "surface": "Firebase Rules", "origin": "extracted" },
"unauth": {
"status": "200", "security": "PUBLIC", "message": "Public access",
"verdict": "public"
},
"auth": null,
"error": null
},
{
"service": "storage",
"url": "https://storage.googleapis.com/storage/v1/b/my-proj.appspot.com/o",
"probe": "read",
"resource": { "surface": "GCS IAM", "origin": "extracted" },
"unauth": {
"status": "401", "security": "PROTECTED", "message": "Unauthorized",
"verdict": "protected"
},
"auth": {
"status": "401", "security": "APP_CHECK",
"message": "Callable returned UNAUTHENTICATED with a valid Bearer token",
"verdict": "app_check",
"identity": { "kind": "service_account", "ref": "firebase-adminsdk-xyz@my-proj.iam.gserviceaccount.com" }
},
"error": null
},
{
"service": "cloud_functions",
"url": "https://us-central1-my-proj.cloudfunctions.net/api",
"probe": "read",
"resource": { "function_name": "api", "region": "us-central1", "origin": "extracted" },
"unauth": {
"status": "0", "security": "TIMEOUT", "message": "Request timeout",
"verdict": "error"
},
"auth": null,
"error": { "stage": "unauth", "kind": "TIMEOUT", "message": "Request timeout" }
},
{
"service": "cloud_functions",
"url": "https://storage.googleapis.com/storage/v1/b/gcf-sources-000000000000-us-central1/o",
"probe": "read",
"resource": { "region": "us-central1", "origin": "extracted" },
"unauth": {
"status": "200", "security": "SOURCE_LEAK",
"message": "Cloud Functions source code bucket is publicly listable (gen1)",
"verdict": "public"
},
"auth": null,
"error": null
}
]
}],
"summary": {
"per_service": {
"firestore": { "read_public_unauth": 0, "read_public_auth": 1, "write_public_unauth": 0, "write_public_auth": 0, "protected": 1, "not_found": 0 },
"storage": { "read_public_unauth": 1, "read_public_auth": 0, "write_public_unauth": 0, "write_public_auth": 0, "app_check": 1 },
"cloud_functions": { "errors": 1 }
}
}
}- Breaking changes bump
schema_versionmajor (e.g.2.0). - Additive fields (new optional keys, new enum values in per-service
securityvocab as scanners grow) do not bump the major. Consumers must ignore unknown fields. - The webapp should reject documents whose
schema_versionmajor doesn't match what it was built for, with a clear "upgrade required" error rather than silent partial rendering.