Skip to content
24 changes: 15 additions & 9 deletions source/addonStore/models/addon.py
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@
from NVDAState import WritePaths

from .channel import Channel
from .scanResults import VirusTotalScanResults
from .status import SupportsAddonState
from .version import (
MajorMinorPatch,
Expand Down Expand Up @@ -114,19 +115,20 @@ class _AddonStoreModel(_AddonGUIModel):
description: str
addonVersionName: str
channel: Channel
homepage: Optional[str]
homepage: str | None
minNVDAVersion: MajorMinorPatch
lastTestedVersion: MajorMinorPatch
legacy: bool
publisher: str
license: str
licenseURL: Optional[str]
licenseURL: str | None
sourceURL: str
URL: str
sha256: str
addonVersionNumber: MajorMinorPatch
reviewURL: Optional[str]
reviewURL: str | None
submissionTime: int | None
scanResults: VirusTotalScanResults | None = None

@property
def tempDownloadPath(self) -> str:
Expand Down Expand Up @@ -260,17 +262,18 @@ class InstalledAddonStoreModel(_AddonManifestModel, _AddonStoreModel):
publisher: str
addonVersionName: str
channel: Channel
homepage: Optional[str]
homepage: str | None
license: str
licenseURL: Optional[str]
licenseURL: str | None
sourceURL: str
URL: str
sha256: str
addonVersionNumber: MajorMinorPatch
minNVDAVersion: MajorMinorPatch
lastTestedVersion: MajorMinorPatch
reviewURL: Optional[str]
reviewURL: str | None
submissionTime: int | None
scanResults: VirusTotalScanResults | None = None
legacy: bool = False
"""
Legacy add-ons contain invalid metadata
Expand All @@ -297,18 +300,19 @@ class AddonStoreModel(_AddonStoreModel):
publisher: str
addonVersionName: str
channel: Channel
homepage: Optional[str]
homepage: str | None
license: str
licenseURL: Optional[str]
licenseURL: str | None
sourceURL: str
URL: str
sha256: str
addonVersionNumber: MajorMinorPatch
minNVDAVersion: MajorMinorPatch
lastTestedVersion: MajorMinorPatch
reviewURL: Optional[str]
reviewURL: str | None
submissionTime: int | None
legacy: bool = False
scanResults: VirusTotalScanResults | None = None
"""
Legacy add-ons contain invalid metadata
and should not be accessible through the add-on store.
Expand Down Expand Up @@ -341,6 +345,7 @@ def _createInstalledStoreModelFromData(addon: Dict[str, Any]) -> InstalledAddonS
lastTestedVersion=MajorMinorPatch(**addon["lastTestedVersion"]),
reviewURL=addon.get("reviewURL"),
submissionTime=addon.get("submissionTime"),
scanResults=VirusTotalScanResults.fromDict(addon),
legacy=addon.get("legacy", False),
)

Expand All @@ -364,6 +369,7 @@ def _createStoreModelFromData(addon: Dict[str, Any]) -> AddonStoreModel:
lastTestedVersion=MajorMinorPatch(**addon["lastTestedVersion"]),
reviewURL=addon.get("reviewUrl"),
submissionTime=addon.get("submissionTime"),
scanResults=VirusTotalScanResults.fromDict(addon),
legacy=addon.get("legacy", False),
)

Expand Down
46 changes: 46 additions & 0 deletions source/addonStore/models/scanResults.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
# A part of NonVisual Desktop Access (NVDA)
# Copyright (C) 2025 NV Access Limited
# This file may be used under the terms of the GNU General Public License, version 2 or later, as modified by the NVDA license.
# For full terms and any additional permissions, see the NVDA license file: https://github.com/nvaccess/nvda/blob/master/copying.txt

from dataclasses import dataclass
from typing import Any


@dataclass(frozen=True)
class VirusTotalScanResults:
scanUrl: str
malicious: int
undetected: int
harmless: int
suspicious: int
failure: int
timeout: int
confirmedTimeout: int
typeUnsupported: int

@classmethod
def fromDict(cls, addon: dict[str, Any]) -> "VirusTotalScanResults | None":
try:
analysisStats = addon["scanResults"]["virusTotal"][0]["last_analysis_stats"]
Comment thread
seanbudd marked this conversation as resolved.
return cls(
scanUrl=addon["vtScanUrl"],
malicious=analysisStats["malicious"],
undetected=analysisStats["undetected"],
harmless=analysisStats["harmless"],
suspicious=analysisStats["suspicious"],
failure=analysisStats["failure"],
timeout=analysisStats["timeout"],
confirmedTimeout=analysisStats["confirmed-timeout"],
typeUnsupported=analysisStats["type-unsupported"],
)
except KeyError:
return None

@property
def totalScans(self) -> int:
return self.malicious + self.undetected + self.harmless + self.suspicious

@property
def totalFlagged(self) -> int:
return self.malicious + self.suspicious
24 changes: 24 additions & 0 deletions source/gui/addonStoreGui/controls/details.py
Original file line number Diff line number Diff line change
Expand Up @@ -362,6 +362,30 @@ def _refresh(self):
pgettext("addonStore", "Publication date:"),
details.publicationDate,
)

if isinstance(details, _AddonStoreModel):
if details.scanResults is not None:
self._appendDetailsLabelValue(
# Translators: Label for an extra detail field for the selected add-on. In the add-on store dialog.
pgettext("addonStore", "VirusTotal scan results:"),
pgettext(
"addonStore",
# Translators: Summary of VirusTotal scan results for the selected add-on.
# {malicious} is the number of vendors that detected the add-on as malicious,
# {total} is the total number of vendors that scanned the add-on.
# In the add-on store dialog.
"{malicious} out of {total} malware scanners detected this add-on as malicious.",
Comment thread
seanbudd marked this conversation as resolved.
Outdated
).format(
malicious=details.scanResults.totalFlagged,
total=details.scanResults.totalScans,
),
)
self._appendDetailsLabelValue(
# Translators: Label for an extra detail field for the selected add-on. In the add-on store dialog.
pgettext("addonStore", "VirusTotal scan URL:"),
details.scanResults.scanUrl,
)

self.contentsPanel.Show()

self.Layout()
Expand Down
8 changes: 8 additions & 0 deletions source/gui/addonStoreGui/viewModels/store.py
Original file line number Diff line number Diff line change
Expand Up @@ -259,6 +259,14 @@ def _makeActionsList(self):
),
actionTarget=selectedListItem,
),
AddonActionVM(
# Translators: Label for an action that opens the VirusTotal scan results for the selected addon
displayName=pgettext("addonStore", "VirusTotal scan results"),
actionHandler=lambda aVM: startfile(cast(_AddonStoreModel, aVM.model).scanResults.scanUrl),
Comment thread
seanbudd marked this conversation as resolved.
validCheck=lambda aVM: isinstance(aVM.model, _AddonStoreModel)
and aVM.model.scanResults is not None,
actionTarget=selectedListItem,
),
]

def helpAddon(self, listItemVM: AddonListItemVM) -> None:
Expand Down
2 changes: 2 additions & 0 deletions user_docs/en/changes.md
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,8 @@ This can be enabled using the "Report when lists support multiple selection" set
* In Visual Studio Code, the status bar is now reported when using the standard `NVDA+end` (desktop) / `NVDA+shift+end` (laptop) gesture. (#11064, @codeofdusk)
* Performance improvements on ARM64 systems, such as with Qualcomm processors. (#18570, @leonarddeR)
* While reading text, spelling errors can now be reported with a sound instead of speech. (#4233, @jcsteh, @CyrilleB79)
* VirusTotal scan results are now available in the details for an add-on in the Add-on Store.
An action has been added to view the full scan results on VirusTotal. (#18974)
Comment thread
seanbudd marked this conversation as resolved.
Outdated

### Changes

Expand Down
29 changes: 25 additions & 4 deletions user_docs/en/userGuide.md
Original file line number Diff line number Diff line change
Expand Up @@ -3919,6 +3919,28 @@ If you install an add-on with paid components and change your mind about using i
The Add-on Store is accessed from the Tools submenu of the NVDA menu.
To access the Add-on Store from anywhere, assign a custom gesture using the [Input Gestures dialog](#InputGestures).

### Security warning for add-ons {#AddonStoreSecurityWarning}

Add-ons listed in the Add-on Store have not been vetted by NV Access or anyone else.
It is very important to only install add-ons from sources you trust.
The functionality of add-ons is unrestricted inside NVDA.
This could include accessing and modifying your personal data or even the entire system.

Add-ons submitted to the Add-on Store are scanned by [VirusTotal](https://www.virustotal.com/).
This can detect known malware from when the add-on was submitted.
Comment thread
seanbudd marked this conversation as resolved.
Outdated
However, VirusTotal results may be inaccurate or out of date.
For example, an add-on that is marked as malicious might not be malicious, and an add-on that is not marked as malicious might actually be malicious.
You can view a summary of the scan results for a scanned add-on by reviewing the add-on's details.
You can view the VirusTotal scan results directly using the "VirusTotal scan results" [action from the context menu](#AddonStoreActions).

There are a number of other ways of investigating the safety of an add-on:

* Research the developer's reputation (e.g. how long have they been contributing)
* Look for user feedback in the [NVDA user group](https://groups.google.com/a/nvaccess.org/g/nvda-users) or the [NVDA add-on group](https://nvda-addons.groups.io/g/nvda-addons)
* Verify that the add-on is regularly updated
* Read [Community reviews](#AddonStoreReviews) of the add-on
* Seek community feedback through forums or social media

### Browsing add-ons {#AddonStoreBrowsing}

When opened, the Add-on Store displays a list of add-ons.
Expand Down Expand Up @@ -3986,10 +4008,9 @@ This menu can also be accessed through an Actions button in the selected add-on'

#### Installing add-ons {#AddonStoreInstalling}

Just because an add-on is available in the NVDA Add-on Store, does not mean that it has been approved or vetted by NV Access or anyone else.
It is very important to only install add-ons from sources you trust.
The functionality of add-ons is unrestricted inside NVDA.
This could include accessing your personal data or even the entire system.
Note: Add-ons are programs that can modify and access your device like any other software you download.
Make sure to only install add-ons from sources you trust.
Please read [our security warning](#AddonStoreSecurityWarning) for more details before installing add-ons.

You can install and update add-ons by [browsing Available add-ons](#AddonStoreBrowsing).
Select an add-on from the "Available add-ons" or "Updatable add-ons" tab.
Expand Down
Loading