Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Create action to virus scan all add-ons #4302

Merged
merged 12 commits into from
Sep 29, 2024
43 changes: 30 additions & 13 deletions .github/workflows/checkAndSubmitAddonMetadata.yml
Original file line number Diff line number Diff line change
Expand Up @@ -66,7 +66,7 @@ jobs:
script: |
const addonId = "${{ steps.getAddonId.outputs.result }}"
return addonId.replace(/[^a-zA-Z0-9]/g, "")
- name: Copy add-on metadata file
- name: Copy add-on metadata file
run: |
Copy-Item ${{ steps.getAddonFileName.outputs.result }} addonMetadata.json
- name: Upload add-on
Expand Down Expand Up @@ -155,6 +155,7 @@ jobs:
issues: write
outputs:
pullRequestNumber: ${{ steps.cpr.outputs.pull-request-number }}
addonFileName: ${{ steps.getAddonFileName.outputs.addonFileName }}
steps:
- name: Checkout code
uses: actions/checkout@v4
Expand Down Expand Up @@ -188,6 +189,7 @@ jobs:
repository: nvaccess/addon-datastore-validation
path: validation
submodules: true
ref: addVtScanUrl
- name: Install addon-datastore-validation dependencies
run: |
python -m pip install --upgrade wheel
Expand Down Expand Up @@ -240,13 +242,19 @@ jobs:
issues: write
env:
VT_API_KEY: ${{ secrets.VT_API_KEY }}
outputs:
vtScanUrl: ${{ steps.setVirusTotalAnalysisStatus.outputs.vtScanUrl }}
steps:
- name: Checkout repository
uses: actions/checkout@v4
- name: Download add-on metadata
uses: actions/download-artifact@v4
with:
name: addonMetadata
- name: Install Node.js
uses: actions/setup-node@v2
- name: Install glob
run: npm install glob
- name: Install virusTotal
run: choco install vt-cli
- name: Set Virus Total analysis status
Expand All @@ -255,7 +263,7 @@ jobs:
with:
script: |
const setVirusTotalAnalysisStatus = require('./.github/workflows/virusTotalAnalysis.js')
setVirusTotalAnalysisStatus({core})
setVirusTotalAnalysisStatus({core}, "${{ needs.createPullRequest.outputs.getAddonFileName }}")
- name: Upload results
id: uploadResults
if: failure()
Expand All @@ -279,7 +287,7 @@ jobs:
issue-number: ${{ inputs.issueNumber }}
body: |
VirusTotal has flagged this add-on as malicious.
You can open this link and [see the results of the analysis](${{ steps.setVirusTotalAnalysisStatus.outputs.analysisUrl }}).
You can open this link and [see the results of the analysis](${{ steps.setVirusTotalAnalysisStatus.outputs.vtScanUrl }}).
Please contact the flagged security vendors to get them to review and unflag the false positive.
Please ask here or email [email protected] if you need assistance with this process.
codeQL-analysis:
Expand Down Expand Up @@ -313,7 +321,7 @@ jobs:
commit-message: Add reviewed add-on (${{ needs.getAddonId.outputs.addonId }})
body: |
This add-on needs to be reviewed by NV Access due to analysis failure.
Review ${{ inputs.issueNumber }} for more information.
Review #${{ inputs.issueNumber }} for more information.
author: github-actions <[email protected]>
delete-branch: true
- name: Request to keep issue opened
Expand All @@ -340,12 +348,12 @@ jobs:
GH_TOKEN: ${{ github.token }}
run: |
gh pr merge ${{ inputs.issueAuthorName }}${{ inputs.issueNumber }} -b '[Automated] Merged ${{ needs.getAddonId.outputs.addonFileName }} into master (PR #${{ needs.createPullRequest.outputs.pullRequestNumber }})' -m

createReviewComment:
# jq for windows has issues parsing multiline strings (e.g. CRLF),
# use linux instead.
runs-on: ubuntu-latest
needs: [getAddonId, mergeToMaster]
needs: [getAddonId, mergeToMaster, virusTotal-analysis]
strategy:
matrix:
python-version: [ 3.11 ]
Expand Down Expand Up @@ -399,13 +407,13 @@ jobs:
.[\"$addonId\"].discussionId = \"$discussionId\"
| .[\"$addonId\"].discussionUrl = \"$discussionUrl\"
"

mv discussions.json discussions.old.json
jq -e "$jqCode" discussions.old.json > discussions.json
jqExitCode=$?
rm discussions.old.json
exit $jqExitCode
- name: Add discussion URL to metadata
- name: Add discussion and VT scan URL to metadata
if: always()
run: |
addonFilename=$(
Expand All @@ -417,15 +425,24 @@ jobs:
reviewUrl=$(
jq ".\"$addonId\".discussionUrl" discussions.json
)
jqCode="
vtScanUrl=$(
echo ${{ needs.virusTotal-analysis.outputs.vtScanUrl }}
)
jqReviewCode="
.[\"reviewUrl\"] = $reviewUrl
"

jqVTCode="
.[\"reviewUrl\"] = $reviewUrl
"

mv $addonFilename $addonFilename.old.json
jq -e -a "$jqCode" $addonFilename.old.json > $addonFilename
jqExitCode=$?
jq -e -a "$jqReviewCode" $addonFilename.old.json > $addonFilename
jqReviewExitCode=$?
mv $addonFilename $addonFilename.old.json
jq -e -a "$jqVTCode" $addonFilename.old.json > $addonFilename
jqVTExitCode=$?
rm $addonFilename.old.json
exit $jqExitCode
exit !(( $jqVTExitCode || $jqReviewExitCode ))
- name: Commit and push
if: always()
run: |
Expand Down
2 changes: 1 addition & 1 deletion .github/workflows/securityAnalysis.js
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,7 @@ module.exports = ({core}, path) => {
reviewedAddonsData[addonId] = [];
}
reviewedAddonsData[addonId].push(sha256);
const stringified = JSON.stringify(reviewedAddonsData, null, 2);
const stringified = JSON.stringify(reviewedAddonsData, null, "\t");
fs.writeFileSync('reviewedAddons.json', stringified);
core.setFailed("Security analysis failed");
};
66 changes: 66 additions & 0 deletions .github/workflows/virusScanAllAddons.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,66 @@
name: Scan all submitted add-ons with Virus Total
seanbudd marked this conversation as resolved.
Show resolved Hide resolved

on:
workflow_dispatch:

jobs:
virusTotal-analysis:
seanbudd marked this conversation as resolved.
Show resolved Hide resolved
runs-on: windows-latest
strategy:
matrix:
python-version: [ 3.11 ]
permissions:
contents: write
env:
VT_API_KEY: ${{ secrets.VT_API_KEY }}
steps:
- name: Checkout repository
uses: actions/checkout@v4
with:
ref: ${{ inputs.headRef }}
- name: Install virusTotal
run: choco install vt-cli
- name: Install Node.js
uses: actions/setup-node@v2
- name: Install glob
seanbudd marked this conversation as resolved.
Show resolved Hide resolved
run: npm install glob
- name: Submit add-ons with VirusTotal
uses: actions/github-script@v7
with:
script: |
const virusTotalSubmit = require('./.github/workflows/virusTotalSubmit.js')
virusTotalSubmit({core}, "./addons/*/*.json")
- name: Set Virus Total analysis status
if: always()
id: setVirusTotalAnalysisStatus
uses: actions/github-script@v7
with:
script: |
const setVirusTotalAnalysisStatus = require('./.github/workflows/virusTotalAnalysis.js')
setVirusTotalAnalysisStatus({core}, "./addons/*/*.json")
- name: Commit and push updated VT urls
if: always()
run: |
git config user.name github-actions
git config user.email [email protected]
git add ./addons/*/*.json
git commit -m "update VT review URLs"
git branch -u origin/master
git pull
git push
- name: Upload results
id: uploadResults
if: failure()
uses: actions/upload-artifact@v4
with:
name: VirusTotal
path: vt.json
overwrite: true
- name: Upload manual approval
id: uploadManualApproval
if: failure()
uses: actions/upload-artifact@v4
with:
name: manualApproval
path: reviewedAddons.json
overwrite: true
93 changes: 67 additions & 26 deletions .github/workflows/virusTotalAnalysis.js
Original file line number Diff line number Diff line change
@@ -1,38 +1,79 @@
module.exports = ({core}) => {
const fs = require('fs');
const { exec } = require('child_process');
const addonMetadataContents = fs.readFileSync('addonMetadata.json');
const addonMetadata = JSON.parse(addonMetadataContents);
const addonId = addonMetadata.addonId;
core.setOutput('addonId', addonId);
const sha256 = addonMetadata.sha256;
const analysisUrl = `https://www.virustotal.com/gui/file/${sha256}`;
console.log(analysisUrl);
core.setOutput('analysisUrl', analysisUrl);
const reviewedAddonsContents = fs.readFileSync('reviewedAddons.json');
const reviewedAddonsData = JSON.parse(reviewedAddonsContents);
if (reviewedAddonsData[addonId] !== undefined && reviewedAddonsData[addonId].includes(sha256)) {
core.info('VirusTotal analysis skipped');
return;
}
exec(`vt file ${sha256} -k ${process.env.VT_API_KEY} --format json`, (err, stdout, stderr) => {
console.log(`err: ${err}`);
console.log(`stdout: ${stdout}`);
console.log(`stderr: ${stderr}`);
const glob = require('glob');
const fs = require('fs');
const { exec } = require('child_process');

function sleep(n) {
Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, n);
}

function countAPIUsageAndWait({core}) {
// Sleep 20 seconds to avoid rate limiting
sleep(20 * 1000);
core._apiUsageCount++;
}
seanbudd marked this conversation as resolved.
Show resolved Hide resolved

function writeVTScanUrl({core}, metadataFile, addonMetadata) {
// Write vtScanUrl to file
const vtScanUrl = `https://www.virustotal.com/gui/file/${addonMetadata.sha256}`;
addonMetadata.vtScanUrl = vtScanUrl;
stringified = JSON.stringify(addonMetadata, null, "\t");
fs.writeFileSync(metadataFile, stringified);
// Store the latest vtScanUrl for single file analysis
core.setOutput('vtScanUrl', vtScanUrl);
}

function getVirusTotalAnalysis({core}, addonMetadata, metadataFile, reviewedAddonsData) {
countAPIUsageAndWait({core});
exec(`vt file ${addonMetadata.sha256} -k ${process.env.VT_API_KEY} --format json`, (err, stdout, stderr) => {
if (stderr !== '' || err !== null) {
console.log(`err: ${err}`);
console.log(`stdout: ${stdout}`);
console.log(`stderr: ${stderr}`);
core.setFailed('Failed to get VirusTotal analysis');
return;
}
writeVTScanUrl({core}, metadataFile, addonMetadata);
// Append the VirusTotal analysis to the file for an artifact
const vtData = JSON.parse(stdout);
fs.writeFileSync('vt.json', stdout);
fs.appendFileSync('vt.json', stdout);
const stats = vtData[0]["last_analysis_stats"];
const malicious = stats.malicious;
if (malicious === 0) {
core.info('VirusTotal analysis succeeded');
return;
}
if (reviewedAddonsData[addonId] === undefined) {
reviewedAddonsData[addonId] = [];
if (reviewedAddonsData[addonMetadata.addonId] === undefined) {
reviewedAddonsData[addonMetadata.addonId] = [];
}
reviewedAddonsData[addonId].push(sha256);
stringified = JSON.stringify(reviewedAddonsData, null, 2);
reviewedAddonsData[addonMetadata.addonId].push(addonMetadata.sha256);
stringified = JSON.stringify(reviewedAddonsData, null, "\t");
fs.writeFileSync('reviewedAddons.json', stringified);
core.setFailed('VirusTotal analysis failed');
});
}

function getVirusTotalAnalysisIfRequired({core}, metadataFile) {
const addonMetadataContents = fs.readFileSync(metadataFile);
const addonMetadata = JSON.parse(addonMetadataContents);
const addonId = addonMetadata.addonId;
const reviewedAddonsContents = fs.readFileSync('reviewedAddons.json');
const reviewedAddonsData = JSON.parse(reviewedAddonsContents);
if (reviewedAddonsData[addonId] !== undefined && reviewedAddonsData[addonId].includes(sha256)) {
core.info('VirusTotal analysis skipped, already performed');
return;
}
if (apiUsageCount >= 10) {
seanbudd marked this conversation as resolved.
Show resolved Hide resolved
core.info('VirusTotal API usage limit reached');
throw new Error('VirusTotal API usage limit reached');
}
getVirusTotalAnalysis({core}, addonMetadata, metadataFile, reviewedAddonsData);
}

module.exports = ({core}, globPattern) => {
var metadataFiles = glob.globSync(globPattern);
// Count API usages to adhere to rate limiting
core._apiUsageCount = 0;
metadataFiles.forEach(metadataFile => {
getVirusTotalAnalysisIfRequired({core}, metadataFile);
});
};
84 changes: 84 additions & 0 deletions .github/workflows/virusTotalSubmit.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,84 @@
const glob = require('glob');
const { v4: uuidv4 } = require('uuid');
const fs = require('fs');
const { exec } = require('child_process');


function sleep(n) {
seanbudd marked this conversation as resolved.
Show resolved Hide resolved
Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, n);
seanbudd marked this conversation as resolved.
Show resolved Hide resolved
}


function countAPIUsageAndWait({core}) {
// Sleep 20 seconds to avoid rate limiting
sleep(20 * 1000);
core._apiUsageCount++;
}


function submitAddon({core}, addonMetadata, downloadFileName) {
seanbudd marked this conversation as resolved.
Show resolved Hide resolved
countAPIUsageAndWait({core});
// scan downloaded file
exec(`vt scan file -k ${process.env.VT_API_KEY} ${downloadFileName}`, (err, stdout, stderr) => {
if (stderr !== '' || err !== null) {
console.log(`err: ${err}`);
console.log(`stdout: ${stdout}`);
console.log(`stderr: ${stderr}`);
console.error(`Failed to scan add-on file: ${addonMetadata.URL}`);
return;
}
})
}


function downloadAndSubmitAddon({core}, addonMetadata) {
// We need a unique name otherwise we could overwrite files
const downloadFileName = `${uuidv4()}.nvda-addon`;
exec(`curl --fail --silent --show-error --location --output "${downloadFileName}" "${addonMetadata.URL}"`, (err, stdout, stderr) => {
if (stderr !== '' || err !== null) {
console.log(`err: ${err}`);
console.log(`stdout: ${stdout}`);
console.log(`stderr: ${stderr}`);
console.error(`Failed to download add-on file: ${addonMetadata.URL}`);
return;
}
submitAddon({core}, addonMetadata, downloadFileName);
})
}


function submitAddonIfNotScanned({core}, metadataFile) {
const addonMetadataContents = fs.readFileSync(metadataFile);
const addonMetadata = JSON.parse(addonMetadataContents);
const sha256 = addonMetadata.sha256;
if (core._apiUsageCount >= 10) {
seanbudd marked this conversation as resolved.
Show resolved Hide resolved
core.info('VirusTotal API usage limit reached');
throw new Error('VirusTotal API usage limit reached');
}
countAPIUsageAndWait({core});
// Check if file has been scanned before
exec(`vt file ${sha256} -k ${process.env.VT_API_KEY} --format json`, (err, stdout, stderr) => {
console.debug(`stdout: ${stdout}`);
console.debug(`stderr: ${stderr}`);
console.debug(`err: ${err}`);
try {
const vtData = JSON.parse(stdout);
console.debug(`Add-on file ${metadataFile} already submitted, results: ${vtData}`);
return;
} catch (e) {
console.debug(`Add-on file ${metadataFile} has not been scanned before`);
// File has not been scanned before,
// download and submit add-on file
downloadAndSubmitAddon({core}, addonMetadata);
}
});
}

module.exports = ({core}, globPattern) => {
const metadataFiles = glob.globSync(globPattern);
// Count API usages to adhere to rate limiting
core._apiUsageCount = 0;
metadataFiles.forEach(metadataFile => {
submitAddonIfNotScanned({core}, metadataFile);
});
};