diff --git a/build/Build-Extension.ps1 b/build/Build-Extension.ps1 new file mode 100644 index 0000000..5f683a9 --- /dev/null +++ b/build/Build-Extension.ps1 @@ -0,0 +1,271 @@ + +<#PSScriptInfo + +.VERSION 1.0.0 + +.GUID 6312d879-4b8c-4d88-aa39-bf245069148e + +.AUTHOR Markus Szumovski + +.COMPANYNAME - + +.COPYRIGHT 2021 + +.TAGS + +.LICENSEURI + +.PROJECTURI + +.ICONURI + +.EXTERNALMODULEDEPENDENCIES + +.REQUIREDSCRIPTS + +.EXTERNALSCRIPTDEPENDENCIES + +.RELEASENOTES +V 1.0.0: Initial version + +.PRIVATEDATA + +#> + +<# + +.SYNOPSIS + Will build the extension. +.DESCRIPTION + Will build the extension. +.PARAMETER ExtensionRepositoryRoot + The path to the extension repository root from where to build the extension from. + Will default to parent directory of script if no path or $null was provided. +.PARAMETER BuildEnvironment + Set to "Release" for production environment or set to anything else (for example "Development") for development environment. + If nothing was provided the Release environment will be built. +.PARAMETER BuildVersion + If provided will set the version number. + If not provided the version number will not be changed. +.PARAMETER NoPackaging + If the switch was provided, the built extension will not be packaged up into a vsix file. +.OUTPUTS + Building and packaging progress + +.EXAMPLE + Build-Extension -BuildVersion "6.1.0.1" -BuildEnvironment "Release" +#> +[CmdletBinding(SupportsShouldProcess=$True)] +Param +( + [Parameter(Position=0)] + [string] $ExtensionRepositoryRoot = $null, + [Parameter(Position=1)] + [string] $BuildEnvironment = "Release", + [Parameter(Position=2)] + [string] $BuildVersion = $null, + [Parameter(Position=3)] + [switch] $NoPackaging + +) + +### --- START --- functions +### --- END --- functions + +### --- START --- main script ### +try { + Write-Host "--- Build extension script started ---`r`n`r`n" -ForegroundColor DarkGreen + + if([string]::IsNullOrWhiteSpace($ExtensionRepositoryRoot)) { + Write-Host "No extension repository root provided, determining path now..." + $ExtensionRepositoryRoot = Split-Path -Path (Split-Path -Path $MyInvocation.MyCommand.Path -Parent -ErrorAction Stop) -Parent -ErrorAction Stop + Write-Host "" + } + else { + $ExtensionRepositoryRoot = Resolve-Path -Path $ExtensionRepositoryRoot -ErrorAction Ignore + } + + if([string]::IsNullOrWhiteSpace($BuildEnvironment)) { + $BuildEnvironment = "Release" + } + + # Set build env vars + if ($BuildEnvironment -eq "Release") { + $TaskId = "47EA1F4A-57BA-414A-B12E-C44F42765E72" + $TaskName = "dependency-check-build-task" + $VssExtensionName = "vss-extension.prod.json" + } + else { + $TaskId = "04450B31-9F11-415A-B37A-514D69EF69A1" + $TaskName = "dependency-check-build-task-dev" + $VssExtensionName = "vss-extension.dev.json" + } + + $ExtensionRepositoryRootExists = Test-Path -Path $ExtensionRepositoryRoot -ErrorAction Ignore + + if($ExtensionRepositoryRootExists) { + $TaskFolderPath = Join-Path -Path $ExtensionRepositoryRoot -ChildPath "src\Tasks\dependency-check-build-task" -ErrorAction Stop + + $TaskDefPath = Join-Path -Path $TaskFolderPath -ChildPath "task.json" -ErrorAction Stop + $TaskDefExists = Test-Path -Path $TaskDefPath -ErrorAction Ignore + + $VssExtensionPath = Join-Path -Path $ExtensionRepositoryRoot -ChildPath $VssExtensionName -ErrorAction Stop + $VssExtensionExists = Test-Path -Path $VssExtensionPath -ErrorAction Ignore + } + else { + $TaskFolderPath = $null + $TaskDefPath = $null + $TaskDefExists = $false + $VssExtensionPath = $null + $VssExtensionExists = $false + } + + #Parse version vars + $VerPatchRevision = $null + if(![string]::IsNullOrWhiteSpace($BuildVersion)) { + $VerMajor,$VerMinor,$VerPatch,$VerRevision = $BuildVersion.Split('.') + if($null -eq $VerMajor) { + $VerMajor = 0 + } + if($null -eq $VerMinor) { + $VerMinor = 0 + } + if($null -eq $VerPatch) { + $VerPatch = 0 + } + if($null -eq $VerRevision) { + $VerRevision = 0 + } + $VerPatchRevision = [string]::Format("{0}{1}", $VerPatch, $VerRevision.PadLeft(3, '0')) + $BuildVersion = "$VerMajor.$VerMinor.$VerPatch.$VerRevision" + $BuildTaskVersion = "$VerMajor.$VerMinor.$VerPatchRevision" + } + + + Write-Host "------------------------------" + + Write-Host "Extension repository root: ""$ExtensionRepositoryRoot"" (" -NoNewline + if($ExtensionRepositoryRootExists) { + Write-Host "exists" -ForegroundColor Green -NoNewline + } + else { + Write-Host "missing" -ForegroundColor Red -NoNewline + } + Write-Host ")" + + Write-Host "Task definition JSON: ""$TaskDefPath"" (" -NoNewline + if($TaskDefExists) { + Write-Host "exists" -ForegroundColor Green -NoNewline + } + else { + Write-Host "missing" -ForegroundColor Red -NoNewline + } + Write-Host ")" + + Write-Host "VSS extension JSON: ""$VssExtensionPath"" (" -NoNewline + if($VssExtensionExists) { + Write-Host "exists" -ForegroundColor Green -NoNewline + } + else { + Write-Host "missing" -ForegroundColor Red -NoNewline + } + Write-Host ")" + + Write-Host "Build environment: $BuildEnvironment" + if([string]::IsNullOrWhiteSpace($BuildVersion)) { + Write-Host "Build version: " + } + else { + Write-Host "Build version: $BuildVersion" + Write-Host "Build-Task version: $BuildTaskVersion" + } + + Write-Host "Task-Id: $TaskId" + Write-Host "Task-Name: $TaskName" + Write-Host "VSS extension JSON: $VssExtensionName" + + Write-Host "------------------------------`r`n" + + if($ExtensionRepositoryRootExists) { + if($TaskDefExists) { + + Write-Host "Reading task.json..." + $TaskJson = Get-Content -Path $TaskDefPath -Raw | ConvertFrom-Json + + Write-Host "Setting task definition id and name..." + $TaskJson.id = $TaskId + $TaskJson.name = $TaskName + + if([string]::IsNullOrWhiteSpace($BuildVersion)) { + Write-Host "(Skipping setting of task definition version since no version was provided)" + } + else { + Write-Host "Setting task definition version..." + $TaskJson.version.Major = $VerMajor + $TaskJson.version.Minor = $VerMinor + $TaskJson.version.Patch = $VerPatchRevision + } + + Write-Host "Saving new task definition..." + $TaskJson | ConvertTo-Json -Depth 100 | Set-Content -Path $TaskDefPath + + if([string]::IsNullOrWhiteSpace($BuildVersion)) { + Write-Host "(Skipping setting of extension version since no version was provided)" + } + else { + Write-Host "Reading ""$VssExtensionName""..." + $VssExtensionJson = Get-Content -Path $VssExtensionPath -Raw | ConvertFrom-Json + + Write-Host "Setting version" + $VssExtensionJson.version = $BuildVersion + + Write-Host "Saving new extension definition..." + $VssExtensionJson | ConvertTo-Json -Depth 100 | Set-Content $VssExtensionPath + } + + Write-Host "`r`nBuilding task..." + Write-Host "------------------------------" + Push-Location + Set-Location -Path $TaskFolderPath + &"npm" install + &"npm" run build + Pop-Location + Write-Host "------------------------------" + Write-Host "`r`nBuilding extension..." + Write-Host "------------------------------" + &"npm" install + &"npm" run build + Write-Host "------------------------------" + + if(!$NoPackaging.IsPresent) { + Write-Host "`r`nPackaging..." + if($BuildEnvironment -eq "Release") { + &"npm" run package-prod + } + else { + &"npm" run package-dev + } + + Write-Host "`r`nBuilding and packaging extension..." -NoNewline + } + else { + Write-Host "`r`nBuilding extension..." -NoNewline + } + + Write-Host "DONE" -ForegroundColor Green + } + else { + Write-Warning "Task.json not found, cannot continue" + } + } + else { + Write-Warning "Extension repository root not found, cannot continue" + } +} +finally { + Write-Host "`r`n`r`n--- Build extension script ended ---" -ForegroundColor DarkGreen +} + +#and we're at the end + +### --- END --- main script ### diff --git a/build/README.md b/build/README.md index ebd2c36..5ed1761 100644 --- a/build/README.md +++ b/build/README.md @@ -4,7 +4,17 @@ Let's start with this: We can automate this with a pipeline later and eliminate Start by making your changes to the extension. When you are ready to test and release, use the steps below. -## Build Task Version +## PowerShell Core building + +The simplest way to create a new vsix package for development or production environment is to use the ./build/Build-Extension.ps1 PowerShell Core script (PowerShell Core needs to be installed for this script to work). + +Just call it via `pwsh ./build/Build-Extension.ps1 -BuildVersion "6.1.0.0" -BuildEnvironment "Release"` and replace the -BuildVersion string with the new version number and use "Release" as BuildEnvironment for production and "Development" as BuildEnvironment for development. + +After the call the new VSIX file should have been created in the repository root directory. + +## Manual building + +### Build Task Version To release a new version, start by opening the *src/Tasks/dependency-check-build-task/task.json* file. Bump the version number. Keep the major and minor versions in sync with the core Dependency Check CLI. The patch release has to be updated every time you want to change the extension. Even in development. Think of it like a build number. Azure won't update the build task during an update if this value is the same as the currently installed build task in a pipeline. So, we put some 0's on th end to tell us what version of Dependency Check we are using, as well as the build id of the extension itself. Example 5.1.1 = 5.1.\[1000-1999\]. @@ -16,7 +26,7 @@ To release a new version, start by opening the *src/Tasks/dependency-check-build }, ``` -## Building for DEV +### Building for DEV Open the **package.json** file and modify the package line: @@ -58,7 +68,7 @@ dependency-check.azuredevops-dev-5.2.0.000.vsix Upload the the marketplace manually (for now until the release pipeline works) -## Build for PROD +### Build for PROD Open the **package.json** file and update the package command to the prod value: diff --git a/overview.md b/overview.md index 94a3749..b7a324a 100644 --- a/overview.md +++ b/overview.md @@ -12,7 +12,7 @@ The OWASP Dependency Check Azure DevOps Extension enables the following features ## GitHub Repository -The extension maintainers do not monitor the Marketplace Question & Answers. please use the [Azure DevOps Dependency Check](https://github.com/dependency-check/azuredevops) repository for questions, issues, or enhancements. +The extension maintainers do not monitor the Marketplace Question & Answers. Please use the [Azure DevOps Dependency Check](https://github.com/dependency-check/azuredevops) repository for questions, issues, or enhancements. ## Installation and Configuration diff --git a/package-lock.json b/package-lock.json index f30d45e..b1637f0 100644 --- a/package-lock.json +++ b/package-lock.json @@ -127,9 +127,9 @@ "dev": true }, "bl": { - "version": "1.2.2", - "resolved": "http://registry.npmjs.org/bl/-/bl-1.2.2.tgz", - "integrity": "sha512-e8tQYnZodmebYDWGH7KMRvtzKXaJHx3BbilrgZCfvyLUYdKpK1t5PSPmpkny/SgiTSCnjfLW7v5rlONXVFkQEA==", + "version": "1.2.3", + "resolved": "http://registry.npmjs.org/bl/-/bl-1.2.3.tgz", + "integrity": "sha512-pvcNpa0UU69UT341rO6AYy4FVAIkUHuZXRIWbq+zHnsVcRzDDjIAhGuuYoi0d//cwIwtt4pkpKycWEfjdV+vww==", "dev": true, "requires": { "readable-stream": "^2.3.5", @@ -491,9 +491,9 @@ "dev": true }, "hosted-git-info": { - "version": "2.7.1", - "resolved": "https://registry.npmjs.org/hosted-git-info/-/hosted-git-info-2.7.1.tgz", - "integrity": "sha512-7T/BxH19zbcCTa8XkMlbK5lTo1WtgkFi3GvdWEyNuc4Vex7/9Dqbnpsf4JMydcfj9HCg4zUWFTL3Za6lapg5/w==", + "version": "2.8.9", + "resolved": "https://registry.npmjs.org/hosted-git-info/-/hosted-git-info-2.8.9.tgz", + "integrity": "sha512-mxIDAb9Lsm6DoOJ7xH+5+X4y1LU/4Hi50L9C5sIswK3JzULS4bwk1FvjdBgvYR4bzT4tuUQiC15FE2f5HbLvYw==", "dev": true }, "i": { @@ -1295,9 +1295,9 @@ "dev": true }, "underscore": { - "version": "1.9.1", - "resolved": "https://registry.npmjs.org/underscore/-/underscore-1.9.1.tgz", - "integrity": "sha512-5/4etnCkd9c8gwgowi5/om/mYO5ajCaOgdzj/oW+0eQV9WxKBDZw5+ycmKmeaTXjInS/W0BzpGLo2xR2aBwZdg==", + "version": "1.13.1", + "resolved": "https://registry.npmjs.org/underscore/-/underscore-1.13.1.tgz", + "integrity": "sha512-hzSoAVtJF+3ZtiFX0VgfFPHEDRm7Y/QPjGyNo4TVdnDTdft3tr8hEkD25a1jC+TjTuE7tkHGKkhwCgs9dgBB2g==", "dev": true }, "util-deprecate": { diff --git a/src/Tasks/dependency-check-build-task/dependency-check-build-task.ts b/src/Tasks/dependency-check-build-task/dependency-check-build-task.ts index e31b858..d60ef48 100644 --- a/src/Tasks/dependency-check-build-task/dependency-check-build-task.ts +++ b/src/Tasks/dependency-check-build-task/dependency-check-build-task.ts @@ -26,6 +26,8 @@ async function run() { let failOnCVSS: string | undefined = tl.getInput('failOnCVSS'); let suppressionPath: string | undefined = tl.getPathInput('suppressionPath'); let reportsDirectory: string | undefined = tl.getPathInput('reportsDirectory'); + let warnOnCVSSViolation: boolean | undefined = tl.getBoolInput('warnOnCVSSViolation', true); + let reportFilename: string | undefined = tl.getPathInput('reportFilename'); let enableExperimental: boolean | undefined = tl.getBoolInput('enableExperimental', true); let enableRetired: boolean | undefined = tl.getBoolInput('enableRetired', true); let enableVerbose: boolean | undefined = tl.getBoolInput('enableVerbose', true); @@ -34,6 +36,7 @@ async function run() { let dataMirror: string | undefined = tl.getInput('dataMirror'); let customRepo: string | undefined = tl.getInput('customRepo'); let additionalArguments: string | undefined = tl.getInput('additionalArguments'); + let hasLocalInstallation = true; // Trim the strings projectName = projectName?.trim() @@ -41,6 +44,7 @@ async function run() { excludePath = excludePath?.trim(); suppressionPath = suppressionPath?.trim(); reportsDirectory = reportsDirectory?.trim(); + reportFilename = reportFilename?.trim(); additionalArguments = additionalArguments?.trim(); localInstallPath = localInstallPath?.trim(); @@ -61,8 +65,14 @@ async function run() { tl.mkdirP(reportsDirectory!); } + // Set output folder (and filename if supplied) + let outField: string = reportsDirectory; + if (reportFilename && format?.split(',')?.length === 1 && format != "ALL") { + outField = tl.resolve(reportsDirectory, reportFilename); + } + // Default args - let args = `--project "${projectName}" --scan "${scanPath}" --out "${reportsDirectory}"`; + let args = `--project "${projectName}" --scan "${scanPath}" --out "${outField}"`; // Exclude switch if (excludePath != sourcesDirectory) @@ -100,6 +110,7 @@ async function run() { // Set installation location if (localInstallPath == sourcesDirectory) { + hasLocalInstallation = false; localInstallPath = tl.resolve('./dependency-check'); tl.checkPath(localInstallPath, 'Dependency Check installer'); @@ -127,9 +138,9 @@ async function run() { await unzipFromUrl(dataMirror, dataDirectory); } - // Get dependency check script path - let depCheck = 'dependency-check.bat'; - if (tl.osType().match(/^Linux/)) depCheck = 'dependency-check.sh'; + // Get dependency check script path (.sh file for Linux and Darwin OS) + let depCheck = 'dependency-check.sh'; + if (tl.osType().match(/^Windows/)) depCheck = 'dependency-check.bat'; let depCheckPath = tl.resolve(localInstallPath, 'bin', depCheck); console.log(`Dependency Check script set to ${depCheckPath}`); @@ -146,8 +157,38 @@ async function run() { // Version smoke test await tl.tool(depCheckPath).arg('--version').exec(); + if(!hasLocalInstallation) { + // Remove lock files from potential previous canceled run if no local/centralized installation of tool is used. + // We need this because due to a bug the dependency check tool is currently leaving .lock files around if you cancel at the wrong moment. + // Since a per-agent installation shouldn't be able to run two scans parallel, we can savely remove all lock files still lying around. + console.log('Searching for left over lock files...'); + let lockFiles = tl.findMatch(localInstallPath, '*.lock', null, { matchBase: true }); + if(lockFiles.length > 0) { + console.log('found ' + lockFiles.length + ' left over lock files, removing them now...'); + lockFiles.forEach(lockfile => { + let fullLockFilePath = tl.resolve(lockfile); + try { + if(tl.exist(fullLockFilePath)) { + console.log('removing lock file "' + fullLockFilePath + '"...'); + tl.rmRF(fullLockFilePath); + } + else { + console.log('found lock file "' + fullLockFilePath + '" doesn\'t exist, that was unexpected'); + } + } + catch (err) { + console.log('could not delete lock file "' + fullLockFilePath + '"!'); + console.error(err); + } + }); + } + else { + console.log('found no left over lock files, continuing...'); + } + } + // Run the scan - let exitCode = await tl.tool(depCheckPath).line(args).exec({ failOnStdErr: false, ignoreReturnCode: false }); + let exitCode = await tl.tool(depCheckPath).line(args).exec({ failOnStdErr: false, ignoreReturnCode: true }); console.log(`Dependency Check completed with exit code ${exitCode}.`); console.log('Dependency Check reports:'); console.log(tl.findMatch(reportsDirectory, '**/*.*')); @@ -159,14 +200,14 @@ async function run() { // Process scan artifacts is required let processArtifacts = !failed || isViolation; if (processArtifacts) { - console.log('##[debug]Attachments:'); + logDebug('Attachments:'); let reports = tl.findMatch(reportsDirectory, '**/*.*'); reports.forEach(filePath => { let fileName = path.basename(filePath).replace('.', '%2E'); let fileExt = path.extname(filePath); - console.log(`##[debug]Attachment name: ${fileName}`); - console.log(`##[debug]Attachment path: ${filePath}`); - console.log(`##[debug]Attachment type: ${fileExt}`); + logDebug(`Attachment name: ${fileName}`); + logDebug(`Attachment path: ${filePath}`); + logDebug(`Attachment type: ${fileExt}`); console.log(`##vso[task.addattachment type=dependencycheck-artifact;name=${fileName};]${filePath}`); console.log(`##vso[artifact.upload containerfolder=dependency-check;artifactname=Dependency Check;]${filePath}`); }) @@ -176,13 +217,41 @@ async function run() { console.log(`##vso[build.uploadlog]${logFile}`); } + let message = "Dependency Check succeeded" + let result = tl.TaskResult.Succeeded if (failed) { - let message = "Dependency Check exited with an error code."; - if (isViolation) message = "CVSS threshold violation."; + if(isViolation) { + message = "CVSS threshold violation."; + + if(warnOnCVSSViolation) { + result = tl.TaskResult.SucceededWithIssues + } + else { + result = tl.TaskResult.Failed + } + } + else { + message = "Dependency Check exited with an error code (exit code: " + exitCode + ")." + result = tl.TaskResult.Failed + } + } - tl.error(message); - tl.setResult(tl.TaskResult.Failed, message); + let consoleMessage = 'Dependency Check '; + switch(result) { + case tl.TaskResult.Succeeded: + consoleMessage += 'succeeded' + break; + case tl.TaskResult.SucceededWithIssues: + consoleMessage += 'succeeded with issues' + break; + case tl.TaskResult.Failed: + consoleMessage += 'failed' + break; } + consoleMessage += ' with message "' + message + '"' + console.log(consoleMessage); + + tl.setResult(result, message); } catch (err) { console.log(err.message); @@ -193,6 +262,18 @@ async function run() { console.log("Ending Dependency Check..."); } +function logDebug(message: string) { + if(message !== null) { + let varSystemDebug = tl.getVariable('system.debug'); + + if(typeof varSystemDebug === 'string') { + if(varSystemDebug.toLowerCase() == 'true') { + console.log('##[debug]' + message) + } + } + } +} + function cleanLocalInstallPath(localInstallPath: string) { let files = tl.findMatch(localInstallPath, ['**', '!data', '!data/**']); files.forEach(file => tl.rmRF(file)); @@ -212,8 +293,31 @@ async function getZipUrl(version: string): Promise { async function unzipFromUrl(zipUrl: string, unzipLocation: string): Promise { let fileName = path.basename(url.parse(zipUrl).pathname); let zipLocation = tl.resolve(fileName) + let tmpError = null; + let response = null; + let downloadErrorRetries = 5; + + do { + tmpError = null; + try { + await console.log('Downloading ZIP from "' + zipUrl + '"...'); + response = await client.get(zipUrl); + await logDebug('done downloading'); + } + catch(error) { + tmpError = error; + downloadErrorRetries--; + await console.error('Error trying to download ZIP (' + (downloadErrorRetries+1) + ' tries left)'); + await console.error(error); + } + } + while(tmpError !== null && downloadErrorRetries >= 0); + + if(tmpError !== null) { + throw tmpError; + } - let response = await client.get(zipUrl); + await logDebug('Download was successful, saving downloaded ZIP file...'); await new Promise(function (resolve, reject) { let writer = fs.createWriteStream(zipLocation); @@ -222,8 +326,15 @@ async function unzipFromUrl(zipUrl: string, unzipLocation: string): Promise { diff --git a/src/Tasks/dependency-check-build-task/package.json b/src/Tasks/dependency-check-build-task/package.json index 5b91b7c..cf7d2d2 100644 --- a/src/Tasks/dependency-check-build-task/package.json +++ b/src/Tasks/dependency-check-build-task/package.json @@ -11,6 +11,8 @@ "dependencies": { "azure-pipelines-task-lib": "^3.0.6-preview.1", "decompress-zip": "^0.3.3", + "is-core-module": "^2.4.0", + "resolve": "^1.20.0", "typed-rest-client": "^1.8.1" }, "devDependencies": { diff --git a/src/Tasks/dependency-check-build-task/task.json b/src/Tasks/dependency-check-build-task/task.json index b31a089..f85bec3 100644 --- a/src/Tasks/dependency-check-build-task/task.json +++ b/src/Tasks/dependency-check-build-task/task.json @@ -82,6 +82,22 @@ "defaultValue": "", "required": false, "helpMarkDown": "Report output directory. On-prem build agents can specify a local directory to override the default location. The default location is the $COMMON_TESTRESULTSDIRECTORY\\dependency-check directory." + }, + { + "name": "reportFilename", + "type": "string", + "label": "Report Filename", + "defaultValue": "", + "required": false, + "helpMarkDown": "Report output filename. Will set the report output name in 'reportsDirectory' to specified filename. Will not work if format is ALL, or multiple formats are supplied to the 'format' parameter. Filename must have an extension or dependency-check will assume it is a path." + }, + { + "name": "warnOnCVSSViolation", + "type": "boolean", + "label": "Only warn for found violations", + "defaultValue": "false", + "required": false, + "helpMarkDown": "Will only warn for found violations above the CVSS failure threshold instead of throwing an error. This build step will then succeed with issues instead of failing." }, { "name": "enableExperimental",