repomatic.release.virustotal module

Upload release binaries to VirusTotal and record detection snapshots.

Submits compiled binaries (.bin, .exe) to the VirusTotal API for malware scanning. This seeds antivirus vendor databases with the signatures of freshly built binaries, which keeps false-positive rates in check for downstream distributors.

Detection statistics polled after an upload are appended to a CSV history, one record per binary per scan date. The sync-binaries command renders that history into the binaries catalog page (docs/binaries.md).

Note

Scan results are deliberately kept out of GitHub release notes: a raw flagged / total count next to a download link reads as a malware verdict to visitors, when it is almost always Nuitka onefile false positives. See kdeldycke/meta-package-manager#1911 for the confusion this caused. The catalog page provides the context release notes cannot.

Note

The free-tier API allows 4 requests per minute. All API calls (uploads and polls) are rate-limited with a sleep between each request.

repomatic.release.virustotal.FREE_TIER_RATE_LIMIT = 4

VirusTotal free-tier request budget, in API calls per minute.

The single source for the upload and polling pace: the scan-virustotal CLI default and both client functions below derive from it.

repomatic.release.virustotal.SCAN_HEADERS = ('tag', 'filename', 'sha256', 'scanned', 'malicious', 'suspicious', 'undetected', 'harmless')

Columns of the committed scan history, in file order.

The release and the file it identifies first, then the four verdict counts, so the table reads left to right from what was scanned to what came back. Rows are ordered by release version rather than alphabetically, which a plain sort of the tag strings would get wrong past v9.

repomatic.release.virustotal.VIRUSTOTAL_GUI_URL = 'https://www.virustotal.com/gui/file/{sha256}'

URL template for the VirusTotal file analysis page.

class repomatic.release.virustotal.DetectionStats(malicious, suspicious, undetected, harmless)[source]

Bases: object

Detection statistics from a completed VirusTotal analysis.

Stores only the four categories that constitute a definitive verdict. type-unsupported, timeout, and failure from the API response are excluded from the total.

malicious: int

Number of engines that flagged the file as malicious.

suspicious: int

Number of engines that flagged the file as suspicious.

undetected: int

Number of engines that found no threat.

harmless: int

Number of engines that classified the file as harmless.

property flagged: int

Total engines that flagged the file (malicious + suspicious).

property total: int

Total engines that produced a definitive verdict.

class repomatic.release.virustotal.ScanResult(filename, sha256, analysis_url, detection_stats=None)[source]

Bases: object

Result of uploading a single file to VirusTotal.

filename: str

Original filename of the uploaded binary.

sha256: str

SHA-256 hash of the file content.

analysis_url: str

VirusTotal web GUI URL for the file analysis.

detection_stats: DetectionStats | None = None

Detection statistics, or None if analysis is still pending.

class repomatic.release.virustotal.ScanRecord(tag, filename, sha256, scanned, stats)[source]

Bases: object

A detection snapshot for one binary, taken on a given date.

Records accumulate in a CSV history (see upsert_scan_records()) committed to the repository. Each record freezes the flagged / total verdict counts at scan time, so the history supports trend analysis across releases even after VirusTotal re-analyzes the files or vendors process false-positive reports.

tag: str

Git tag of the release the binary belongs to (e.g. v1.2.3).

filename: str

Filename of the scanned binary.

sha256: str

SHA-256 hash of the file content.

scanned: str

Scan date in YYYY-MM-DD format.

stats: DetectionStats

Detection statistics at scan time.

property key: tuple[str, str]

Deduplication identity: the same file scanned on the same day.

as_row()[source]

Flatten to one CSV row, in SCAN_HEADERS order.

Return type:

tuple[str, ...]

classmethod from_row(data)[source]

Rebuild a record from one parsed CSV row, or a legacy JSON mapping.

Both shapes carry the same eight keys, so one reader covers a store mid-migration as well as one already converted.

Return type:

ScanRecord

repomatic.release.virustotal.scan_files(api_key, file_paths, rate_limit=4)[source]

Upload files to VirusTotal and return scan results.

Uses the synchronous vt.Client API. Sleeps between uploads to respect the free-tier rate limit.

Parameters:
  • api_key (str) – VirusTotal API key.

  • file_paths (list[Path]) – Paths to binary files to upload.

  • rate_limit (int) – Maximum requests per minute (free tier: 4).

Return type:

list[ScanResult]

Returns:

List of scan results with analysis URLs.

repomatic.release.virustotal.poll_detection_stats(api_key, results, rate_limit=4, timeout=600)[source]

Poll VirusTotal for detection statistics of previously uploaded files.

Queries GET /files/{sha256} for each file until analysis completes or the timeout is reached. Respects the free-tier rate limit for all API calls.

Parameters:
  • api_key (str) – VirusTotal API key.

  • results (list[ScanResult]) – Scan results from a previous upload.

  • rate_limit (int) – Maximum API requests per minute (shared with uploads).

  • timeout (int) – Maximum seconds to wait for all analyses to complete.

Return type:

list[ScanResult]

Returns:

Results with detection_stats populated (or None for files whose analysis did not complete before the timeout).

repomatic.release.virustotal.records_from_results(results, tag, scanned=None)[source]

Build history records from scan results whose analysis completed.

Results still pending (no detection statistics) are skipped: a record without verdict counts carries no information the release assets don’t already provide.

Parameters:
  • results (list[ScanResult]) – Scan results, typically from poll_detection_stats().

  • tag (str) – Git tag of the release the binaries belong to.

  • scanned (str | None) – Snapshot date in YYYY-MM-DD format. Today (UTC) when None.

Return type:

list[ScanRecord]

Returns:

One record per result with detection statistics.

repomatic.release.virustotal.records_from_release_notes(body, tag, scanned)[source]

Recover detection snapshots from a legacy release-notes table.

Before the scan history file existed, the release pipeline appended a VirusTotal table to GitHub release notes, with a flagged / total Detections cell frozen minutes after publication. Those cells are genuine at-release snapshots, so sync-binaries --backfill-records harvests them to seed the history for releases that predate the file.

Note

The legacy table only recorded the flagged and total aggregates, not the malicious/suspicious/undetected/harmless split. The split is rebuilt as flagged = malicious and the remainder = undetected, which is lossless for everything the catalog consumes (flagged and total).

Parameters:
  • body (str) – Release notes markdown.

  • tag (str) – Git tag of the release.

  • scanned (str) – Snapshot date, normally the release publication date.

Return type:

list[ScanRecord]

Returns:

One record per table row carrying a numeric Detections cell.

repomatic.release.virustotal.load_scan_records(path)[source]

Load scan records from the CSV history file.

Note

A repository whose history predates the CSV store carries the same records in a sibling .json, and is read from there when the CSV is absent. The next upsert_scan_records() write lands as CSV, so a repository migrates on its first release after upgrading without anyone converting anything. The stale .json is then inert and can be deleted.

Parameters:

path (Path) – Path to the CSV file.

Return type:

list[ScanRecord]

Returns:

The records, or an empty list when neither file exists.

Raises:

ValueError – When a file exists but cannot be parsed. Loud on purpose: a corrupt history must never be silently clobbered by the next upsert_scan_records() write.

repomatic.release.virustotal.upsert_scan_records(path, new_records)[source]

Merge new records into the CSV history at path.

Records sharing the same (sha256, scanned) identity are replaced, so re-running a scan the same day is idempotent. The file is created (with its parent directories) when missing, and always rewritten in normalized form, sorted by version, filename and scan date.

CSV also keeps the store out of the autofix lane: nothing there rewrites a .csv, where a committed JSON file has to match whatever layout Biome is configured for or format-json reformats it right back. See claude.md § Naming conventions rule 8 for the rest of that reasoning.

Parameters:
  • path (Path) – Path to the CSV history.

  • new_records (list[ScanRecord]) – Records to merge in.

Return type:

bool

Returns:

True when the file content changed.