Docs / Sending builds from CI
Sending builds from CI
A build goes up when it is made, its report is there before anyone installs it, and the job fails when the score falls under the line you set. With Team and Business.
1. A key
A key is made on your account page, under API keys. It is shown once, when it is made; the
site keeps only its hash, so a lost key is revoked and made again. It goes in the Authorization header
of every request, and belongs in the build server's secrets - below it is APKREPORT_KEY.
A key sends builds and reads what came of them. It does not open the account: not its keys, its data or its erasure.
The API comes with Team and Business: plans and pricing.
2. Five lines
Send the APK, say what it scored, and fail under 70. One request carries up to 64 MB and waits up to 75 seconds for its report; past either, see a large app.
curl -sS --fail-with-body -H "Authorization: Bearer $APKREPORT_KEY" \
-F files=@app/build/outputs/apk/release/app-release.apk \
https://apkreport.com/api/scan -o report.json
jq -r '.results[0] | "\(.score.total) \(.score.band) \(.url)"' report.json
test "$(jq '.results[0].score.total' report.json)" -ge 70
The answer holds one entry in results for each app sent:
| Field | What it holds |
|---|---|
.results[0].sha256 | The build's digest: its name in every other address. |
.results[0].score.total | The score, 0 to 100. |
.results[0].score.band | good, warning, serious or critical. |
.results[0].score.axes | The six axes by name: security, tracking,
hygiene, packaging, access, background. |
.results[0].url | The report, for whoever is signed in to your account or your team. |
.results[0].findings | Each finding with its severity, title and evidence. |
.results[0].facts | What was read: package, version_name,
version_code, permissions and the rest of the report's facts. |
A base APK and its splits go as several -F files=@... in one request and come back as one report, as the
phone installs them. An .apks, .xapk or .apkm bundle is checked the same way.
With -F keep=true the file is kept for as long as your plan says, so its sources can be decompiled from the report.
To hold a build to one axis, or to its findings:
test "$(jq '.results[0].score.axes.tracking' report.json)" -ge 60 test "$(jq '[.results[0].findings[] | select(.severity == "critical")] | length' report.json)" -eq 0
3. A large app
One request carries 64 MB at most: a larger APK is refused with 413 before the site sees it,
and goes up through the script, which sends it in slices. The request waits for the report for up
to 75 seconds. An app that takes longer is answered with 202 and a job to ask after,
.job, instead of .results:
job=$(jq -r '.job // empty' report.json)
while [ -n "$job" ]; do
sleep 5
curl -sS -H "Authorization: Bearer $APKREPORT_KEY" "https://apkreport.com/api/jobs?ids=$job" -o job.json
case "$(jq -r '.jobs[0].state' job.json)" in
done) sha=$(jq -r '.jobs[0].sha256' job.json); break ;;
failed) jq -r '.jobs[0].error' job.json; exit 1 ;;
esac
done
[ -z "$job" ] || curl -sS -H "Authorization: Bearer $APKREPORT_KEY" \
"https://apkreport.com/api/artifacts/$sha/score" -o score.json
[ -z "$job" ] || test "$(jq '.total' score.json)" -ge 70
/api/artifacts/<sha256>/score answers with .total, .band and the axes
with their arithmetic, for any build you sent.
4. The SBOM
The library list of a build as a CycloneDX 1.5 document, to file with the release or hand to a tool that reads SBOMs:
sha=$(jq -r '.results[0].sha256' report.json)
curl -sS --fail-with-body -H "Authorization: Bearer $APKREPORT_KEY" \
"https://apkreport.com/api/artifacts/$sha/sbom" -o app.cdx.json
It says what the APK states and nothing more: a version where a file in the APK gives one, Maven coordinates and a purl only where a packaging marker names the group and the artifact. A package found in the code is listed as a package.
5. Against the build before
What a pull request brought: the build set against the build before it of the same app from your account or your team, and the verdict by the lines the job sets.
curl -sS --fail-with-body -H "Authorization: Bearer $APKREPORT_KEY" \
"https://apkreport.com/api/artifacts/$sha/check?fail_below=70&fail_on=new-tracker,new-dangerous-permission" -o check.json
jq -r '.verdict.reasons[]' check.json
test "$(jq -r '.verdict.state' check.json)" = pass
The build before is the newest one your account or team sent of the same package with a version code no higher
than this build's: a branch that keeps its version code is checked build against build. With nothing at or below
it, the build above it, and .previous.against_newer says so. against=<sha256> names
the build to compare with instead - the main branch's last build, say. Builds anyone else sent are never compared
with yours.
| Parameter | What it does |
|---|---|
fail_below | Fail when the score is under this, 0 to 100. |
fail_on=new-tracker | Fail on a tracking SDK the build before did not have. |
fail_on=new-dangerous-permission | Fail on a dangerous permission the build before did not ask for. |
fail_on=new-finding | Fail on a warning, serious or critical finding the build before did not have, or had milder. |
fail_on=score-drop | Fail on a score under the build before's. |
fail_on takes several, comma-separated or repeated. A build with nothing before it fails on the score
alone: there is nothing it brought, and .verdict.notes says so. The answer:
| Field | What it holds |
|---|---|
.verdict.state | pass or fail; .verdict.reasons
says why it failed, a sentence each. |
.score | The build's score: .total, .band and the
.axes by name, as in /api/scan. |
.previous | The build it was set against: .sha256, .version_name,
.version_code, its .score; null when there is none. |
.score_delta | This build's total less the one before's; .axes_delta the axes that moved. |
.trackers.new | The tracking SDKs the build before did not have; .trackers.gone the ones it no longer has. |
.dangerous_permissions.new | The dangerous permissions it asks for and the build before did not. |
.findings.new | The findings it has that the build before did not, or had milder (.was);
.findings.resolved the ones that went. |
.changes | The report's change list of the two: the signing key, the network, the exported components, the libraries and the rest, section by section. |
/api/artifacts/<sha256>/diff is the same answer without the verdict.
6. SARIF
The build's findings as SARIF 2.1.0, for GitHub's code scanning or any tool that reads SARIF: a rule for each kind of finding, an alert for each finding, with its severity. An alert is the same alert from build to build, so the page closes it when a build no longer has it.
curl -sS --fail-with-body -H "Authorization: Bearer $APKREPORT_KEY" \
"https://apkreport.com/api/artifacts/$sha/sarif?uri=app/build/outputs/apk/release/app-release.apk" -o apkreport.sarif
uri is where the alerts are shown: the APK's path in the repository. With
manifest=app/src/main/AndroidManifest.xml what the manifest says - debuggable, backups, exported
components, cleartext - is shown on the manifest of your sources instead.
7. The script
All of it in one step: apkreport-check.py, Python 3.8 or later and
nothing else. It sends the build in 48 MB slices, as the site's own uploader does, so no request is larger than the
site takes in one, and it waits for the report by asking after the job rather than holding a request open: an app of
any size the plan takes goes through. Then it asks for the check, prints it, and exits 0 on pass,
1 on fail and 2 when the build could not be checked.
curl -fsSO https://apkreport.com/static/ci/apkreport-check.py
python3 apkreport-check.py app/build/outputs/apk/release/app-release.apk \
--fail-below 70 --fail-on new-tracker,new-dangerous-permission --sarif apkreport.sarif
| Option | What it does |
|---|---|
--fail-below, --fail-on, --against | As fail_below,
fail_on and against above. |
--sarif PATH | Writes the SARIF there; --manifest as above. |
--comment github | Posts the score and what the build brought on the pull request, and
updates that one comment on every push; gitea and gitlab for those. |
--json PATH | Writes the check's answer there; --summary PATH the comment. |
--keep | Keeps the file, as -F keep=true does. |
--no-fail | Reports, and exits 0 whatever the verdict. |
Several files are a base APK and its splits; an .apks, .xapk or .apkm goes as one.
The key comes from APKREPORT_KEY.
8. GitHub Actions
The key as a repository secret named APKREPORT_KEY; the comment is posted with the job's own token:
on: [pull_request, push]
permissions: { contents: read, pull-requests: write, security-events: write }
jobs:
apkreport:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-java@v4
with: { distribution: temurin, java-version: 21 }
- run: ./gradlew assembleRelease
- name: APKreport
env:
APKREPORT_KEY: ${{ secrets.APKREPORT_KEY }}
GITHUB_TOKEN: ${{ github.token }}
run: |
curl -fsSO https://apkreport.com/static/ci/apkreport-check.py
python3 apkreport-check.py app/build/outputs/apk/release/app-release.apk \
--fail-below 70 --fail-on new-tracker,new-dangerous-permission \
--comment github --sarif apkreport.sarif --no-fail --json apkreport.json
- uses: github/codeql-action/upload-sarif@v4
with: { sarif_file: apkreport.sarif, category: apkreport }
- run: test "$(jq -r '.verdict.state' apkreport.json)" = pass
The job fails after the SARIF is up, so a build that fails still shows its alerts. Code scanning on a private
repository needs GitHub's Advanced Security; without it, leave out --sarif and the upload step.
9. Gitea Actions
The same job; the comment goes with a token of an account that may comment on the repository, as a secret named
APKREPORT_GITEA_TOKEN (Gitea keeps the names that start with GITEA_ to itself). Gitea has no
code scanning: the SARIF is kept with the run.
- name: APKreport
env:
APKREPORT_KEY: ${{ secrets.APKREPORT_KEY }}
APKREPORT_GITEA_TOKEN: ${{ secrets.APKREPORT_GITEA_TOKEN }}
run: |
curl -fsSO https://apkreport.com/static/ci/apkreport-check.py
python3 apkreport-check.py app/build/outputs/apk/release/app-release.apk \
--fail-below 70 --fail-on new-tracker,new-dangerous-permission \
--comment gitea --sarif apkreport.sarif
- uses: actions/upload-artifact@v3
if: always()
with: { name: apkreport, path: apkreport.sarif }
10. GitLab CI
A job after the one that builds the APK. The key as a masked CI/CD variable APKREPORT_KEY; the comment
on the merge request goes with a project access token with the api scope, as
APKREPORT_GITLAB_TOKEN.
apkreport:
stage: test
image: python:3.12-slim
needs: [assemble]
rules:
- if: $CI_PIPELINE_SOURCE == "merge_request_event"
- if: $CI_COMMIT_BRANCH == $CI_DEFAULT_BRANCH
script:
- python3 -c "import urllib.request as u; u.urlretrieve('https://apkreport.com/static/ci/apkreport-check.py', 'apkreport-check.py')"
- python3 apkreport-check.py app/build/outputs/apk/release/app-release.apk
--fail-below 70 --fail-on new-tracker,new-dangerous-permission
--comment gitlab --sarif apkreport.sarif --summary apkreport.md
artifacts:
when: always
paths: [apkreport.sarif, apkreport.md]
11. Gradle
A task in the app module's build.gradle.kts, with the script beside it; the key comes from the environment:
tasks.register<Exec>("apkreportCheck") {
dependsOn("assembleRelease")
val apk = layout.buildDirectory.file("outputs/apk/release/app-release.apk")
commandLine("python3", rootProject.file("apkreport-check.py").path, apk.get().asFile.path,
"--fail-below", "70", "--fail-on", "new-tracker,new-dangerous-permission")
}
./gradlew apkreportCheck fails as the job would, and prints why.
12. What counts
Every new build counts as one app against the plan. The same bytes sent again are answered from the store
- the entry says "existing": true - and count nothing, so a job that runs twice on one build costs once.
What a team's keys send counts against the team's plan as one.
What Team and Business send is confidential. It never feeds an app's public page, and its report opens to your account and your team only.
13. When it says no
| Answer | What it means |
|---|---|
400 | The file could not be read as an APK; .detail says why. |
401 | No key, a revoked key, or a key whose plan no longer has the API. |
402 | The plan does not cover it; .detail names the plan and the limit. |
413 | More than one request carries (64 MB), or more than the plan takes for one app. |
429 | Too many requests from one address, or uploads open on one account; Retry-After says how long to wait. |
A 504, or Cloudflare's 524, is the way in giving up on an answer, not the site: the build
is still checked. Run the job again a few minutes later and the same file is answered from the store, at no cost.