diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml new file mode 100644 index 00000000..520ec044 --- /dev/null +++ b/.github/workflows/test.yml @@ -0,0 +1,39 @@ +name: Build and Test + +on: + pull_request: + branches: + - main + - 'release/**' + push: + branches: + - main + +permissions: + contents: read + +jobs: + test: + name: Build and Test + runs-on: ubuntu-latest + + steps: + - name: Checkout repository + uses: actions/checkout@v6 + + - name: Set up Node.js + uses: actions/setup-node@v6 + with: + node-version: '20' + + - name: Install dependencies + run: npm install + + - name: Compile TypeScript + run: npm run build + + - name: Compile Tests + run: npm run buildTests + + - name: Run Tests + run: npm test diff --git a/lib/container-mapping.js b/lib/container-mapping.js index f0908b59..497b66ee 100644 --- a/lib/container-mapping.js +++ b/lib/container-mapping.js @@ -15,13 +15,23 @@ var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? ( }) : function(o, v) { o["default"] = v; }); -var __importStar = (this && this.__importStar) || function (mod) { - if (mod && mod.__esModule) return mod; - var result = {}; - if (mod != null) for (var k in mod) if (k !== "default" && Object.prototype.hasOwnProperty.call(mod, k)) __createBinding(result, mod, k); - __setModuleDefault(result, mod); - return result; -}; +var __importStar = (this && this.__importStar) || (function () { + var ownKeys = function(o) { + ownKeys = Object.getOwnPropertyNames || function (o) { + var ar = []; + for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k; + return ar; + }; + return ownKeys(o); + }; + return function (mod) { + if (mod && mod.__esModule) return mod; + var result = {}; + if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]); + __setModuleDefault(result, mod); + return result; + }; +})(); var __awaiter = (this && this.__awaiter) || function (thisArg, _arguments, P, generator) { function adopt(value) { return value instanceof P ? value : new P(function (resolve) { resolve(value); }); } return new (P || (P = Promise))(function (resolve, reject) { @@ -37,7 +47,9 @@ const https = __importStar(require("https")); const core = __importStar(require("@actions/core")); const exec = __importStar(require("@actions/exec")); const os = __importStar(require("os")); -const sendReportRetryCount = 1; +const SEND_REPORT_RETRY_COUNT = 1; +const REQUEST_TIMEOUT_MS = 2500; +const PRE_JOB_FALLBACK_OFFSET_MS = 10000; const GetScanContextURL = "https://dfdinfra-afdendpoint-prod-d5fqbucbg7fue0cf.z01.azurefd.net/github/v1/auth-push/GetScanContext?context=authOnly"; const ContainerMappingURL = "https://dfdinfra-afdendpoint-prod-d5fqbucbg7fue0cf.z01.azurefd.net/github/v1/container-mappings"; class ContainerMapping { @@ -45,16 +57,18 @@ class ContainerMapping { this.succeedOnError = true; } runPreJob() { - try { - core.info("::group::Microsoft Defender for DevOps container mapping pre-job - https://go.microsoft.com/fwlink/?linkid=2231419"); - this._runPreJob(); - } - catch (error) { - core.info("Error in Container Mapping pre-job: " + error); - } - finally { - core.info("::endgroup::"); - } + return __awaiter(this, void 0, void 0, function* () { + try { + core.info("::group::Microsoft Defender for DevOps container mapping pre-job - https://go.microsoft.com/fwlink/?linkid=2231419"); + this._runPreJob(); + } + catch (error) { + core.warning(`Error in Container Mapping pre-job: ${error}`); + } + finally { + core.info("::endgroup::"); + } + }); } _runPreJob() { const startTime = new Date().toISOString(); @@ -72,7 +86,7 @@ class ContainerMapping { yield this._runPostJob(); } catch (error) { - core.info("Error in Container Mapping post-job: " + error); + core.warning(`Error in Container Mapping post-job: ${error}`); } finally { core.info("::endgroup::"); @@ -83,7 +97,7 @@ class ContainerMapping { return __awaiter(this, void 0, void 0, function* () { let startTime = core.getState('PreJobStartTime'); if (startTime.length <= 0) { - startTime = new Date(new Date().getTime() - 10000).toISOString(); + startTime = new Date(new Date().getTime() - PRE_JOB_FALLBACK_OFFSET_MS).toISOString(); core.debug(`PreJobStartTime not defined, using now-10secs`); } core.info(`PreJobStartTime: ${startTime}`); @@ -95,34 +109,33 @@ class ContainerMapping { let bearerToken = yield core.getIDToken() .then((token) => { return token; }) .catch((error) => { - throw new Error("Unable to get token: " + error); + throw new Error(`Unable to get OIDC token. Ensure the workflow has 'id-token: write' permission. Details: ${error}`); }); if (!bearerToken) { - throw new Error("Empty OIDC token received"); + throw new Error("Empty OIDC token received. Ensure the workflow has 'id-token: write' permission."); } - var callerIsOnboarded = yield this.checkCallerIsCustomer(bearerToken, sendReportRetryCount); + var callerIsOnboarded = yield this.checkCallerIsCustomer(bearerToken, SEND_REPORT_RETRY_COUNT); if (!callerIsOnboarded) { - core.info("Client is not onboarded to Defender for DevOps. Skipping container mapping workload."); + core.warning("Client is not onboarded to Defender for DevOps. Skipping container mapping workload."); return; } core.info("Client is onboarded for container mapping."); let dockerVersionOutput = yield exec.getExecOutput('docker --version'); - if (dockerVersionOutput.exitCode != 0) { - core.info(`Unable to get docker version: ${dockerVersionOutput}`); - core.info(`Skipping container mapping since docker not found/available.`); + if (dockerVersionOutput.exitCode !== 0) { + core.warning(`Docker not found or not available. Skipping container mapping. Exit code: ${dockerVersionOutput.exitCode}`); return; } reportData.dockerVersion = dockerVersionOutput.stdout.trim(); yield this.execCommand(`docker events --since ${startTime} --until ${new Date().toISOString()} --filter event=push --filter type=image --format ID={{.ID}}`, reportData.dockerEvents) .catch((error) => { - throw new Error("Unable to get docker events: " + error); + throw new Error(`Unable to get docker events: ${error}`); }); yield this.execCommand(`docker images --format CreatedAt={{.CreatedAt}}::Repo={{.Repository}}::Tag={{.Tag}}::Digest={{.Digest}}`, reportData.dockerImages) .catch((error) => { - throw new Error("Unable to get docker images: " + error); + throw new Error(`Unable to get docker images: ${error}`); }); core.debug("Finished data collection, starting API calls."); - var reportSent = yield this.sendReport(JSON.stringify(reportData), bearerToken, sendReportRetryCount); + var reportSent = yield this.sendReport(JSON.stringify(reportData), bearerToken, SEND_REPORT_RETRY_COUNT); if (!reportSent) { throw new Error("Unable to send report to backend service"); } @@ -134,7 +147,7 @@ class ContainerMapping { return __awaiter(this, void 0, void 0, function* () { return exec.getExecOutput(command) .then((result) => { - if (result.exitCode != 0) { + if (result.exitCode !== 0) { return Promise.reject(`Command execution failed: ${result}`); } result.stdout.trim().split(os.EOL).forEach(element => { @@ -145,15 +158,15 @@ class ContainerMapping { }); }); } - sendReport(data, bearerToken, retryCount = 0) { - return __awaiter(this, void 0, void 0, function* () { + sendReport(data_1, bearerToken_1) { + return __awaiter(this, arguments, void 0, function* (data, bearerToken, retryCount = 0) { core.debug(`attempting to send report: ${data}`); return yield this._sendReport(data, bearerToken) .then(() => { return true; }) .catch((error) => __awaiter(this, void 0, void 0, function* () { - if (retryCount == 0) { + if (retryCount === 0) { return false; } else { @@ -166,29 +179,30 @@ class ContainerMapping { } _sendReport(data, bearerToken) { return __awaiter(this, void 0, void 0, function* () { - return new Promise((resolve, reject) => __awaiter(this, void 0, void 0, function* () { - let apiTime = new Date().getMilliseconds(); - let options = { - method: 'POST', - timeout: 2500, - headers: { - 'Content-Type': 'application/json', - 'Authorization': 'Bearer ' + bearerToken, - 'Content-Length': data.length - } - }; - core.debug(`${options['method'].toUpperCase()} ${ContainerMappingURL}`); + const apiStartTime = new Date().getTime(); + const options = { + method: 'POST', + timeout: REQUEST_TIMEOUT_MS, + headers: { + 'Content-Type': 'application/json', + 'Authorization': `Bearer ${bearerToken}`, + 'Content-Length': data.length + } + }; + core.debug(`${options.method} ${ContainerMappingURL}`); + return new Promise((resolve, reject) => { const req = https.request(ContainerMappingURL, options, (res) => { let resData = ''; res.on('data', (chunk) => { resData += chunk.toString(); }); res.on('end', () => { - core.debug('API calls finished. Time taken: ' + (new Date().getMilliseconds() - apiTime) + "ms"); + const elapsed = new Date().getTime() - apiStartTime; + core.debug(`API calls finished. Time taken: ${elapsed}ms`); core.debug(`Status code: ${res.statusCode} ${res.statusMessage}`); - core.debug('Response headers: ' + JSON.stringify(res.headers)); + core.debug(`Response headers: ${JSON.stringify(res.headers)}`); if (resData.length > 0) { - core.debug('Response: ' + resData); + core.debug(`Response: ${resData}`); } if (res.statusCode < 200 || res.statusCode >= 300) { return reject(`Received Failed Status code when calling url: ${res.statusCode} ${resData}`); @@ -201,17 +215,17 @@ class ContainerMapping { }); req.write(data); req.end(); - })); + }); }); } - checkCallerIsCustomer(bearerToken, retryCount = 0) { - return __awaiter(this, void 0, void 0, function* () { + checkCallerIsCustomer(bearerToken_1) { + return __awaiter(this, arguments, void 0, function* (bearerToken, retryCount = 0) { return yield this._checkCallerIsCustomer(bearerToken) .then((statusCode) => __awaiter(this, void 0, void 0, function* () { - if (statusCode == 200) { + if (statusCode === 200) { return true; } - else if (statusCode == 403) { + else if (statusCode === 403) { return false; } else { @@ -227,7 +241,7 @@ class ContainerMapping { } retryCall(bearerToken, retryCount) { return __awaiter(this, void 0, void 0, function* () { - if (retryCount == 0) { + if (retryCount === 0) { core.info(`All retries failed.`); return false; } @@ -240,28 +254,28 @@ class ContainerMapping { } _checkCallerIsCustomer(bearerToken) { return __awaiter(this, void 0, void 0, function* () { - return new Promise((resolve, reject) => __awaiter(this, void 0, void 0, function* () { - let options = { - method: 'GET', - timeout: 2500, - headers: { - 'Content-Type': 'application/json', - 'Authorization': 'Bearer ' + bearerToken, - } - }; - core.debug(`${options['method'].toUpperCase()} ${GetScanContextURL}`); + const options = { + method: 'GET', + timeout: REQUEST_TIMEOUT_MS, + headers: { + 'Content-Type': 'application/json', + 'Authorization': `Bearer ${bearerToken}`, + } + }; + core.debug(`${options.method} ${GetScanContextURL}`); + return new Promise((resolve, reject) => { const req = https.request(GetScanContextURL, options, (res) => { res.on('end', () => { resolve(res.statusCode); }); - res.on('data', function (d) { + res.on('data', () => { }); }); req.on('error', (error) => { reject(new Error(`Error calling url: ${error}`)); }); req.end(); - })); + }); }); } } diff --git a/lib/main.js b/lib/main.js index f857f586..0b4cd0bb 100644 --- a/lib/main.js +++ b/lib/main.js @@ -15,13 +15,23 @@ var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? ( }) : function(o, v) { o["default"] = v; }); -var __importStar = (this && this.__importStar) || function (mod) { - if (mod && mod.__esModule) return mod; - var result = {}; - if (mod != null) for (var k in mod) if (k !== "default" && Object.prototype.hasOwnProperty.call(mod, k)) __createBinding(result, mod, k); - __setModuleDefault(result, mod); - return result; -}; +var __importStar = (this && this.__importStar) || (function () { + var ownKeys = function(o) { + ownKeys = Object.getOwnPropertyNames || function (o) { + var ar = []; + for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k; + return ar; + }; + return ownKeys(o); + }; + return function (mod) { + if (mod && mod.__esModule) return mod; + var result = {}; + if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]); + __setModuleDefault(result, mod); + return result; + }; +})(); var __awaiter = (this && this.__awaiter) || function (thisArg, _arguments, P, generator) { function adopt(value) { return value instanceof P ? value : new P(function (resolve) { resolve(value); }); } return new (P || (P = Promise))(function (resolve, reject) { @@ -54,7 +64,7 @@ function shouldRunMain() { let toolsString = core.getInput('tools'); if (!common.isNullOrWhiteSpace(toolsString)) { let tools = toolsString.split(','); - if (tools.length == 1 && tools[0].trim() == msdo_helpers_1.Tools.ContainerMapping) { + if (tools.length === 1 && tools[0].trim() === msdo_helpers_1.Tools.ContainerMapping) { return false; } } diff --git a/lib/msdo-helpers.js b/lib/msdo-helpers.js index 3a060a58..f600e234 100644 --- a/lib/msdo-helpers.js +++ b/lib/msdo-helpers.js @@ -3,7 +3,9 @@ var __importDefault = (this && this.__importDefault) || function (mod) { return (mod && mod.__esModule) ? mod : { "default": mod }; }; Object.defineProperty(exports, "__esModule", { value: true }); -exports.writeToOutStream = exports.getEncodedContent = exports.encode = exports.Constants = exports.Tools = exports.RunnerType = exports.Inputs = void 0; +exports.encode = exports.Constants = exports.Tools = exports.RunnerType = exports.Inputs = void 0; +exports.getEncodedContent = getEncodedContent; +exports.writeToOutStream = writeToOutStream; const os_1 = __importDefault(require("os")); var Inputs; (function (Inputs) { @@ -49,8 +51,6 @@ function getEncodedContent(dockerVersion, dockerEvents, dockerImages) { data.push(dockerImages); return (0, exports.encode)(data.join(os_1.default.EOL)); } -exports.getEncodedContent = getEncodedContent; function writeToOutStream(data, outStream = process.stdout) { outStream.write(data.trim() + os_1.default.EOL); } -exports.writeToOutStream = writeToOutStream; diff --git a/lib/msdo-interface.js b/lib/msdo-interface.js index ed538cfb..4e2b7220 100644 --- a/lib/msdo-interface.js +++ b/lib/msdo-interface.js @@ -1,7 +1,6 @@ "use strict"; Object.defineProperty(exports, "__esModule", { value: true }); -exports.getExecutor = void 0; +exports.getExecutor = getExecutor; function getExecutor(runner) { return new runner(); } -exports.getExecutor = getExecutor; diff --git a/lib/msdo.js b/lib/msdo.js index 039c3c00..7f1d5a03 100644 --- a/lib/msdo.js +++ b/lib/msdo.js @@ -15,13 +15,23 @@ var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? ( }) : function(o, v) { o["default"] = v; }); -var __importStar = (this && this.__importStar) || function (mod) { - if (mod && mod.__esModule) return mod; - var result = {}; - if (mod != null) for (var k in mod) if (k !== "default" && Object.prototype.hasOwnProperty.call(mod, k)) __createBinding(result, mod, k); - __setModuleDefault(result, mod); - return result; -}; +var __importStar = (this && this.__importStar) || (function () { + var ownKeys = function(o) { + ownKeys = Object.getOwnPropertyNames || function (o) { + var ar = []; + for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k; + return ar; + }; + return ownKeys(o); + }; + return function (mod) { + if (mod && mod.__esModule) return mod; + var result = {}; + if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]); + __setModuleDefault(result, mod); + return result; + }; +})(); var __awaiter = (this && this.__awaiter) || function (thisArg, _arguments, P, generator) { function adopt(value) { return value instanceof P ? value : new P(function (resolve) { resolve(value); }); } return new (P || (P = Promise))(function (resolve, reject) { @@ -52,7 +62,7 @@ class MicrosoftSecurityDevOps { runMain() { return __awaiter(this, void 0, void 0, function* () { core.debug('MicrosoftSecurityDevOps.runMain - Running MSDO...'); - let args = undefined; + let args; let existingFilename = core.getInput('existingFilename'); if (!common.isNullOrWhiteSpace(existingFilename)) { args = ['upload', '--file', existingFilename]; @@ -100,9 +110,9 @@ class MicrosoftSecurityDevOps { let tool = tools[i]; let toolTrimmed = tool.trim(); if (!common.isNullOrWhiteSpace(tool) - && tool != msdo_helpers_1.Tools.ContainerMapping - && includedTools.indexOf(toolTrimmed) == -1) { - if (includedTools.length == 0) { + && tool !== msdo_helpers_1.Tools.ContainerMapping + && includedTools.indexOf(toolTrimmed) === -1) { + if (includedTools.length === 0) { args.push('--tool'); } args.push(toolTrimmed); diff --git a/lib/post.js b/lib/post.js index ca4f9b68..474e0405 100644 --- a/lib/post.js +++ b/lib/post.js @@ -15,13 +15,23 @@ var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? ( }) : function(o, v) { o["default"] = v; }); -var __importStar = (this && this.__importStar) || function (mod) { - if (mod && mod.__esModule) return mod; - var result = {}; - if (mod != null) for (var k in mod) if (k !== "default" && Object.prototype.hasOwnProperty.call(mod, k)) __createBinding(result, mod, k); - __setModuleDefault(result, mod); - return result; -}; +var __importStar = (this && this.__importStar) || (function () { + var ownKeys = function(o) { + ownKeys = Object.getOwnPropertyNames || function (o) { + var ar = []; + for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k; + return ar; + }; + return ownKeys(o); + }; + return function (mod) { + if (mod && mod.__esModule) return mod; + var result = {}; + if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]); + __setModuleDefault(result, mod); + return result; + }; +})(); var __awaiter = (this && this.__awaiter) || function (thisArg, _arguments, P, generator) { function adopt(value) { return value instanceof P ? value : new P(function (resolve) { resolve(value); }); } return new (P || (P = Promise))(function (resolve, reject) { @@ -32,14 +42,15 @@ var __awaiter = (this && this.__awaiter) || function (thisArg, _arguments, P, ge }); }; Object.defineProperty(exports, "__esModule", { value: true }); +exports.run = run; const core = __importStar(require("@actions/core")); const container_mapping_1 = require("./container-mapping"); const msdo_interface_1 = require("./msdo-interface"); -function runPost() { +function run() { return __awaiter(this, void 0, void 0, function* () { yield (0, msdo_interface_1.getExecutor)(container_mapping_1.ContainerMapping).runPostJob(); }); } -runPost().catch((error) => { - core.debug(error); +run().catch((error) => { + core.warning(`Post-job failed: ${error}`); }); diff --git a/lib/pre.js b/lib/pre.js index 1305f979..af8e3ffe 100644 --- a/lib/pre.js +++ b/lib/pre.js @@ -15,13 +15,23 @@ var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? ( }) : function(o, v) { o["default"] = v; }); -var __importStar = (this && this.__importStar) || function (mod) { - if (mod && mod.__esModule) return mod; - var result = {}; - if (mod != null) for (var k in mod) if (k !== "default" && Object.prototype.hasOwnProperty.call(mod, k)) __createBinding(result, mod, k); - __setModuleDefault(result, mod); - return result; -}; +var __importStar = (this && this.__importStar) || (function () { + var ownKeys = function(o) { + ownKeys = Object.getOwnPropertyNames || function (o) { + var ar = []; + for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k; + return ar; + }; + return ownKeys(o); + }; + return function (mod) { + if (mod && mod.__esModule) return mod; + var result = {}; + if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]); + __setModuleDefault(result, mod); + return result; + }; +})(); var __awaiter = (this && this.__awaiter) || function (thisArg, _arguments, P, generator) { function adopt(value) { return value instanceof P ? value : new P(function (resolve) { resolve(value); }); } return new (P || (P = Promise))(function (resolve, reject) { @@ -32,14 +42,15 @@ var __awaiter = (this && this.__awaiter) || function (thisArg, _arguments, P, ge }); }; Object.defineProperty(exports, "__esModule", { value: true }); +exports.run = run; const core = __importStar(require("@actions/core")); const container_mapping_1 = require("./container-mapping"); const msdo_interface_1 = require("./msdo-interface"); -function runPre() { +function run() { return __awaiter(this, void 0, void 0, function* () { yield (0, msdo_interface_1.getExecutor)(container_mapping_1.ContainerMapping).runPreJob(); }); } -runPre().catch((error) => { - core.debug(error); +run().catch((error) => { + core.warning(`Pre-job failed: ${error}`); }); diff --git a/package.json b/package.json index 7f43ea2e..4d073beb 100644 --- a/package.json +++ b/package.json @@ -10,7 +10,7 @@ "build": "npx gulp", "buildTests": "npx gulp buildTests", "test": "npx mocha **/*.tests.js", - "buildAndTest": "npx gulp buildTests & npx mocha **/*.tests.js" + "buildAndTest": "npx gulp buildTests && npx mocha **/*.tests.js" }, "author": "Microsoft Corporation", "license": "MIT", diff --git a/src/container-mapping.ts b/src/container-mapping.ts index 67dc1f82..730ed87b 100644 --- a/src/container-mapping.ts +++ b/src/container-mapping.ts @@ -4,7 +4,9 @@ import * as core from '@actions/core'; import * as exec from '@actions/exec'; import * as os from 'os'; -const sendReportRetryCount: number = 1; +const SEND_REPORT_RETRY_COUNT: number = 1; +const REQUEST_TIMEOUT_MS: number = 2500; +const PRE_JOB_FALLBACK_OFFSET_MS: number = 10000; const GetScanContextURL: string = "https://dfdinfra-afdendpoint-prod-d5fqbucbg7fue0cf.z01.azurefd.net/github/v1/auth-push/GetScanContext?context=authOnly"; const ContainerMappingURL: string = "https://dfdinfra-afdendpoint-prod-d5fqbucbg7fue0cf.z01.azurefd.net/github/v1/container-mappings"; @@ -21,17 +23,15 @@ export class ContainerMapping implements IMicrosoftSecurityDevOps { /** * Container mapping pre-job commands wrapped in exception handling. */ - public runPreJob() { + public async runPreJob() { try { core.info("::group::Microsoft Defender for DevOps container mapping pre-job - https://go.microsoft.com/fwlink/?linkid=2231419"); this._runPreJob(); } catch (error) { - // Log the error - core.info("Error in Container Mapping pre-job: " + error); + core.warning(`Error in Container Mapping pre-job: ${error}`); } finally { - // End the collapsible section core.info("::endgroup::"); } } @@ -61,10 +61,8 @@ export class ContainerMapping implements IMicrosoftSecurityDevOps { core.info("::group::Microsoft Defender for DevOps container mapping post-job - https://go.microsoft.com/fwlink/?linkid=2231419"); await this._runPostJob(); } catch (error) { - // Log the error - core.info("Error in Container Mapping post-job: " + error); + core.warning(`Error in Container Mapping post-job: ${error}`); } finally { - // End the collapsible section core.info("::endgroup::"); } } @@ -76,7 +74,7 @@ export class ContainerMapping implements IMicrosoftSecurityDevOps { private async _runPostJob() { let startTime = core.getState('PreJobStartTime'); if (startTime.length <= 0) { - startTime = new Date(new Date().getTime() - 10000).toISOString(); + startTime = new Date(new Date().getTime() - PRE_JOB_FALLBACK_OFFSET_MS).toISOString(); core.debug(`PreJobStartTime not defined, using now-10secs`); } core.info(`PreJobStartTime: ${startTime}`); @@ -86,47 +84,46 @@ export class ContainerMapping implements IMicrosoftSecurityDevOps { dockerEvents: [], dockerImages: [] }; - + let bearerToken: string | void = await core.getIDToken() .then((token) => { return token; }) .catch((error) => { - throw new Error("Unable to get token: " + error); + throw new Error(`Unable to get OIDC token. Ensure the workflow has 'id-token: write' permission. Details: ${error}`); }); if (!bearerToken) { - throw new Error("Empty OIDC token received"); + throw new Error("Empty OIDC token received. Ensure the workflow has 'id-token: write' permission."); } // Don't run the container mapping workload if this caller isn't an active customer. - var callerIsOnboarded: boolean = await this.checkCallerIsCustomer(bearerToken, sendReportRetryCount); + var callerIsOnboarded: boolean = await this.checkCallerIsCustomer(bearerToken, SEND_REPORT_RETRY_COUNT); if (!callerIsOnboarded) { - core.info("Client is not onboarded to Defender for DevOps. Skipping container mapping workload.") + core.warning("Client is not onboarded to Defender for DevOps. Skipping container mapping workload.") return; } core.info("Client is onboarded for container mapping."); - // Initialize the commands + // Initialize the commands let dockerVersionOutput = await exec.getExecOutput('docker --version'); - if (dockerVersionOutput.exitCode != 0) { - core.info(`Unable to get docker version: ${dockerVersionOutput}`); - core.info(`Skipping container mapping since docker not found/available.`); + if (dockerVersionOutput.exitCode !== 0) { + core.warning(`Docker not found or not available. Skipping container mapping. Exit code: ${dockerVersionOutput.exitCode}`); return; } reportData.dockerVersion = dockerVersionOutput.stdout.trim(); await this.execCommand(`docker events --since ${startTime} --until ${new Date().toISOString()} --filter event=push --filter type=image --format ID={{.ID}}`, reportData.dockerEvents) .catch((error) => { - throw new Error("Unable to get docker events: " + error); + throw new Error(`Unable to get docker events: ${error}`); }); await this.execCommand(`docker images --format CreatedAt={{.CreatedAt}}::Repo={{.Repository}}::Tag={{.Tag}}::Digest={{.Digest}}`, reportData.dockerImages) .catch((error) => { - throw new Error("Unable to get docker images: " + error); + throw new Error(`Unable to get docker images: ${error}`); }); core.debug("Finished data collection, starting API calls."); - var reportSent: boolean = await this.sendReport(JSON.stringify(reportData), bearerToken, sendReportRetryCount); + var reportSent: boolean = await this.sendReport(JSON.stringify(reportData), bearerToken, SEND_REPORT_RETRY_COUNT); if (!reportSent) { throw new Error("Unable to send report to backend service"); }; @@ -142,7 +139,7 @@ export class ContainerMapping implements IMicrosoftSecurityDevOps { private async execCommand(command: string, listener: string[]): Promise { return exec.getExecOutput(command) .then((result) => { - if(result.exitCode != 0) { + if(result.exitCode !== 0) { return Promise.reject(`Command execution failed: ${result}`); } result.stdout.trim().split(os.EOL).forEach(element => { @@ -160,14 +157,14 @@ export class ContainerMapping implements IMicrosoftSecurityDevOps { * @param bearerToken the GitHub-generated OIDC token * @returns a boolean Promise to indicate if the report was sent successfully or not */ - private async sendReport(data: string, bearerToken: string, retryCount: number = 0): Promise { + public async sendReport(data: string, bearerToken: string, retryCount: number = 0): Promise { core.debug(`attempting to send report: ${data}`); return await this._sendReport(data, bearerToken) .then(() => { return true; }) .catch(async (error) => { - if (retryCount == 0) { + if (retryCount === 0) { return false; } else { core.info(`Retrying API call due to error: ${error}.\nRetry count: ${retryCount}`); @@ -182,20 +179,20 @@ export class ContainerMapping implements IMicrosoftSecurityDevOps { * @param data the data to send * @returns a Promise */ - private async _sendReport(data: string, bearerToken: string): Promise { - return new Promise(async (resolve, reject) => { - let apiTime = new Date().getMilliseconds(); - let options = { - method: 'POST', - timeout: 2500, - headers: { - 'Content-Type': 'application/json', - 'Authorization': 'Bearer ' + bearerToken, - 'Content-Length': data.length - } - }; - core.debug(`${options['method'].toUpperCase()} ${ContainerMappingURL}`); + public async _sendReport(data: string, bearerToken: string): Promise { + const apiStartTime = new Date().getTime(); + const options = { + method: 'POST', + timeout: REQUEST_TIMEOUT_MS, + headers: { + 'Content-Type': 'application/json', + 'Authorization': `Bearer ${bearerToken}`, + 'Content-Length': data.length + } + }; + core.debug(`${options.method} ${ContainerMappingURL}`); + return new Promise((resolve, reject) => { const req = https.request(ContainerMappingURL, options, (res) => { let resData = ''; res.on('data', (chunk) => { @@ -203,11 +200,12 @@ export class ContainerMapping implements IMicrosoftSecurityDevOps { }); res.on('end', () => { - core.debug('API calls finished. Time taken: ' + (new Date().getMilliseconds() - apiTime) + "ms"); + const elapsed = new Date().getTime() - apiStartTime; + core.debug(`API calls finished. Time taken: ${elapsed}ms`); core.debug(`Status code: ${res.statusCode} ${res.statusMessage}`); - core.debug('Response headers: ' + JSON.stringify(res.headers)); + core.debug(`Response headers: ${JSON.stringify(res.headers)}`); if (resData.length > 0) { - core.debug('Response: ' + resData); + core.debug(`Response: ${resData}`); } if (res.statusCode < 200 || res.statusCode >= 300) { return reject(`Received Failed Status code when calling url: ${res.statusCode} ${resData}`); @@ -234,9 +232,9 @@ export class ContainerMapping implements IMicrosoftSecurityDevOps { private async checkCallerIsCustomer(bearerToken: string, retryCount: number = 0): Promise { return await this._checkCallerIsCustomer(bearerToken) .then(async (statusCode) => { - if (statusCode == 200) { // Status 'OK' means the caller is an onboarded customer. + if (statusCode === 200) { // Status 'OK' means the caller is an onboarded customer. return true; - } else if (statusCode == 403) { // Status 'Forbidden' means caller is not a customer. + } else if (statusCode === 403) { // Status 'Forbidden' means caller is not a customer. return false; } else { core.debug(`Unexpected status code: ${statusCode}`); @@ -250,7 +248,7 @@ export class ContainerMapping implements IMicrosoftSecurityDevOps { } private async retryCall(bearerToken: string, retryCount: number): Promise { - if (retryCount == 0) { + if (retryCount === 0) { core.info(`All retries failed.`); return false; } else { @@ -261,23 +259,23 @@ export class ContainerMapping implements IMicrosoftSecurityDevOps { } private async _checkCallerIsCustomer(bearerToken: string): Promise { - return new Promise(async (resolve, reject) => { - let options = { - method: 'GET', - timeout: 2500, - headers: { - 'Content-Type': 'application/json', - 'Authorization': 'Bearer ' + bearerToken, - } - }; - core.debug(`${options['method'].toUpperCase()} ${GetScanContextURL}`); + const options = { + method: 'GET', + timeout: REQUEST_TIMEOUT_MS, + headers: { + 'Content-Type': 'application/json', + 'Authorization': `Bearer ${bearerToken}`, + } + }; + core.debug(`${options.method} ${GetScanContextURL}`); + return new Promise((resolve, reject) => { const req = https.request(GetScanContextURL, options, (res) => { - res.on('end', () => { resolve(res.statusCode); }); - res.on('data', function(d) { + res.on('data', () => { + // consume data to trigger 'end' event }); }); @@ -289,4 +287,4 @@ export class ContainerMapping implements IMicrosoftSecurityDevOps { }); } -} \ No newline at end of file +} diff --git a/src/main.ts b/src/main.ts index 1f45f9d1..4c10dd08 100644 --- a/src/main.ts +++ b/src/main.ts @@ -26,7 +26,7 @@ function shouldRunMain() { let toolsString: string = core.getInput('tools'); if (!common.isNullOrWhiteSpace(toolsString)) { let tools = toolsString.split(','); - if (tools.length == 1 && tools[0].trim() == Tools.ContainerMapping) { + if (tools.length === 1 && tools[0].trim() === Tools.ContainerMapping) { return false; } } diff --git a/src/msdo-interface.ts b/src/msdo-interface.ts index af50977e..ed0fdc21 100644 --- a/src/msdo-interface.ts +++ b/src/msdo-interface.ts @@ -4,9 +4,9 @@ export interface IMicrosoftSecurityDevOps { readonly succeedOnError: boolean; /* param source - The source of the task: main, pre, or post. */ - runPreJob(): any; - runMain(): any; - runPostJob(): any; + runPreJob(): Promise; + runMain(): Promise; + runPostJob(): Promise; } /** @@ -26,4 +26,4 @@ export interface IMicrosoftSecurityDevOpsFactory { */ export function getExecutor(runner: IMicrosoftSecurityDevOpsFactory): IMicrosoftSecurityDevOps { return new runner(); -} \ No newline at end of file +} diff --git a/src/msdo.ts b/src/msdo.ts index de7afadb..c7056b6d 100644 --- a/src/msdo.ts +++ b/src/msdo.ts @@ -25,7 +25,7 @@ export class MicrosoftSecurityDevOps implements IMicrosoftSecurityDevOps { public async runMain() { core.debug('MicrosoftSecurityDevOps.runMain - Running MSDO...'); - let args: string[] = undefined; + let args: string[]; // Check job type - might be existing file let existingFilename = core.getInput('existingFilename'); @@ -83,9 +83,9 @@ export class MicrosoftSecurityDevOps implements IMicrosoftSecurityDevOps { let tool = tools[i]; let toolTrimmed = tool.trim(); if (!common.isNullOrWhiteSpace(tool) - && tool != Tools.ContainerMapping // This tool is not handled by this executor - && includedTools.indexOf(toolTrimmed) == -1) { - if (includedTools.length == 0) { + && tool !== Tools.ContainerMapping // This tool is not handled by this executor + && includedTools.indexOf(toolTrimmed) === -1) { + if (includedTools.length === 0) { args.push('--tool'); } args.push(toolTrimmed); diff --git a/src/post.ts b/src/post.ts index ab75224f..57a5283a 100644 --- a/src/post.ts +++ b/src/post.ts @@ -2,10 +2,10 @@ import * as core from '@actions/core'; import { ContainerMapping } from './container-mapping'; import { getExecutor } from './msdo-interface'; -async function runPost() { +export async function run() { await getExecutor(ContainerMapping).runPostJob(); } -runPost().catch((error) => { - core.debug(error); -}); \ No newline at end of file +run().catch((error) => { + core.warning(`Post-job failed: ${error}`); +}); diff --git a/src/pre.ts b/src/pre.ts index f717e43a..a2e40b71 100644 --- a/src/pre.ts +++ b/src/pre.ts @@ -2,10 +2,10 @@ import * as core from '@actions/core'; import { ContainerMapping } from './container-mapping'; import { getExecutor } from './msdo-interface'; -async function runPre() { +export async function run() { await getExecutor(ContainerMapping).runPreJob(); } -runPre().catch((error) => { - core.debug(error); -}); \ No newline at end of file +run().catch((error) => { + core.warning(`Pre-job failed: ${error}`); +}); diff --git a/test/post.tests.ts b/test/post.tests.ts index 8464a9f6..ace463aa 100644 --- a/test/post.tests.ts +++ b/test/post.tests.ts @@ -1,129 +1,75 @@ -import assert from 'assert'; -import https from 'https'; import sinon from 'sinon'; import * as core from '@actions/core'; -import * as exec from '@actions/exec'; -import { run, sendReport, _sendReport } from '../lib/post'; +import { ContainerMapping } from '../lib/container-mapping'; describe('postjob run', function() { - let execStub: sinon.SinonStub; - let sendReportStub: sinon.SinonStub; - beforeEach(() => { - execStub = sinon.stub(exec, 'exec'); - sendReportStub = sinon.stub(sendReport); + sinon.stub(core, 'info'); + sinon.stub(core, 'debug'); + sinon.stub(core, 'warning'); }); afterEach(() => { - execStub.restore(); - sendReport.restore(); + sinon.restore(); }); - it('should run three docker commands and send the report', async () => { - await run(); + it('should not throw even when post-job encounters errors', async () => { + sinon.stub(core, 'getState').returns(''); + sinon.stub(core, 'getIDToken').rejects(new Error('No OIDC token')); + + const cm = new ContainerMapping(); + // Should not throw because errors are caught inside runPostJob + await cm.runPostJob(); + }); + + it('should skip container mapping when client is not onboarded', async () => { + sinon.stub(core, 'getState').returns('2023-01-01T00:00:00.000Z'); + sinon.stub(core, 'getIDToken').resolves('mock-token'); + + // Mock _checkCallerIsCustomer to return 403 (not onboarded) + const cm = new ContainerMapping(); + sinon.stub(cm as any, '_checkCallerIsCustomer').resolves(403); - sinon.assert.callCount(execStub, 3); - sinon.assert.calledWith(execStub, 'docker --version'); - sinon.assert.calledWith(execStub, 'docker images --format CreatedAt={{.CreatedAt}}::Repo={{.Repository}}::Tag={{.Tag}}::Digest={{.Digest}}'); + await cm.runPostJob(); - sinon.assert.calledOnce(sendReport); + sinon.assert.calledWith(core.warning as sinon.SinonStub, sinon.match('not onboarded')); }); }); describe('postjob sendReport', function() { - let _sendReportStub: sinon.SinonStub; - let data: Object; + let cm: ContainerMapping; beforeEach(() => { - _sendReportStub = sinon.stub(_sendReport); - data = { - "key.fake": "value.fake" - }; - }); - - afterEach(() => { - _sendReportStub.restore(); + sinon.stub(core, 'info'); + sinon.stub(core, 'debug'); + cm = new ContainerMapping(); }); - it('should still call _sendReport once if retryCount < 1', async () => { - await sendReport(data, -1); - sinon.assert.calledOnce(_sendReport); + afterEach(() => { + sinon.restore(); }); - it('should succeed if _sendReport succeeds', async () => { - _sendReportStub.throws(new Error('_sendReport().Error')); + it('should return false when _sendReport fails and retryCount is 0', async () => { + sinon.stub(cm, '_sendReport').rejects(new Error('API error')); - await sendReport(data, 0); - sinon.assert.calledOnce(_sendReport); + const result = await cm.sendReport('{}', 'mock-token', 0); + sinon.assert.match(result, false); }); - it('should succeed if _sendReport succeeds', async () => { + it('should retry when _sendReport fails and retryCount > 0', async () => { + const sendReportStub = sinon.stub(cm, '_sendReport'); + sendReportStub.onFirstCall().rejects(new Error('API error')); + sendReportStub.onSecondCall().resolves(); - - await sendReport(data, 0); - sinon.assert.calledOnce(_sendReport); + const result = await cm.sendReport('{}', 'mock-token', 1); + sinon.assert.match(result, true); + sinon.assert.calledTwice(sendReportStub); }); - // should still call _sendReport once if retryCount < 1 - // should succeed if _sendReport succeeds - // should fail if _sendReport fails and retryCount == 0 - // should succeed if _sendReport fails the first time and succeeds the second if retryCount > 0 - // should fail if _sendReport fails for all retries + it('should return true when _sendReport succeeds', async () => { + sinon.stub(cm, '_sendReport').resolves(); -}); - - -describe('postjob _sendReport', function() { - let core_getIDTokenStub: sinon.SinonStub; - let https_requestStub: sinon.SinonStub; - let clientRequestStub; - let data: Object; - const expectedUrl = 'https://dfdinfra-afdendpoint2-dogfood-edb5h5g7gyg7h3hq.z01.azurefd.net/github/v1/container-mappings'; - - beforeEach(() => { - core_getIDTokenStub = sinon.stub(core, 'getIDToken'); - https_requestStub = sinon.stub(https, 'request'); - clientRequestStub = sinon.stub(); - clientRequestStub.end = sinon.stub(); - - core_getIDTokenStub.resolves('bearerToken.mock'); - https_requestStub - .callsArgWith(2, { - on: (event, callback) => { - if (event === 'data') { - callback(); - } else if (event === 'end') { - callback(); - } - }, - end: () => {} - }) - .returns(clientRequestStub); - - data = { - "key.fake": "value.fake" - }; + const result = await cm.sendReport('{}', 'mock-token', 0); + sinon.assert.match(result, true); }); - - afterEach(() => { - core_getIDTokenStub.restore(); - https_requestStub.restore(); - clientRequestStub.restore(); - }); - - it('should still call _sendReport once if retryCount < 1', async () => { - await _sendReport(data, -1); - sinon.assert.calledOnce(core_getIDTokenStub); - sinon.assert.calledOnce(https_requestStub); - - // { - // method: 'POST', - // timeout: 2500, - // headers: { - // 'Content-Type': 'application/json', - // 'Authorization': 'Bearer bearerToken.mock' - // }, - // data: data - // }; - }); -}); \ No newline at end of file +}); diff --git a/test/pre.tests.ts b/test/pre.tests.ts index 5bd2d553..7146e75c 100644 --- a/test/pre.tests.ts +++ b/test/pre.tests.ts @@ -1,27 +1,31 @@ import sinon from 'sinon'; import * as core from '@actions/core'; -import { run } from '../lib/pre'; +import { ContainerMapping } from '../lib/container-mapping'; describe('prejob run', () => { let saveStateStub: sinon.SinonStub; - let dateSub: sinon.SinonStub; beforeEach(() => { saveStateStub = sinon.stub(core, 'saveState'); - dateSub = sinon.stub(global, 'Date'); + sinon.stub(core, 'info'); }); afterEach(() => { - saveStateStub.restore(); + sinon.restore(); }); it('should save the current time as PreJobStartTime', async () => { - dateSub.returns({ - toISOString: () => '2023-01-23T45:12:34.567Z' - }); + const cm = new ContainerMapping(); + await cm.runPreJob(); - await run(); + sinon.assert.calledWith(saveStateStub, 'PreJobStartTime', sinon.match.string); + }); + + it('should succeed even if runPreJob throws internally', async () => { + saveStateStub.throws(new Error('saveState failed')); - sinon.assert.calledWithExactly(saveStateStub, 'PreJobStartTime', '2023-01-23T45:12:34.567Z'); + const cm = new ContainerMapping(); + // Should not throw because errors are caught inside runPreJob + await cm.runPreJob(); }); -}); \ No newline at end of file +});