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
20 changes: 18 additions & 2 deletions .github/workflows/release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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"
11 changes: 11 additions & 0 deletions docs/en/release-automation.md
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
11 changes: 11 additions & 0 deletions docs/ru/release-automation.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
10 changes: 8 additions & 2 deletions fastlane/Fastfile
Original file line number Diff line number Diff line change
Expand Up @@ -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")
Expand Down Expand Up @@ -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")
Expand Down
155 changes: 155 additions & 0 deletions scripts/tests/test_virus_total.py
Original file line number Diff line number Diff line change
@@ -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)
Loading
Loading