From 7153cbc20306041f57c9bcb5effcf96c2ca63cd2 Mon Sep 17 00:00:00 2001 From: Andrey Prokopyuk Date: Mon, 19 Oct 2026 12:47:36 +0300 Subject: [PATCH] Scan signed release APKs with VirusTotal before publication --- .github/workflows/release.yml | 20 +++- docs/en/release-automation.md | 11 ++ docs/ru/release-automation.md | 11 ++ fastlane/Fastfile | 10 +- scripts/tests/test_virus_total.py | 155 ++++++++++++++++++++++++++ scripts/virus_total.py | 173 ++++++++++++++++++++++++++++++ 6 files changed, 376 insertions(+), 4 deletions(-) create mode 100644 scripts/tests/test_virus_total.py create mode 100644 scripts/virus_total.py diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 6a32b67..1a0cf74 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -18,7 +18,7 @@ jobs: release: name: Build and verify signed APK runs-on: ubuntu-latest - timeout-minutes: 40 + timeout-minutes: 75 steps: - uses: actions/checkout@v6 with: @@ -65,6 +65,20 @@ jobs: - name: Remove signing files if: always() run: rm -f "$RUNNER_TEMP/message487-release.p12" "$RUNNER_TEMP/message487-keystore-password" + - name: Scan signed APKs with VirusTotal + if: startsWith(github.ref, 'refs/tags/v') && github.event_name == 'push' + timeout-minutes: 35 + env: + VIRUSTOTAL_API_KEY: ${{ secrets.VIRUSTOTAL_API_KEY }} + run: bundle exec fastlane android virus_total + - name: Preserve VirusTotal reports + if: always() + uses: actions/upload-artifact@v7 + with: + name: virustotal-${{ github.run_id }} + path: dist/release/VIRUSTOTAL.* + retention-days: 30 + if-no-files-found: warn - uses: actions/upload-artifact@v7 with: name: message487-signed-release @@ -129,8 +143,10 @@ jobs: committed_notes="release-notes/${GITHUB_REF_NAME#v}.md" test -s "$committed_notes" cp "$committed_notes" "$notes" + printf '\n\n' >> "$notes" + cat dist/release/VIRUSTOTAL.md >> "$notes" cd dist/release sha256sum -c SHA256SUMS - gh release create "$GITHUB_REF_NAME" ./*.apk mapping.txt SHA256SUMS \ + gh release create "$GITHUB_REF_NAME" ./*.apk mapping.txt SHA256SUMS VIRUSTOTAL.md VIRUSTOTAL.json \ --repo "$GITHUB_REPOSITORY" --verify-tag --notes-file "$notes" \ --title "Message487 $GITHUB_REF_NAME" diff --git a/docs/en/release-automation.md b/docs/en/release-automation.md index 72581b9..cba6578 100644 --- a/docs/en/release-automation.md +++ b/docs/en/release-automation.md @@ -9,6 +9,17 @@ then signs and publishes the APK. Merging the implementation does not itself rel This flow does not replace [device release acceptance](release-testing.md). +## VirusTotal check + +Tag publication (`v*`) requires the Actions secret `VIRUSTOTAL_API_KEY`. +After signing, the workflow runs `bundle exec fastlane android virus_total`. +It waits for completed analyses; malicious/suspicious detections, API errors, +timeouts and a missing key block publication. `VIRUSTOTAL.md` and +`VIRUSTOTAL.json` are saved in Actions and attached to the release; +SHA-256 report links appear in the release notes and Actions summary. +Both APK filenames are checked. Manual runs and `release-check/*` do not upload APKs. +PR CI never receives the key. Results are a point-in-time check, not a safety guarantee. + ## One-time manual setup In [Settings → Secrets and variables → Actions](https://github.com/andre487/AndroidMessage487/settings/secrets/actions), add: diff --git a/docs/ru/release-automation.md b/docs/ru/release-automation.md index 82dee07..41fa993 100644 --- a/docs/ru/release-automation.md +++ b/docs/ru/release-automation.md @@ -9,6 +9,17 @@ Этот флоу не заменяет [приёмку релиза на устройствах](release-testing.md). +## Проверка VirusTotal + +Для публикации по тегу `v*` нужен Actions secret `VIRUSTOTAL_API_KEY`. +После подписи APK workflow запускает `bundle exec fastlane android virus_total`. +Он ждёт завершения анализа; обнаружения malicious/suspicious, ошибки API, +таймауты и отсутствие ключа блокируют публикацию. `VIRUSTOTAL.md` и +`VIRUSTOTAL.json` сохраняются в Actions и прикладываются к релизу; +ссылки по SHA-256 добавляются в описание релиза и Actions summary. +Оба имени APK проверяются. Ручной запуск и `release-check/*` не отправляют APK. +PR CI не получает ключ. Результат — проверка на момент анализа, не гарантия безопасности. + ## Что настроить вручную один раз 1. В [Settings → Secrets and variables → Actions](https://github.com/andre487/AndroidMessage487/settings/secrets/actions) diff --git a/fastlane/Fastfile b/fastlane/Fastfile index 5b2a41f..523e077 100644 --- a/fastlane/Fastfile +++ b/fastlane/Fastfile @@ -34,8 +34,8 @@ platform :android do lane :python_checks do python = ENV.fetch("PYTHON", "python3") Dir.chdir(project_root) do - sh(python, "-m", "isort", "--check-only", "DevServer/tests", "scripts/release_automation.py", "scripts/generate_n8n_examples.py", "scripts/tests") - sh(python, "-m", "black", "--check", "DevServer/tests", "scripts/release_automation.py", "scripts/generate_n8n_examples.py", "scripts/tests") + sh(python, "-m", "isort", "--check-only", "DevServer/tests", "scripts/release_automation.py", "scripts/virus_total.py", "scripts/generate_n8n_examples.py", "scripts/tests") + sh(python, "-m", "black", "--check", "DevServer/tests", "scripts/release_automation.py", "scripts/virus_total.py", "scripts/generate_n8n_examples.py", "scripts/tests") sh(python, "scripts/generate_n8n_examples.py", "--check") FileUtils.rm_rf("test-results/python") sh(python, "-m", "xmlrunner", "discover", "-s", "DevServer/tests", "-o", "test-results/python/dev-server") @@ -70,6 +70,12 @@ platform :android do sh(File.join(project_root, "scripts/build-release-apk.sh")) end + desc "Scan signed release APKs with VirusTotal before publication" + lane :virus_total do + sh(ENV.fetch("PYTHON", "python3"), File.join(project_root, "scripts/virus_total.py"), + "--artifacts", File.join(project_root, "dist/release")) + end + desc "Generate EN/RU changelogs, bump the version, and create a release PR" lane :release_prepare do |options| version = options[:version] || ENV.fetch("RELEASE_VERSION") diff --git a/scripts/tests/test_virus_total.py b/scripts/tests/test_virus_total.py new file mode 100644 index 0000000..721b6e9 --- /dev/null +++ b/scripts/tests/test_virus_total.py @@ -0,0 +1,155 @@ +import importlib.util +import json +import os +import tempfile +import unittest +from pathlib import Path +from unittest.mock import Mock, patch + +spec = importlib.util.spec_from_file_location( + "virus_total", Path(__file__).parents[1] / "virus_total.py" +) +m = importlib.util.module_from_spec(spec) +spec.loader.exec_module(m) + + +class VirusTotalTests(unittest.TestCase): + def test_upload_large_apk_waits_for_complete_verdict(self): + with tempfile.TemporaryDirectory() as tmp: + path = Path(tmp) / "mega-proxy-universal.apk" + path.write_bytes(b"A" * (32 * 1024 * 1024)) + client = m.Client("a" * 64) + with patch.object( + client, + "request", + side_effect=[ + {"data": "https://bigfiles.virustotal.com/_ah/upload/test"}, + {"data": {"id": "analysis/id"}}, + {"data": {"attributes": {"status": "queued"}}}, + {"data": {"attributes": {"status": "in-progress"}}}, + { + "data": { + "attributes": { + "status": "completed", + "stats": { + "malicious": 0, + "suspicious": 0, + "undetected": 60, + }, + } + } + }, + ], + ) as request: + row = client.scan(path) + self.assertEqual(5, request.call_count) + self.assertEqual( + m.API + "files/upload_url", request.call_args_list[0].args[0] + ) + upload = request.call_args_list[1].args + self.assertEqual( + "https://bigfiles.virustotal.com/_ah/upload/test", upload[0] + ) + self.assertIn(b'filename="mega-proxy-universal.apk"', upload[1]) + self.assertIn(b"A" * 1000, upload[1]) + self.assertIn("analysis%2Fid", request.call_args_list[-1].args[0]) + self.assertTrue(row["url"].endswith(row["sha256"])) + + def test_untrusted_upload_url_and_redirect_cannot_receive_key(self): + client = m.Client("a" * 64) + client.opener = Mock() + for url in [ + "http://www.virustotal.com/upload", + "https://evil.test/upload", + "https://www.virustotal.com.evil.test/upload", + "https://user@www.virustotal.com/upload", + "https://www.virustotal.com:444/upload", + ]: + with self.subTest(url=url), self.assertRaises(RuntimeError): + client.request(url) + client.opener.open.assert_not_called() + with self.assertRaises(RuntimeError): + m.NoRedirect().redirect_request( + None, None, 302, "", {}, "https://evil.test" + ) + + def test_api_quota_deadline_and_error_do_not_expose_key(self): + client = m.Client("a" * 64) + client.opener = Mock() + client.next_request = 110 + client.deadline = 1000 + error = m.urllib.error.HTTPError(m.API, 429, "secret", {}, None) + client.opener.open.side_effect = error + with ( + patch.object(m.time, "monotonic", return_value=100), + patch.object(m.time, "sleep") as sleep, + ): + with self.assertRaisesRegex( + RuntimeError, r"^VirusTotal API failed \(HTTP 429\)$" + ): + client.request(m.API + "files/upload_url") + sleep.assert_called_once_with(10) + client.deadline = 99 + with ( + patch.object(m.time, "monotonic", return_value=100), + self.assertRaisesRegex(RuntimeError, "deadline"), + ): + client.request(m.API) + + def test_detections_and_incomplete_scans_block_with_partial_reports(self): + row = { + "file": "test.apk", + "sha256": "a" * 64, + "url": "https://www.virustotal.com/gui/file/" + "a" * 64, + "stats": {"malicious": 1, "suspicious": 0, "undetected": 60}, + } + with ( + tempfile.TemporaryDirectory() as tmp, + patch.dict(os.environ, {"VIRUSTOTAL_API_KEY": "a" * 64}), + ): + directory = Path(tmp) + (directory / "test.apk").write_bytes(b"APK") + with ( + patch.object(m.Client, "scan", return_value=row), + self.assertRaisesRegex(RuntimeError, "detections"), + ): + m.scan_artifacts(directory) + self.assertIn("| 1 | 0 |", (directory / "VIRUSTOTAL.md").read_text()) + (directory / "z.apk").write_bytes(b"APK") + with ( + patch.object( + m.Client, "scan", side_effect=[row, RuntimeError("timeout")] + ), + self.assertRaisesRegex(RuntimeError, "timeout"), + ): + m.scan_artifacts(directory) + self.assertIn("Scan incomplete", (directory / "VIRUSTOTAL.md").read_text()) + self.assertEqual( + [row], json.loads((directory / "VIRUSTOTAL.json").read_text()) + ) + + def test_invalid_verdicts_and_hash_mismatch_block(self): + with tempfile.TemporaryDirectory() as tmp: + path = Path(tmp) / "message487.apk" + path.write_bytes(b"APK") + for stats, digest in [ + ({}, None), + ({"malicious": 0, "suspicious": 0, "undetected": 0}, None), + ({"malicious": 0, "suspicious": 0, "undetected": -1}, None), + ({"malicious": 0, "suspicious": 0, "undetected": 60}, "wrong"), + ]: + response = { + "data": {"attributes": {"status": "completed", "stats": stats}}, + "meta": {"file_info": {"sha256": digest}}, + } + client = m.Client("a" * 64) + with ( + self.subTest(stats=stats, digest=digest), + patch.object( + client, + "request", + side_effect=[{"data": {"id": "test"}}, response], + ), + self.assertRaises(RuntimeError), + ): + client.scan(path) diff --git a/scripts/virus_total.py b/scripts/virus_total.py new file mode 100644 index 0000000..c52fb6c --- /dev/null +++ b/scripts/virus_total.py @@ -0,0 +1,173 @@ +"""Scan release APKs and write hash-addressed VirusTotal reports.""" + +import argparse +import hashlib +import json +import os +import re +import time +import urllib.error +import urllib.parse +import urllib.request +import uuid +from pathlib import Path + +API = "https://www.virustotal.com/api/v3/" + + +class NoRedirect(urllib.request.HTTPRedirectHandler): + def redirect_request(self, req, fp, code, msg, headers, newurl): + raise RuntimeError( + "VirusTotal unexpectedly redirected an authenticated request" + ) + + +class Client: + def __init__(self, key): + if not re.fullmatch(r"[0-9a-fA-F]{64}", key): + raise RuntimeError("VIRUSTOTAL_API_KEY is missing or invalid") + self.key = key + self.opener = urllib.request.build_opener(NoRedirect()) + self.next_request = 0.0 + self.deadline = time.monotonic() + 1800 + + def request(self, url, body=None, content_type=None): + parsed = urllib.parse.urlsplit(url) + if ( + parsed.scheme != "https" + or parsed.hostname not in ("www.virustotal.com", "bigfiles.virustotal.com") + or parsed.port not in (None, 443) + or parsed.username + or parsed.password + ): + raise RuntimeError("VirusTotal returned an untrusted upload URL") + delay = max(0, self.next_request - time.monotonic()) + if time.monotonic() + delay >= self.deadline: + raise RuntimeError("VirusTotal scan deadline exceeded (30 minutes)") + time.sleep(delay) + self.next_request = time.monotonic() + 16 # Public API: at most 4/minute. + headers = {"x-apikey": self.key} + if content_type: + headers["Content-Type"] = content_type + req = urllib.request.Request(url, data=body, headers=headers) + try: + with self.opener.open( + req, timeout=min(120, self.deadline - time.monotonic()) + ) as response: + return json.load(response) + except urllib.error.HTTPError as error: + raise RuntimeError(f"VirusTotal API failed (HTTP {error.code})") from None + except (urllib.error.URLError, TimeoutError): + raise RuntimeError("VirusTotal network request failed") from None + + def scan(self, path): + content = path.read_bytes() + if not 0 < len(content) <= 650 * 1024 * 1024: + raise RuntimeError("APK size is outside VirusTotal upload limits") + digest = hashlib.sha256(content).hexdigest() + url = API + "files" + if len(content) >= 32 * 1024 * 1024: + url = self.request(API + "files/upload_url")["data"] + boundary = uuid.uuid4().hex + body = ( + ( + f'--{boundary}\r\nContent-Disposition: form-data; name="file"; ' + f'filename="{path.name}"\r\nContent-Type: application/vnd.android.package-archive\r\n\r\n' + ).encode() + + content + + f"\r\n--{boundary}--\r\n".encode() + ) + analysis_id = self.request( + url, body, f"multipart/form-data; boundary={boundary}" + )["data"]["id"] + while True: + response = self.request( + API + "analyses/" + urllib.parse.quote(analysis_id, safe="") + ) + attributes = response["data"]["attributes"] + if attributes["status"] == "completed": + break + if attributes["status"] not in ("queued", "in-progress"): + raise RuntimeError("VirusTotal returned an unknown analysis status") + reported = response.get("meta", {}).get("file_info", {}).get("sha256") + if reported is not None and reported != digest: + raise RuntimeError("VirusTotal analysis hash differs from uploaded APK") + stats = attributes["stats"] + if ( + not stats + or any(type(value) is not int or value < 0 for value in stats.values()) + or not all( + key in stats for key in ("malicious", "suspicious", "undetected") + ) + or sum( + stats.get(k, 0) + for k in ("malicious", "suspicious", "harmless", "undetected") + ) + == 0 + ): + raise RuntimeError("VirusTotal completed without usable engine verdicts") + return { + "file": path.name, + "sha256": digest, + "analysis_id": analysis_id, + "stats": stats, + "url": f"https://www.virustotal.com/gui/file/{digest}", + } + + +def scan_artifacts(directory): + paths = sorted(directory.glob("*.apk")) + if not paths or any(not re.fullmatch(r"[\w.-]+\.apk", p.name) for p in paths): + raise RuntimeError("No release APKs found or invalid APK filename") + client = Client(os.environ.get("VIRUSTOTAL_API_KEY", "").strip()) + rows = [] + complete = False + try: + for path in paths: + print(f"Scanning {path.name}", flush=True) + rows.append(client.scan(path)) + complete = True + finally: + report = "## VirusTotal\n\n" + if not complete: + report += "**Scan incomplete; release publication blocked.**\n\n" + report += ( + "| APK | Malicious | Suspicious | Report |\n| --- | --- | --- | --- |\n" + ) + for row in rows: + stats = row["stats"] + report += f"| {row['file']} | {stats['malicious']} | {stats['suspicious']} | [SHA-256 report]({row['url']}) |\n" + report += "\nCompleted engine verdicts are a point-in-time check, not a guarantee of safety. See each report for unsupported engines, failures and timeouts.\n" + (directory / "VIRUSTOTAL.md").write_text(report) + (directory / "VIRUSTOTAL.json").write_text(json.dumps(rows, indent=2) + "\n") + if summary := os.environ.get("GITHUB_STEP_SUMMARY"): + with open(summary, "a") as output: + output.write(report) + if any(row["stats"]["malicious"] or row["stats"]["suspicious"] for row in rows): + raise RuntimeError("VirusTotal detections require review; publication blocked") + + +def main(): + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--artifacts", type=Path, default=Path("dist/release")) + args = parser.parse_args() + try: + scan_artifacts(args.artifacts) + except ( + RuntimeError, + KeyError, + TypeError, + ValueError, + OSError, + ) as error: + # Malformed JSON/API payloads must not print upload URLs or credentials. + message = ( + str(error) + if isinstance(error, RuntimeError) + else "VirusTotal operation failed" + ) + raise SystemExit(message) from None + + +if __name__ == "__main__": + main()