Skip to content

Commit dedc7cd

Browse files
authored
Create action to virus scan all add-ons (#4302)
VirusTotal scanning has been enabled for newly submitted add-ons. This PR adds a GitHub action to scan all already submitted add-ons with VirusTotal. When the scan runs, a PR is opened to add scan URLs to add-on metadata, and update the list of approved add-ons which have Virus scanning flagged.
1 parent 8c60585 commit dedc7cd

File tree

6 files changed

+309
-44
lines changed

6 files changed

+309
-44
lines changed

.github/workflows/checkAndSubmitAddonMetadata.yml

Lines changed: 32 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -66,7 +66,7 @@ jobs:
6666
script: |
6767
const addonId = "${{ steps.getAddonId.outputs.result }}"
6868
return addonId.replace(/[^a-zA-Z0-9]/g, "")
69-
- name: Copy add-on metadata file
69+
- name: Copy add-on metadata file
7070
run: |
7171
Copy-Item ${{ steps.getAddonFileName.outputs.result }} addonMetadata.json
7272
- name: Upload add-on
@@ -155,6 +155,7 @@ jobs:
155155
issues: write
156156
outputs:
157157
pullRequestNumber: ${{ steps.cpr.outputs.pull-request-number }}
158+
addonFileName: ${{ steps.getAddonFileName.outputs.addonFileName }}
158159
steps:
159160
- name: Checkout code
160161
uses: actions/checkout@v4
@@ -188,6 +189,7 @@ jobs:
188189
repository: nvaccess/addon-datastore-validation
189190
path: validation
190191
submodules: true
192+
ref: addVtScanUrl
191193
- name: Install addon-datastore-validation dependencies
192194
run: |
193195
python -m pip install --upgrade wheel
@@ -240,13 +242,20 @@ jobs:
240242
issues: write
241243
env:
242244
VT_API_KEY: ${{ secrets.VT_API_KEY }}
245+
VT_API_LIMIT: ${{ vars.VT_API_LIMIT }}
246+
outputs:
247+
vtScanUrl: ${{ steps.setVirusTotalAnalysisStatus.outputs.vtScanUrl }}
243248
steps:
244249
- name: Checkout repository
245250
uses: actions/checkout@v4
246251
- name: Download add-on metadata
247252
uses: actions/download-artifact@v4
248253
with:
249254
name: addonMetadata
255+
- name: Install Node.js
256+
uses: actions/setup-node@v2
257+
- name: Install glob
258+
run: npm install glob
250259
- name: Install virusTotal
251260
run: choco install vt-cli
252261
- name: Set Virus Total analysis status
@@ -255,7 +264,7 @@ jobs:
255264
with:
256265
script: |
257266
const setVirusTotalAnalysisStatus = require('./.github/workflows/virusTotalAnalysis.js')
258-
setVirusTotalAnalysisStatus({core})
267+
setVirusTotalAnalysisStatus({core}, "${{ needs.createPullRequest.outputs.getAddonFileName }}")
259268
- name: Upload results
260269
id: uploadResults
261270
if: failure()
@@ -279,7 +288,7 @@ jobs:
279288
issue-number: ${{ inputs.issueNumber }}
280289
body: |
281290
VirusTotal has flagged this add-on as malicious.
282-
You can open this link and [see the results of the analysis](${{ steps.setVirusTotalAnalysisStatus.outputs.analysisUrl }}).
291+
You can open this link and [see the results of the analysis](${{ steps.setVirusTotalAnalysisStatus.outputs.vtScanUrl }}).
283292
Please contact the flagged security vendors to get them to review and unflag the false positive.
284293
Please ask here or email [email protected] if you need assistance with this process.
285294
codeQL-analysis:
@@ -313,7 +322,7 @@ jobs:
313322
commit-message: Add reviewed add-on (${{ needs.getAddonId.outputs.addonId }})
314323
body: |
315324
This add-on needs to be reviewed by NV Access due to analysis failure.
316-
Review ${{ inputs.issueNumber }} for more information.
325+
Review #${{ inputs.issueNumber }} for more information.
317326
author: github-actions <[email protected]>
318327
delete-branch: true
319328
- name: Request to keep issue opened
@@ -340,12 +349,12 @@ jobs:
340349
GH_TOKEN: ${{ github.token }}
341350
run: |
342351
gh pr merge ${{ inputs.issueAuthorName }}${{ inputs.issueNumber }} -b '[Automated] Merged ${{ needs.getAddonId.outputs.addonFileName }} into master (PR #${{ needs.createPullRequest.outputs.pullRequestNumber }})' -m
343-
352+
344353
createReviewComment:
345354
# jq for windows has issues parsing multiline strings (e.g. CRLF),
346355
# use linux instead.
347356
runs-on: ubuntu-latest
348-
needs: [getAddonId, mergeToMaster]
357+
needs: [getAddonId, mergeToMaster, virusTotal-analysis]
349358
strategy:
350359
matrix:
351360
python-version: [ 3.11 ]
@@ -399,13 +408,13 @@ jobs:
399408
.[\"$addonId\"].discussionId = \"$discussionId\"
400409
| .[\"$addonId\"].discussionUrl = \"$discussionUrl\"
401410
"
402-
411+
403412
mv discussions.json discussions.old.json
404413
jq -e --tab "$jqCode" discussions.old.json > discussions.json
405414
jqExitCode=$?
406415
rm discussions.old.json
407416
exit $jqExitCode
408-
- name: Add discussion URL to metadata
417+
- name: Add discussion and VT scan URL to metadata
409418
if: always()
410419
run: |
411420
addonFilename=$(
@@ -415,17 +424,26 @@ jobs:
415424
echo ${{ needs.getAddonId.outputs.addonId }}
416425
)
417426
reviewUrl=$(
418-
jq ".\"$addonId\".discussionUrl" discussions.json
427+
jq --tab ".\"$addonId\".discussionUrl" discussions.json
419428
)
420-
jqCode="
429+
vtScanUrl=$(
430+
echo ${{ needs.virusTotal-analysis.outputs.vtScanUrl }}
431+
)
432+
jqReviewCode="
421433
.[\"reviewUrl\"] = $reviewUrl
422434
"
423-
435+
jqVTCode="
436+
.[\"reviewUrl\"] = $reviewUrl
437+
"
438+
424439
mv $addonFilename $addonFilename.old.json
425-
jq -e --tab "$jqCode" $addonFilename.old.json > $addonFilename
426-
jqExitCode=$?
440+
jq -e --tab "$jqReviewCode" $addonFilename.old.json > $addonFilename
441+
jqReviewExitCode=$?
442+
mv $addonFilename $addonFilename.old.json
443+
jq -e --tab "$jqVTCode" $addonFilename.old.json > $addonFilename
444+
jqVTExitCode=$?
427445
rm $addonFilename.old.json
428-
exit $jqExitCode
446+
exit !(( $jqVTExitCode || $jqReviewExitCode ))
429447
- name: Commit and push
430448
if: always()
431449
run: |

.github/workflows/securityAnalysis.js

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -23,7 +23,7 @@ module.exports = ({core}, path) => {
2323
reviewedAddonsData[addonId] = [];
2424
}
2525
reviewedAddonsData[addonId].push(sha256);
26-
const stringified = JSON.stringify(reviewedAddonsData, null, 2);
26+
const stringified = JSON.stringify(reviewedAddonsData, null, "\t");
2727
fs.writeFileSync('reviewedAddons.json', stringified);
2828
core.setFailed("Security analysis failed");
2929
};
Lines changed: 68 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,68 @@
1+
name: Scan a batch of submitted add-ons with Virus Total
2+
3+
on:
4+
workflow_dispatch:
5+
6+
jobs:
7+
virusTotal-analysis:
8+
runs-on: windows-latest
9+
strategy:
10+
matrix:
11+
python-version: [ 3.11 ]
12+
permissions:
13+
contents: write
14+
pull-requests: write
15+
env:
16+
VT_API_KEY: ${{ secrets.VT_API_KEY }}
17+
VT_API_LIMIT: ${{ vars.VT_API_LIMIT }}
18+
steps:
19+
- name: Checkout repository
20+
uses: actions/checkout@v4
21+
with:
22+
ref: ${{ inputs.headRef }}
23+
- name: Install virusTotal
24+
run: choco install vt-cli
25+
- name: Install Node.js
26+
uses: actions/setup-node@v2
27+
- name: Install npm dependencies
28+
run: npm install glob uuid
29+
- name: Submit add-ons with VirusTotal
30+
uses: actions/github-script@v7
31+
with:
32+
script: |
33+
const virusTotalSubmit = require('./.github/workflows/virusTotalSubmit.js')
34+
virusTotalSubmit({core}, "./addons/*/*.json")
35+
- name: Set Virus Total analysis status
36+
if: always()
37+
id: setVirusTotalAnalysisStatus
38+
uses: actions/github-script@v7
39+
with:
40+
script: |
41+
const setVirusTotalAnalysisStatus = require('./.github/workflows/virusTotalAnalysis.js')
42+
setVirusTotalAnalysisStatus({core}, "./addons/*/*.json")
43+
- name: Create PR for updated VT urls
44+
if: always()
45+
uses: peter-evans/create-pull-request@v6
46+
with:
47+
title: Add VirusTotal review URLs
48+
branch: addVTURLs${{ github.run_number }}
49+
commit-message: Add VirusTotal review URLs
50+
body: "Add VirusTotal review URLs to add-ons"
51+
author: github-actions <[email protected]>
52+
add-paths: 'addons/*/*.json'
53+
- name: Upload results
54+
id: uploadResults
55+
if: failure()
56+
uses: actions/upload-artifact@v4
57+
with:
58+
name: VirusTotal
59+
path: vt.json
60+
overwrite: true
61+
- name: Upload manual approval
62+
id: uploadManualApproval
63+
if: failure()
64+
uses: actions/upload-artifact@v4
65+
with:
66+
name: manualApproval
67+
path: reviewedAddons.json
68+
overwrite: true
Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
function sleep(sleepTimeMs) {
2+
/* Sleep for sleepTimeMs milliseconds.
3+
Atomics.wait waits a timeout of sleepTimeMs.
4+
https://stackoverflow.com/a/56406126/8030743
5+
*/
6+
Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, sleepTimeMs);
7+
}
8+
9+
10+
module.exports = ({core}) => {
11+
if (core._apiUsageCount >= Number(process.env.VT_API_LIMIT)) {
12+
core.info("VirusTotal API usage limit reached");
13+
throw new Error("VirusTotal API usage limit reached");
14+
}
15+
// Sleep 20 seconds to avoid rate limiting
16+
sleep(20 * 1000);
17+
core._apiUsageCount++;
18+
}
Lines changed: 82 additions & 29 deletions
Original file line numberDiff line numberDiff line change
@@ -1,38 +1,91 @@
1-
module.exports = ({core}) => {
2-
const fs = require('fs');
3-
const { exec } = require('child_process');
4-
const addonMetadataContents = fs.readFileSync('addonMetadata.json');
5-
const addonMetadata = JSON.parse(addonMetadataContents);
6-
const addonId = addonMetadata.addonId;
7-
core.setOutput('addonId', addonId);
8-
const sha256 = addonMetadata.sha256;
9-
const analysisUrl = `https://www.virustotal.com/gui/file/${sha256}`;
10-
console.log(analysisUrl);
11-
core.setOutput('analysisUrl', analysisUrl);
12-
const reviewedAddonsContents = fs.readFileSync('reviewedAddons.json');
13-
const reviewedAddonsData = JSON.parse(reviewedAddonsContents);
14-
if (reviewedAddonsData[addonId] !== undefined && reviewedAddonsData[addonId].includes(sha256)) {
15-
core.info('VirusTotal analysis skipped');
16-
return;
17-
}
18-
exec(`vt file ${sha256} -k ${process.env.VT_API_KEY} --format json`, (err, stdout, stderr) => {
19-
console.log(`err: ${err}`);
20-
console.log(`stdout: ${stdout}`);
21-
console.log(`stderr: ${stderr}`);
1+
const glob = require("glob");
2+
const fs = require("fs");
3+
const { exec } = require("child_process");
4+
const countAPIUsageAndWait = require("./virusTotalAPISleepAndCount");
5+
6+
7+
function writeVTScanUrl({core}, metadataFile, addonMetadata) {
8+
const vtScanUrl = `https://www.virustotal.com/gui/file/${addonMetadata.sha256}`;
9+
addonMetadata.vtScanUrl = vtScanUrl;
10+
stringified = JSON.stringify(addonMetadata, null, "\t");
11+
// Write vtScanUrl to add-on metadata file
12+
fs.writeFileSync(metadataFile, stringified);
13+
// Store the latest vtScanUrl for single file analysis
14+
core.setOutput("vtScanUrl", vtScanUrl);
15+
}
16+
17+
18+
function getVirusTotalAnalysis({core}, addonMetadata, metadataFile, reviewedAddonsData) {
19+
/*
20+
Get the VirusTotal analysis for the add-on file.
21+
If the add-on is flagged as malicious, store the sha256 hash in reviewedAddons.json.
22+
Always store the scan URL in the add-on metadata file.
23+
If Virus total fails to scan the add-on, fail the job.
24+
*/
25+
countAPIUsageAndWait({core});
26+
exec(`vt file ${addonMetadata.sha256} -k ${process.env.VT_API_KEY} --format json`, (err, stdout, stderr) => {
27+
if (stderr !== "" || err !== null) {
28+
console.log(`err: ${err}`);
29+
console.log(`stdout: ${stdout}`);
30+
console.log(`stderr: ${stderr}`);
31+
if (core._isSingleFileAnalysis) {
32+
core.setFailed("Failed to get VirusTotal analysis");
33+
}
34+
return;
35+
}
36+
writeVTScanUrl({core}, metadataFile, addonMetadata);
37+
// Append the VirusTotal analysis to the file for an artifact
2238
const vtData = JSON.parse(stdout);
23-
fs.writeFileSync('vt.json', stdout);
39+
fs.appendFileSync("vt.json", stdout);
2440
const stats = vtData[0]["last_analysis_stats"];
2541
const malicious = stats.malicious;
2642
if (malicious === 0) {
27-
core.info('VirusTotal analysis succeeded');
43+
core.info("VirusTotal analysis succeeded");
2844
return;
2945
}
30-
if (reviewedAddonsData[addonId] === undefined) {
31-
reviewedAddonsData[addonId] = [];
46+
if (reviewedAddonsData[addonMetadata.addonId] === undefined) {
47+
reviewedAddonsData[addonMetadata.addonId] = [];
48+
}
49+
reviewedAddonsData[addonMetadata.addonId].push(addonMetadata.sha256);
50+
stringified = JSON.stringify(reviewedAddonsData, null, "\t");
51+
fs.writeFileSync("reviewedAddons.json", stringified);
52+
if (core._isSingleFileAnalysis) {
53+
core.setFailed("VirusTotal analysis failed");
3254
}
33-
reviewedAddonsData[addonId].push(sha256);
34-
stringified = JSON.stringify(reviewedAddonsData, null, 2);
35-
fs.writeFileSync('reviewedAddons.json', stringified);
36-
core.setFailed('VirusTotal analysis failed');
55+
});
56+
}
57+
58+
59+
function getVirusTotalAnalysisIfRequired({core}, metadataFile) {
60+
/*
61+
If we have scanned and stored the VirusTotal analysis for the add-on before,
62+
skip the analysis. Otherwise, get the VirusTotal analysis and store the URL
63+
in the add-on metadata.
64+
*/
65+
const addonMetadataContents = fs.readFileSync(metadataFile);
66+
const addonMetadata = JSON.parse(addonMetadataContents);
67+
const addonId = addonMetadata.addonId;
68+
const reviewedAddonsContents = fs.readFileSync("reviewedAddons.json");
69+
const reviewedAddonsData = JSON.parse(reviewedAddonsContents);
70+
// Check if add-on has been flagged before through VirusTotal.
71+
if (reviewedAddonsData[addonId] !== undefined && reviewedAddonsData[addonId].includes(sha256)) {
72+
core.info("VirusTotal analysis skipped, already performed");
73+
return;
74+
}
75+
// Check if add-on has been scanned before through VirusTotal.
76+
if (addonMetadata.vtScanUrl !== undefined) {
77+
core.info("VirusTotal analysis skipped, already performed");
78+
return;
79+
}
80+
getVirusTotalAnalysis({core}, addonMetadata, metadataFile, reviewedAddonsData);
81+
}
82+
83+
module.exports = ({core}, globPattern) => {
84+
var metadataFiles = glob.globSync(globPattern);
85+
// Count API usages to adhere to rate limiting
86+
core._apiUsageCount = 0;
87+
core._isSingleFileAnalysis = metadataFiles.length == 1;
88+
metadataFiles.forEach(metadataFile => {
89+
getVirusTotalAnalysisIfRequired({core}, metadataFile);
3790
});
3891
};

0 commit comments

Comments
 (0)