diff --git a/.github/workflows/compliance-workflow.yml b/.github/workflows/compliance-workflow.yml index b20f1b14..d1f00928 100644 --- a/.github/workflows/compliance-workflow.yml +++ b/.github/workflows/compliance-workflow.yml @@ -3,9 +3,9 @@ name: CyDig Compliance Workflow on: workflow_dispatch: schedule: - - cron: "0 1 * * 1-5" + - cron: '0 1 * * 1-5' jobs: - CyDig-Compliance-Workflow: - uses: Omegapoint/cydig-reusable-workflows/.github/workflows/compliance-template.yml@main - secrets: inherit + CyDig-Compliance-Workflow: + uses: Omegapoint/cydig-reusable-workflows/.github/workflows/compliance-template.yml@main + secrets: inherit diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index b41098d2..90f0b52e 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -1,4 +1,4 @@ -name: test action +name: test action run-name: ${{ github.actor }} is running tests permissions: contents: read @@ -23,4 +23,5 @@ jobs: uses: ./ with: cydigConfigPath: ${{ github.workspace }}/src/cydigConfig.json - PAT-token: ${{ secrets.MY_GITHUB_PAT}} \ No newline at end of file + PAT-token: ${{ secrets.MY_GITHUB_PAT}} + accessTokenAzureDevOps: ${{ DEVOPS_TOKEN_WORK_ITEMS }} diff --git a/README.md b/README.md index 9d74a33f..2c1384dd 100644 --- a/README.md +++ b/README.md @@ -39,22 +39,22 @@ npm run format:write ``` 3. If you are developing a new control, create a new folder for your control in the ```src``` folder. -4. Start developing. To compile your code, run the following command:

 +4. Start developing. To compile your code, run the following command: ```bash npm run build ``` -5. To run the tests, run the following command:


 +5. To run the tests, run the following command: -```bash
 +```bash npm run test ``` -To generated test results in a XML-file, run the following command:

 +To generated test results in a XML-file, run the following command: -```bash
 -npm run testScript
 +```bash +npm run testScript ``` 6. If necessary, add input parameter in ```action.yml```, if it is needed for the control. @@ -66,4 +66,4 @@ npm run testScript
 ## Creating a release for the action At cydig, we follow [Semantic Versioning](https://semver.org/) for our action releases. Practically, this means that when you're developing and creating a pull request (PR), you can assign one of three labels to the PR: Major, Minor, or Patch. These labels correspond to version numbers in the format vX.Y.Z, where X is the major version, Y is the minor version, and Z is the patch version.For example, if you add the "Patch" label to your PR, and it's approved and merged, a workflow will automatically run to create a release for the action. Here's an illustration of how the version number would change before and after the PR: * Version before PR: v1.0.1 -* Version after PR: v1.0.2 \ No newline at end of file +* Version after PR: v1.0.2 diff --git a/action.yml b/action.yml index 0ed706d0..26cc3674 100644 --- a/action.yml +++ b/action.yml @@ -11,6 +11,10 @@ inputs: description: 'Automatic token from Github workflow' required: true default: 'Not working' + accessTokenAzureDevOps: + description: 'Personal access token for Azure DevOps with scope Work Items:read' + required: false + default: 'Not working' runs: using: node16 main: dist/index.js diff --git a/package-lock.json b/package-lock.json index 448662bf..1d923e7c 100644 --- a/package-lock.json +++ b/package-lock.json @@ -12,6 +12,7 @@ "@actions/core": "^1.10.0", "@actions/github": "^5.1.1", "@vercel/ncc": "^0.36.1", + "azure-devops-node-api": "^12.4.0", "joi": "^17.10.1" }, "devDependencies": { @@ -837,6 +838,15 @@ "node": "*" } }, + "node_modules/azure-devops-node-api": { + "version": "12.4.0", + "resolved": "https://registry.npmjs.org/azure-devops-node-api/-/azure-devops-node-api-12.4.0.tgz", + "integrity": "sha512-ZrJlnoAOjliBYvO1wV9oa5Saa3h5tfRbvCSpwjqryag7bIeeY5Zl/zGiZBVD+75EumhtY5mOXNBzHvLf6JmdNQ==", + "dependencies": { + "tunnel": "0.0.6", + "typed-rest-client": "^1.8.4" + } + }, "node_modules/balanced-match": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", @@ -885,6 +895,23 @@ "integrity": "sha512-qhAVI1+Av2X7qelOfAIYwXONood6XlZE/fXaBSmW/T5SzLAmCgzi+eiWE7fUvbHaeNBQH13UftjpXxsfLkMpgw==", "dev": true }, + "node_modules/call-bind": { + "version": "1.0.6", + "resolved": "https://registry.npmjs.org/call-bind/-/call-bind-1.0.6.tgz", + "integrity": "sha512-Mj50FLHtlsoVfRfnHaZvyrooHcrlceNZdL/QBvJJVd9Ta55qCQK0gs4ss2oZDeV9zFCs6ewzYgVE5yfVmfFpVg==", + "dependencies": { + "es-errors": "^1.3.0", + "function-bind": "^1.1.2", + "get-intrinsic": "^1.2.3", + "set-function-length": "^1.2.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/callsites": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/callsites/-/callsites-3.1.0.tgz", @@ -1084,6 +1111,20 @@ "integrity": "sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ==", "dev": true }, + "node_modules/define-data-property": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/define-data-property/-/define-data-property-1.1.2.tgz", + "integrity": "sha512-SRtsSqsDbgpJBbW3pABMCOt6rQyeM8s8RiyeSN8jYG8sYmt/kGJejbydttUsnDs1tadr19tvhT4ShwMyoqAm4g==", + "dependencies": { + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.2", + "gopd": "^1.0.1", + "has-property-descriptors": "^1.0.1" + }, + "engines": { + "node": ">= 0.4" + } + }, "node_modules/deprecation": { "version": "2.3.1", "resolved": "https://registry.npmjs.org/deprecation/-/deprecation-2.3.1.tgz", @@ -1128,6 +1169,14 @@ "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==", "dev": true }, + "node_modules/es-errors": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz", + "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==", + "engines": { + "node": ">= 0.4" + } + }, "node_modules/escalade": { "version": "3.1.1", "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.1.1.tgz", @@ -1474,6 +1523,14 @@ "node": "^8.16.0 || ^10.6.0 || >=11.0.0" } }, + "node_modules/function-bind": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", + "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/get-caller-file": { "version": "2.0.5", "resolved": "https://registry.npmjs.org/get-caller-file/-/get-caller-file-2.0.5.tgz", @@ -1492,6 +1549,24 @@ "node": "*" } }, + "node_modules/get-intrinsic": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.2.4.tgz", + "integrity": "sha512-5uYhsJH8VJBTv7oslg4BznJYhDoRI6waYCxMmCdnTrcCrHA/fCFKoTFz2JKKE0HdDFUF7/oQuhzumXJK7paBRQ==", + "dependencies": { + "es-errors": "^1.3.0", + "function-bind": "^1.1.2", + "has-proto": "^1.0.1", + "has-symbols": "^1.0.3", + "hasown": "^2.0.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/glob": { "version": "7.2.0", "resolved": "https://registry.npmjs.org/glob/-/glob-7.2.0.tgz", @@ -1559,6 +1634,17 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/gopd": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.0.1.tgz", + "integrity": "sha512-d65bNlIadxvpb/A2abVdlqKqV563juRnZ1Wtk6s1sIR8uNsXR70xqIzVqxVf1eTqDunwT2MkczEeaezCKTZhwA==", + "dependencies": { + "get-intrinsic": "^1.1.3" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/graphemer": { "version": "1.4.0", "resolved": "https://registry.npmjs.org/graphemer/-/graphemer-1.4.0.tgz", @@ -1574,6 +1660,50 @@ "node": ">=8" } }, + "node_modules/has-property-descriptors": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/has-property-descriptors/-/has-property-descriptors-1.0.1.tgz", + "integrity": "sha512-VsX8eaIewvas0xnvinAe9bw4WfIeODpGYikiWYLH+dma0Jw6KHYqWiWfhQlgOVK8D6PvjubK5Uc4P0iIhIcNVg==", + "dependencies": { + "get-intrinsic": "^1.2.2" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/has-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/has-proto/-/has-proto-1.0.1.tgz", + "integrity": "sha512-7qE+iP+O+bgF9clE5+UoBFzE65mlBiVj3tKCrlNQ0Ogwm0BjpT/gK4SlLYDMybDh5I3TCTKnPPa0oMG7JDYrhg==", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/has-symbols": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.0.3.tgz", + "integrity": "sha512-l3LCuF6MgDNwTDKkdYGEihYjt5pRPbEg46rtlmnSPlUbgmB8LOIrKJbYYFBSbnPaJexMKtiPO8hmeRjRz2Td+A==", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/hasown": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.0.tgz", + "integrity": "sha512-vUptKVTpIJhcczKBbgnS+RtcuYMB8+oNzPK2/Hp3hanz8JmpATdmmgLgSaadVREkDm+e2giHwY3ZRkyjSIDDFA==", + "dependencies": { + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, "node_modules/he": { "version": "1.2.0", "resolved": "https://registry.npmjs.org/he/-/he-1.2.0.tgz", @@ -2079,6 +2209,14 @@ "node": ">=0.10.0" } }, + "node_modules/object-inspect": { + "version": "1.13.1", + "resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.1.tgz", + "integrity": "sha512-5qoj1RUiKOMsCCNLV1CBiPYE10sziTsnmNxkAI/rZhiD63CF7IqdFGC/XzjWjpSgLf0LxXX3bDFIh0E18f6UhQ==", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/once": { "version": "1.4.0", "resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz", @@ -2245,6 +2383,20 @@ "node": ">=6" } }, + "node_modules/qs": { + "version": "6.11.2", + "resolved": "https://registry.npmjs.org/qs/-/qs-6.11.2.tgz", + "integrity": "sha512-tDNIz22aBzCDxLtVH++VnTfzxlfeK5CbqohpSqpJgj1Wg/cQbStNAz3NuqCs5vV+pjBsK4x4pN9HlVh7rcYRiA==", + "dependencies": { + "side-channel": "^1.0.4" + }, + "engines": { + "node": ">=0.6" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/queue-microtask": { "version": "1.2.3", "resolved": "https://registry.npmjs.org/queue-microtask/-/queue-microtask-1.2.3.tgz", @@ -2396,6 +2548,22 @@ "randombytes": "^2.1.0" } }, + "node_modules/set-function-length": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/set-function-length/-/set-function-length-1.2.1.tgz", + "integrity": "sha512-j4t6ccc+VsKwYHso+kElc5neZpjtq9EnRICFZtWyBsLojhmeF/ZBd/elqm22WJh/BziDe/SBiOeAt0m2mfLD0g==", + "dependencies": { + "define-data-property": "^1.1.2", + "es-errors": "^1.3.0", + "function-bind": "^1.1.2", + "get-intrinsic": "^1.2.3", + "gopd": "^1.0.1", + "has-property-descriptors": "^1.0.1" + }, + "engines": { + "node": ">= 0.4" + } + }, "node_modules/shebang-command": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", @@ -2417,6 +2585,23 @@ "node": ">=8" } }, + "node_modules/side-channel": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.0.5.tgz", + "integrity": "sha512-QcgiIWV4WV7qWExbN5llt6frQB/lBven9pqliLXfGPB+K9ZYXxDozp0wLkHS24kWCm+6YXH/f0HhnObZnZOBnQ==", + "dependencies": { + "call-bind": "^1.0.6", + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.4", + "object-inspect": "^1.13.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/sinon": { "version": "15.2.0", "resolved": "https://registry.npmjs.org/sinon/-/sinon-15.2.0.tgz", @@ -2598,6 +2783,16 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/typed-rest-client": { + "version": "1.8.11", + "resolved": "https://registry.npmjs.org/typed-rest-client/-/typed-rest-client-1.8.11.tgz", + "integrity": "sha512-5UvfMpd1oelmUPRbbaVnq+rHP7ng2cE4qoQkQeAqxRL6PklkxsM0g32/HL0yfvruK6ojQ5x8EE+HF4YV6DtuCA==", + "dependencies": { + "qs": "^6.9.1", + "tunnel": "0.0.6", + "underscore": "^1.12.1" + } + }, "node_modules/typescript": { "version": "4.9.5", "resolved": "https://registry.npmjs.org/typescript/-/typescript-4.9.5.tgz", @@ -2611,6 +2806,11 @@ "node": ">=4.2.0" } }, + "node_modules/underscore": { + "version": "1.13.6", + "resolved": "https://registry.npmjs.org/underscore/-/underscore-1.13.6.tgz", + "integrity": "sha512-+A5Sja4HP1M08MaXya7p5LvjuM7K6q/2EaC0+iovj/wOcMsTzMvDFbasi/oSapiwOlt252IqsKqPjCl7huKS0A==" + }, "node_modules/universal-user-agent": { "version": "6.0.0", "resolved": "https://registry.npmjs.org/universal-user-agent/-/universal-user-agent-6.0.0.tgz", diff --git a/package.json b/package.json index 8e394cf3..292c06cb 100644 --- a/package.json +++ b/package.json @@ -26,6 +26,7 @@ "@actions/core": "^1.10.0", "@actions/github": "^5.1.1", "@vercel/ncc": "^0.36.1", + "azure-devops-node-api": "^12.4.0", "joi": "^17.10.1" }, "devDependencies": { diff --git a/src/azuredevopsboard/AzureDevOpsBoardService.ts b/src/azuredevopsboard/AzureDevOpsBoardService.ts new file mode 100644 index 00000000..414321c7 --- /dev/null +++ b/src/azuredevopsboard/AzureDevOpsBoardService.ts @@ -0,0 +1,52 @@ +import * as core from '@actions/core'; +import { AzureDevOpsConnection } from '../helpfunctions/AzureDevOpsConnection'; +import { CyDigConfig } from '../types/CyDigConfig'; +import { DevOpsBoard } from '../types/DevOpsBoard'; +import { PentestTickets } from './PentestTickets'; +import { ThreatModelingTickets } from './ThreatModelingTickets'; + +export class AzureDevOpsBoardService { + public static async getStateOfAzureDevOpsBoards(cydigConfig: CyDigConfig): Promise { + if (!cydigConfig.azureDevOps.boards) { + try { + console.log('\n Running Azure DevOps Boards control'); + + const azureDevOpsConnection: AzureDevOpsConnection = new AzureDevOpsConnection( + cydigConfig.azureDevOps.organizationName, + core.getInput('accessTokenAzureDevOps') + ); + + const pentestTagInput: string | undefined = process.env.pentestTag; + const threatModelingTagInput: string | undefined = process.env.threatModelingTag; + + let pentestTag: string = cydigConfig.pentest.boardsTag; + if (pentestTagInput !== undefined && pentestTagInput !== '') { + pentestTag = pentestTagInput; + } + + let threatModelingTag: string = cydigConfig.threatModeling.boardsTag; + if (threatModelingTagInput !== undefined && threatModelingTagInput !== '') { + threatModelingTag = threatModelingTagInput; + } + + const board: DevOpsBoard = { + nameOfBoards: cydigConfig.azureDevOps.boards.nameOfBoard, + pentestTag: pentestTag, + threatModelingTag: threatModelingTag, + projectId: cydigConfig.azureDevOps.projectName, + }; + + if (process.env.pentestDate && process.env.pentestDate != 'not specified') { + await PentestTickets.setPentestTickets(azureDevOpsConnection, board); + } + + if (process.env.threatModelingDate && process.env.threatModelingDate != 'not specified') { + await ThreatModelingTickets.setThreatModelingTickets(azureDevOpsConnection, board); + } + } catch (error) { + core.warning('Error getting tickets for Azure DevOps Board!'); + console.log('Error:', error.message); + } + } + } +} diff --git a/src/azuredevopsboard/PenetrationTestTicketService.ts b/src/azuredevopsboard/PenetrationTestTicketService.ts new file mode 100644 index 00000000..88377926 --- /dev/null +++ b/src/azuredevopsboard/PenetrationTestTicketService.ts @@ -0,0 +1,121 @@ +import { WebApi } from 'azure-devops-node-api'; +import { WorkItemQueryResult, WorkItemReference } from 'azure-devops-node-api/interfaces/WorkItemTrackingInterfaces'; +import { IWorkItemTrackingApi } from 'azure-devops-node-api/WorkItemTrackingApi'; + +export class PenetrationTestTicketService { + private connection: WebApi; + private projectId: string; + private tag: string; + + constructor(azureDevOpsConnection: WebApi, projectId: string, tag: string) { + this.connection = azureDevOpsConnection; + this.projectId = projectId; + this.tag = this.setTag(tag); + } + + private setTag(tag: string): string { + if (!tag) { + return 'PT'; + } + return tag; + } + + public async getPenetrationTestTickets(nameOfBoards: string): Promise<{ + numberOfActiveTickets: number; + numberOfClosedTickets: number; + }> { + const areaPaths: string[] = await this.getAreaPaths(nameOfBoards); + const workItems: WorkItemReference[] = await this.getWorkItems(areaPaths); + const numberOfTickets: { + numberOfActiveTickets: number; + numberOfClosedTickets: number; + } = await this.getNumberOfActiveAndClosedTickets(workItems); + return numberOfTickets; + } + + private async getAreaPaths(nameOfBoards: string): Promise { + if (!nameOfBoards || nameOfBoards.toLocaleLowerCase() === null || nameOfBoards === 'not specified') { + console.log('No board specified, looking through all boards in project'); + return []; + } + + let nameOfBoardsArray: string[] = []; + if (nameOfBoards.length > 0) { + nameOfBoardsArray = nameOfBoards.split(', '); + console.log(`Boards: ${nameOfBoardsArray}`); + } + + const areaPaths: string[] = []; + for (const board of nameOfBoardsArray) { + const areaPath: string | undefined = ( + await ( + await this.connection.getWorkApi() + ).getTeamFieldValues({ + team: board, + project: this.projectId, + }) + ).defaultValue; + + if (areaPath) { + areaPaths.push(areaPath); + } + } + + if (areaPaths.length == 0) { + throw new Error('Found no board with the specified name'); + } + + return areaPaths; + } + + private async getWorkItems(areaPaths: string[]): Promise { + const witApi: IWorkItemTrackingApi = await this.connection.getWorkItemTrackingApi(); + let queryStringAreaPath: string = ''; + if (areaPaths.length > 0) { + queryStringAreaPath = this.getQueryStringAreaPath(areaPaths); + } + + const workItemsResponse: WorkItemQueryResult = await witApi.queryByWiql({ + query: `SELECT [System.Id] FROM WorkItems WHERE ${queryStringAreaPath} [System.TeamProject] = '${this.projectId}' AND [System.Tags] CONTAINS '${this.tag}'`, + }); + + const workItems: WorkItemReference[] | undefined = workItemsResponse.workItems; + + return workItems!; + } + + private getQueryStringAreaPath(areaPaths: string[]): string { + let queryStringAreaPath: string = '('; + areaPaths.forEach((areaPath: string) => { + if (areaPath === areaPaths[areaPaths.length - 1]) { + queryStringAreaPath = queryStringAreaPath.concat(`[System.AreaPath] = '${areaPath}') AND`); + } else { + queryStringAreaPath = queryStringAreaPath.concat(`[System.AreaPath] = '${areaPath}' OR `); + } + }); + return queryStringAreaPath; + } + + private async getNumberOfActiveAndClosedTickets(workItems: WorkItemReference[]): Promise<{ + numberOfActiveTickets: number; + numberOfClosedTickets: number; + }> { + const witApi: IWorkItemTrackingApi = await this.connection.getWorkItemTrackingApi(); + let numberOfClosedTickets: number = 0; + let numberOfActiveTickets: number = 0; + + for (const workItem of workItems) { + const workItemFetched = await witApi.getWorkItem(workItem.id!); + const state: string = await workItemFetched.fields!['System.State']; + if (state === 'Closed' || state === 'Done') { + numberOfClosedTickets++; + } else { + numberOfActiveTickets++; + } + } + return { + numberOfActiveTickets: numberOfActiveTickets, + numberOfClosedTickets: numberOfClosedTickets, + }; + } +} diff --git a/src/azuredevopsboard/PentestTickets.ts b/src/azuredevopsboard/PentestTickets.ts new file mode 100644 index 00000000..e545cdea --- /dev/null +++ b/src/azuredevopsboard/PentestTickets.ts @@ -0,0 +1,26 @@ +import * as core from '@actions/core'; +import { AzureDevOpsConnection } from '../helpfunctions/AzureDevOpsConnection'; +import { PenetrationTestTicketService } from './PenetrationTestTicketService'; +import { DevOpsBoard } from '../types/DevOpsBoard'; + +export class PentestTickets { + static async setPentestTickets(azureDevOpsConnection: AzureDevOpsConnection, board: DevOpsBoard): Promise { + console.log('Getting penetration test tickets'); + const penetrationTestTicketService: PenetrationTestTicketService = new PenetrationTestTicketService( + azureDevOpsConnection.getConnection(), + board.projectId, + board.pentestTag + ); + const numberOfTickets: { + numberOfActiveTickets: number; + numberOfClosedTickets: number; + } = await penetrationTestTicketService.getPenetrationTestTickets(board.nameOfBoards); + + console.log('Active penetration test tickets: ' + numberOfTickets.numberOfActiveTickets); + console.log('Closed penetration test tickets : ' + numberOfTickets.numberOfClosedTickets); + + core.exportVariable('ptNumberOfActiveTickets', numberOfTickets.numberOfActiveTickets.toString()); + core.exportVariable('ptNumberOfClosedTickets', numberOfTickets.numberOfClosedTickets.toString()); + return; + } +} diff --git a/src/azuredevopsboard/ThreatModelingTicketService.ts b/src/azuredevopsboard/ThreatModelingTicketService.ts new file mode 100644 index 00000000..f9d69824 --- /dev/null +++ b/src/azuredevopsboard/ThreatModelingTicketService.ts @@ -0,0 +1,121 @@ +import { WebApi } from 'azure-devops-node-api'; +import { WorkItemQueryResult, WorkItemReference } from 'azure-devops-node-api/interfaces/WorkItemTrackingInterfaces'; +import { IWorkItemTrackingApi } from 'azure-devops-node-api/WorkItemTrackingApi'; + +export class ThreatModelingTicketService { + private connection: WebApi; + private projectId: string; + private tag: string; + + constructor(azureDevOpsConnection: WebApi, projectId: string, tag: string) { + this.connection = azureDevOpsConnection; + this.projectId = projectId; + this.tag = this.setTag(tag); + } + + private setTag(tag: string): string { + if (!tag || tag === 'not specified') { + return 'TM'; + } + return tag; + } + + public async getThreatModelingTickets(nameOfBoards: string): Promise<{ + numberOfActiveTickets: number; + numberOfClosedTickets: number; + }> { + const areaPaths: string[] = await this.getAreaPaths(nameOfBoards); + const workItems: WorkItemReference[] = await this.getWorkItems(areaPaths); + const numberOfTickets: { + numberOfActiveTickets: number; + numberOfClosedTickets: number; + } = await this.getNumberOfActiveAndClosedTickets(workItems); + return numberOfTickets; + } + + private async getAreaPaths(nameOfBoards: string): Promise { + if (!nameOfBoards || nameOfBoards.toLocaleLowerCase() === null || nameOfBoards === 'not specified') { + console.log('No board specified, looking through all boards in project'); + return []; + } + + let nameOfBoardsArray: string[] = []; + if (nameOfBoards.length > 0) { + nameOfBoardsArray = nameOfBoards.split(', '); + console.log(`Boards: ${nameOfBoardsArray}`); + } + + const areaPaths: string[] = []; + for (const board of nameOfBoardsArray) { + const areaPath: string | undefined = ( + await ( + await this.connection.getWorkApi() + ).getTeamFieldValues({ + team: board, + project: this.projectId, + }) + ).defaultValue; + + if (areaPath) { + areaPaths.push(areaPath); + } + } + + if (areaPaths.length == 0) { + throw new Error('Found no board with the specified name'); + } + + return areaPaths; + } + + private async getWorkItems(areaPaths: string[]): Promise { + const witApi: IWorkItemTrackingApi = await this.connection.getWorkItemTrackingApi(); + let queryStringAreaPath: string = ''; + if (areaPaths.length > 0) { + queryStringAreaPath = this.getQueryStringAreaPath(areaPaths); + } + + const workItemsResponse: WorkItemQueryResult = await witApi.queryByWiql({ + query: `SELECT [System.Id] FROM WorkItems WHERE ${queryStringAreaPath} [System.TeamProject] = '${this.projectId}' AND [System.Tags] CONTAINS '${this.tag}'`, + }); + + const workItems: WorkItemReference[] | undefined = workItemsResponse.workItems; + + return workItems!; + } + + private getQueryStringAreaPath(areaPaths: string[]): string { + let queryStringAreaPath: string = '('; + areaPaths.forEach((areaPath: string) => { + if (areaPath === areaPaths[areaPaths.length - 1]) { + queryStringAreaPath = queryStringAreaPath.concat(`[System.AreaPath] = '${areaPath}') AND`); + } else { + queryStringAreaPath = queryStringAreaPath.concat(`[System.AreaPath] = '${areaPath}' OR `); + } + }); + return queryStringAreaPath; + } + + private async getNumberOfActiveAndClosedTickets(workItems: WorkItemReference[]): Promise<{ + numberOfActiveTickets: number; + numberOfClosedTickets: number; + }> { + const witApi: IWorkItemTrackingApi = await this.connection.getWorkItemTrackingApi(); + let numberOfClosedTickets: number = 0; + let numberOfActiveTickets: number = 0; + + for (const workItem of workItems) { + const workItemFetched = await witApi.getWorkItem(workItem.id!); + const state: string = await workItemFetched.fields!['System.State']; + if (state === 'Closed' || state === 'Done') { + numberOfClosedTickets++; + } else { + numberOfActiveTickets++; + } + } + return { + numberOfActiveTickets: numberOfActiveTickets, + numberOfClosedTickets: numberOfClosedTickets, + }; + } +} diff --git a/src/azuredevopsboard/ThreatModelingTickets.ts b/src/azuredevopsboard/ThreatModelingTickets.ts new file mode 100644 index 00000000..e7d3bc87 --- /dev/null +++ b/src/azuredevopsboard/ThreatModelingTickets.ts @@ -0,0 +1,29 @@ +import * as core from '@actions/core'; +import { AzureDevOpsConnection } from '../helpfunctions/AzureDevOpsConnection'; +import { ThreatModelingTicketService } from './ThreatModelingTicketService'; +import { DevOpsBoard } from '../types/DevOpsBoard'; + +export class ThreatModelingTickets { + static async setThreatModelingTickets( + azureDevOpsConnection: AzureDevOpsConnection, + board: DevOpsBoard + ): Promise { + console.log('Getting threat modeling tickets'); + const threatModelingTicketService: ThreatModelingTicketService = new ThreatModelingTicketService( + azureDevOpsConnection.getConnection(), + board.projectId, + board.threatModelingTag + ); + const numberOfTickets: { + numberOfActiveTickets: number; + numberOfClosedTickets: number; + } = await threatModelingTicketService.getThreatModelingTickets(board.nameOfBoards); + + console.log('Active threat modeling tickets: ' + numberOfTickets.numberOfActiveTickets); + console.log('Closed threat modeling tickets : ' + numberOfTickets.numberOfClosedTickets); + + core.exportVariable('tmNumberOfActiveTickets', numberOfTickets.numberOfActiveTickets.toString()); + core.exportVariable('tmNumberOfClosedTickets', numberOfTickets.numberOfClosedTickets.toString()); + return; + } +} diff --git a/src/cydigConfig.json b/src/cydigConfig.json index c51ed598..7cf8f75b 100644 --- a/src/cydigConfig.json +++ b/src/cydigConfig.json @@ -10,14 +10,19 @@ "boardsTag": "PT" }, "github": { - "usingRepos": true, + "usingRepos": true + }, + "azureDevOps": { + "usingRepos": false, "repos": { "username": "firstname.lastname (usually)" }, "usingBoards": true, "boards": { - "nameOfBoard": "name-of-boards (use 'not specified' for all boards in project)" - } + "nameOfBoard": "not specified" + }, + "organizationName": "CyDig", + "projectName": "CyDig" }, "scaTool": { "nameOfTool": "name-of-tool", diff --git a/src/helpfunctions/AzureDevOpsConnection.ts b/src/helpfunctions/AzureDevOpsConnection.ts new file mode 100644 index 00000000..60f5fa67 --- /dev/null +++ b/src/helpfunctions/AzureDevOpsConnection.ts @@ -0,0 +1,31 @@ +import * as nodeApi from 'azure-devops-node-api'; +import { IRequestHandler } from 'azure-devops-node-api/interfaces/common/VsoBaseInterfaces'; + +export class AzureDevOpsConnection { + private accessToken: string; + private orgUrlDevOps: string; + private orgName: string; + + constructor(orgName: string, accessToken: string) { + this.accessToken = this.isNullOrUndefined(accessToken); + this.orgName = orgName; + this.orgUrlDevOps = `https://dev.azure.com/${orgName}/`; + } + + private isNullOrUndefined(value: string): string { + if (!value) { + throw new Error('Input values cannot be null or undefined'); + } + return value; + } + + public getConnection(): nodeApi.WebApi { + const authHandler: IRequestHandler = nodeApi.getPersonalAccessTokenHandler(this.accessToken); + const connection: nodeApi.WebApi = new nodeApi.WebApi(this.orgUrlDevOps, authHandler); + return connection; + } + + public getOrgName(): string { + return this.orgName; + } +} diff --git a/src/helpfunctions/JsonService.ts b/src/helpfunctions/JsonService.ts index 35b83915..6822ce82 100644 --- a/src/helpfunctions/JsonService.ts +++ b/src/helpfunctions/JsonService.ts @@ -31,6 +31,9 @@ export function validateConfig(config: unknown): void { }), github: Joi.object({ usingRepos: Joi.boolean(), + }), + azureDevOps: { + usingRepos: Joi.boolean(), repos: Joi.object({ username: Joi.string(), }), @@ -38,7 +41,9 @@ export function validateConfig(config: unknown): void { boards: Joi.object({ nameOfBoard: Joi.string(), }), - }), + organizationName: Joi.string(), + projectName: Joi.string(), + }, scaTool: Joi.object({ nameOfTool: Joi.string(), owaspDependencyCheck: Joi.object({ diff --git a/src/index.ts b/src/index.ts index d7dfbf58..15820036 100644 --- a/src/index.ts +++ b/src/index.ts @@ -4,6 +4,7 @@ import { CyDigConfig } from './types/CyDigConfig'; import { getContentOfFile } from './helpfunctions/JsonService'; import { PentestService } from './Pentest/PentestService'; import { ThreatModelingService } from './threatmodeling/ThreatModelingService'; +import { AzureDevOpsBoardService } from './azuredevopsboard/AzureDevOpsBoardService'; /** * The main function for the action. * @returns {Promise} Resolves when the action is complete. @@ -12,10 +13,13 @@ export async function run(): Promise { try { console.log('\n Running controls on your repository'); const cydigConfig: CyDigConfig = getContentOfFile(core.getInput('cydigConfigPath')); + await BranchProtectionService.getStateOfBranchProtection(); await PentestService.getStateOfPentest(cydigConfig.pentest); await ThreatModelingService.getStateOfThreatModeling(cydigConfig.threatModeling); + + await AzureDevOpsBoardService.getStateOfAzureDevOpsBoards(cydigConfig); } catch (error) { // Fail the workflow run if an error occurs if (error instanceof Error) core.setFailed(error.message); diff --git a/src/types/CyDigConfig.ts b/src/types/CyDigConfig.ts index 4b448cae..a6d31fd1 100644 --- a/src/types/CyDigConfig.ts +++ b/src/types/CyDigConfig.ts @@ -11,6 +11,9 @@ export type CyDigConfig = { }; github: { usingRepos: boolean; + }; + azureDevOps: { + usingRepos: boolean; repos: { username: string; }; @@ -18,6 +21,8 @@ export type CyDigConfig = { boards: { nameOfBoard: string; }; + organizationName: string; + projectName: string; }; scaTool: { nameOfTool: string; diff --git a/src/types/DevOpsBoard.ts b/src/types/DevOpsBoard.ts new file mode 100644 index 00000000..87d7b000 --- /dev/null +++ b/src/types/DevOpsBoard.ts @@ -0,0 +1,6 @@ +export type DevOpsBoard = { + nameOfBoards: string; + pentestTag: string; + threatModelingTag: string; + projectId: string; +};