From 97b11d222c3e683dbc02c531f40de8f52114d8e4 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sun, 18 Oct 2026 10:22:29 +0000 Subject: [PATCH 1/2] Initial plan From 4c4dd80f24d13c275e313ba51c300058a28c369b Mon Sep 17 00:00:00 2001 From: DimaBir <28827735+DimaBir@users.noreply.github.com> Date: Sun, 18 Oct 2026 10:40:38 +0000 Subject: [PATCH 2/2] Fix Docker event IDs and surface container mapping warnings Use Actor.ID to support current Docker event messages without changing report data. Keep mapping failures non-blocking but visible as pipeline warnings, and cover the behavior with isolated async tests. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- README.md | 1 + .../v1/container-mapping.ts | 5 +- .../v1/container-mapping.tests.ts | 103 +++++++++++++----- test/MicrosoftSecurityDevOps/v1/msdoMock.ts | 28 ++++- 4 files changed, 106 insertions(+), 31 deletions(-) diff --git a/README.md b/README.md index 987362e..3f7f53f 100644 --- a/README.md +++ b/README.md @@ -11,6 +11,7 @@ An extension for Azure DevOps that contributes a build task to run the [Microsof * Build breaks and more * Captures the [container images pushed in a build run](https://learn.microsoft.com/azure/defender-for-cloud/container-image-mapping) * In Azure DevOps, this requires the [Microsoft Defender for DevOps Container Mapping extension](https://marketplace.visualstudio.com/items?itemName=ms-securitydevops.ms-dfd-code-to-cloud) to extract metadata from pipelines, such as the container's digest ID and name, for connecting DevOps entities with their related cloud resources. This extension is automatically shared and installed with organizations that are [connected to Microsoft Defender for Cloud](https://learn.microsoft.com/azure/defender-for-cloud/quickstart-onboard-devops). If an organization is not connected to Defender for Cloud, there is **no way** to configure this functionality as it does not work with manual modification of the Microsoft Security DevOps extension. Attempting to configure it through Microsoft Security DevOps may cause unexpected issues. + * Container mapping reads pushed image IDs from Docker events using `Actor.ID`, which is supported by both older Docker versions and Docker 29 and later. If container mapping fails, the injected task emits a pipeline warning without failing the job. ## Basic diff --git a/src/MicrosoftSecurityDevOps/v1/container-mapping.ts b/src/MicrosoftSecurityDevOps/v1/container-mapping.ts index 0d557be..b6e9476 100644 --- a/src/MicrosoftSecurityDevOps/v1/container-mapping.ts +++ b/src/MicrosoftSecurityDevOps/v1/container-mapping.ts @@ -48,7 +48,7 @@ export class ContainerMapping implements IMicrosoftSecurityDevOps { // Initialize the commands let dockerVersionCmd = new CommandExecutor('docker', '--version'); - let eventsCmd = new CommandExecutor('docker', `events --since ${startTime} --until ${new Date().toISOString()} --filter event=push --filter type=image --format ID={{.ID}}`); + let eventsCmd = new CommandExecutor('docker', `events --since ${startTime} --until ${new Date().toISOString()} --filter event=push --filter type=image --format ID={{.Actor.ID}}`); let imagesCmd = new CommandExecutor('docker', 'images --format CreatedAt={{.CreatedAt}}::Repo={{.Repository}}::Tag={{.Tag}}::Digest={{.Digest}}'); // Execute all commands in parallel @@ -235,8 +235,7 @@ export class ContainerMapping implements IMicrosoftSecurityDevOps { } } catch (error) { - // Log the error - writeToOutStream("Error in Container Mapping: " + error); + tl.warning("Error in Container Mapping: " + error); } finally { // End the collapsible section diff --git a/test/MicrosoftSecurityDevOps/v1/container-mapping.tests.ts b/test/MicrosoftSecurityDevOps/v1/container-mapping.tests.ts index f0f93a7..cedc0e2 100644 --- a/test/MicrosoftSecurityDevOps/v1/container-mapping.tests.ts +++ b/test/MicrosoftSecurityDevOps/v1/container-mapping.tests.ts @@ -1,6 +1,7 @@ import { stagingDirectory, TestConstants } from '../../testCommon'; import * as path from 'path'; import * as assert from 'assert'; +import * as os from 'os'; import * as ttm from 'azure-pipelines-task-lib/mock-test'; import { TaskLibAnswerExecResult } from 'azure-pipelines-task-lib/mock-answer'; const helpers = require(path.join(stagingDirectory, 'MicrosoftSecurityDevOps', 'v1', 'msdo-helpers')); @@ -13,6 +14,14 @@ describe('Container Mapping tests', function () { var curTime = new Date().toISOString(); + async function runTask(): Promise { + const tr = new ttm.MockTestRunner(conMapTestFile); + tr.nodePath = process.execPath; + await tr.runAsync(); + assert.equal(tr.stderr, '', 'should not write errors:\n' + tr.stderr); + return tr; + } + // Set the trace to true before running the tests. // This will enable debug messages to be logged in a file. this.beforeAll(() => { @@ -26,66 +35,108 @@ describe('Container Mapping tests', function () { delete process.env[TestConstants.InputPrefix + Inputs.CommandType]; }); - it('should process pre-job', function(done: Mocha.Done) { + it('should process pre-job', async function() { process.env[TestConstants.InputPrefix + Inputs.CommandType] = CommandType.PreJob; - let tr: ttm.MockTestRunner = new ttm.MockTestRunner(conMapTestFile); - tr.run(); + let tr = await runTask(); assert.equal(tr.succeeded, true, 'should have succeeded:\n' + tr.stdout); assert.equal(tr.stdOutContained('vso[task.setvariable variable='+ helpers.Constants.PreJobStartTime +';'), true, "variable not set:\n" + tr.stdout); - done(); + assert.deepEqual(tr.warningIssues, []); }); - it('should process post-job', function(done: Mocha.Done) { + it('should process post-job', async function() { process.env[TestConstants.InputPrefix + Inputs.CommandType] = CommandType.PostJob; process.env[helpers.Constants.PreJobStartTime] = curTime; - let tr: ttm.MockTestRunner = new ttm.MockTestRunner(conMapTestFile); - tr.run(); + let tr = await runTask(); assert.equal(tr.succeeded, true, 'should have succeeded:\n' + tr.stdout); assert.equal(tr.stdOutContained('##vso[task.debug]'+ helpers.Constants.PreJobStartTime +'='+curTime), true, "Task should have been processed:\n" + tr.stdout); - done(); + assert.equal(tr.stdOutContained('Container Mapping data sent successfully'), true, tr.stdout); + assert.deepEqual(tr.warningIssues, []); }); - it('should always succeed if pre or post job', function(done: Mocha.Done) { + it('should warn and still succeed when Docker events fail', async function() { process.env[TestConstants.InputPrefix + Inputs.CommandType] = CommandType.PostJob; let mockResponse: TaskLibAnswerExecResult = { code: 100, stdout: TestConstants.Error + " Exit Code: 100" }; - process.env[TestConstants.MockResponse] = JSON.stringify(mockResponse); + process.env[TestConstants.MockResponse] = JSON.stringify({events: mockResponse}); + process.env[helpers.Constants.PreJobStartTime] = curTime; + let tr = await runTask(); + assert.equal(tr.succeeded, true, 'should have succeeded:\n' + tr.stdout); + assert.deepEqual(tr.warningIssues, [ + 'Error in Container Mapping: Error: Unable to fetch Docker events: Error Exit Code: 100' + ]); + assert.deepEqual(tr.errorIssues, []); + assert.equal(tr.stdOutContained('Container Mapping data sent successfully'), false, tr.stdout); + assert.equal(tr.stdOutContained('##[endgroup]'), true, tr.stdout); + }); + + it('should warn and still succeed when Docker images fail', async function() { + process.env[TestConstants.InputPrefix + Inputs.CommandType] = CommandType.PostJob; + process.env[helpers.Constants.PreJobStartTime] = curTime; + process.env[TestConstants.MockResponse] = JSON.stringify({ + images: {code: 1, stdout: 'Unable to connect to Docker'} + }); + let tr = await runTask(); + assert.equal(tr.succeeded, true, 'should have succeeded:\n' + tr.stdout); + assert.deepEqual(tr.warningIssues, [ + 'Error in Container Mapping: Error: Unable to fetch Docker images: Unable to connect to Docker' + ]); + assert.deepEqual(tr.errorIssues, []); + assert.equal(tr.stdOutContained('Container Mapping data sent successfully'), false, tr.stdout); + assert.equal(tr.stdOutContained('##[endgroup]'), true, tr.stdout); + }); + + it('should request Actor IDs and preserve the event report format', async function() { + process.env[TestConstants.InputPrefix + Inputs.CommandType] = CommandType.PostJob; process.env[helpers.Constants.PreJobStartTime] = curTime; - let tr: ttm.MockTestRunner = new ttm.MockTestRunner(conMapTestFile); - tr.run(); + const dockerEvents = ['ID=sha256:first', 'ID=sha256:second']; + process.env[TestConstants.MockResponse] = JSON.stringify({ + events: {code: 0, stdout: dockerEvents.join(os.EOL)} + }); + let tr = await runTask(); assert.equal(tr.succeeded, true, 'should have succeeded:\n' + tr.stdout); - assert.equal(tr.stdOutContained('Exit Code: 100'), true, "Exit code not present:\n" + tr.stdout); - done(); + assert.match(tr.stdout, new RegExp( + `docker events --since ${curTime} --until \\S+ --filter event=push --filter type=image --format ID=\\{\\{\\.Actor\\.ID\\}\\}` + )); + assert.equal(tr.stdOutContained('--format ID={{.ID}}'), false, tr.stdout); + assert.equal(tr.stdOutContained(helpers.getEncodedContent( + TestConstants.Success, dockerEvents.join(os.EOL), TestConstants.Success + )), true, tr.stdout); + assert.equal(tr.stdOutContained(JSON.stringify({ + dockerVersion: TestConstants.Success, + dockerEvents, + dockerImages: [TestConstants.Success] + })), true, tr.stdout); + assert.equal(tr.stdOutContained('Container Mapping data sent successfully'), true, tr.stdout); + assert.deepEqual(tr.warningIssues, []); }); - it('should encode post-job output', function(done: Mocha.Done) { + it('should encode post-job output', async function() { process.env[TestConstants.InputPrefix + Inputs.CommandType] = CommandType.PostJob; process.env[helpers.Constants.PreJobStartTime] = curTime; - let tr: ttm.MockTestRunner = new ttm.MockTestRunner(conMapTestFile); - tr.run(); + let tr = await runTask(); var expectedEncodedOutput = helpers.getEncodedContent(TestConstants.Success, TestConstants.Success, TestConstants.Success); assert.equal(tr.succeeded, true, 'should have succeeded:\n' + tr.stdout); assert.equal(tr.stdOutContained(expectedEncodedOutput), true, "Output should have been encoded:\n" + tr.stdout); - done(); }); - it('should not fetch images if no events', function(done: Mocha.Done) { + it('should report no events without a warning', async function() { process.env[TestConstants.InputPrefix + Inputs.CommandType] = CommandType.PostJob; process.env[helpers.Constants.PreJobStartTime] = curTime; let mockResponse: TaskLibAnswerExecResult = { - code: 0 + code: 0, + stdout: '' }; - process.env[TestConstants.MockResponse] = JSON.stringify(mockResponse); - let tr: ttm.MockTestRunner = new ttm.MockTestRunner(conMapTestFile); - tr.run(); - var expectedEncodedOutput = helpers.getEncodedContent(undefined, '', ''); + process.env[TestConstants.MockResponse] = JSON.stringify({events: mockResponse}); + let tr = await runTask(); + var expectedEncodedOutput = helpers.getEncodedContent(TestConstants.Success, '', ''); assert.equal(tr.succeeded, true, 'should have succeeded:\n' + tr.stdout); - assert.equal(tr.stdOutContained('code=NoDockerEvents'), true, "Issue not reported:\n" + tr.stdout); + assert.equal(tr.stdOutContained('type=NoDockerEvents'), true, "Timeline detail not reported:\n" + tr.stdout); assert.equal(tr.stdOutContained(expectedEncodedOutput), true, "Encoded output not present:\n" + tr.stdout); - done(); + assert.equal(tr.stdOutContained('Container Mapping data sent successfully'), true, tr.stdout); + assert.deepEqual(tr.warningIssues, []); }); }); \ No newline at end of file diff --git a/test/MicrosoftSecurityDevOps/v1/msdoMock.ts b/test/MicrosoftSecurityDevOps/v1/msdoMock.ts index 82f0de0..ecf2dbe 100644 --- a/test/MicrosoftSecurityDevOps/v1/msdoMock.ts +++ b/test/MicrosoftSecurityDevOps/v1/msdoMock.ts @@ -2,6 +2,7 @@ import { stagingDirectory, TestConstants } from '../../testCommon'; import { TaskLibAnswerExecResult } from 'azure-pipelines-task-lib/mock-answer'; import * as tmrm from 'azure-pipelines-task-lib/mock-run'; import * as path from 'path'; +import { Readable, Writable } from 'stream'; let taskPath = path.join(stagingDirectory, 'MicrosoftSecurityDevOps', 'v1', 'index.js'); @@ -13,11 +14,18 @@ let successResponse: TaskLibAnswerExecResult = { }; let response: string = process.env[TestConstants.MockResponse]; -let mockResponse: TaskLibAnswerExecResult = response ? JSON.parse(response) : successResponse; +let mockResponses: Record = response ? JSON.parse(response) : {}; class ExecutorMock { + private readonly response: TaskLibAnswerExecResult; + + constructor(toolName: string, argLine: string) { + console.log(`[command]${toolName} ${argLine}`); + this.response = mockResponses[argLine.split(' ')[0]] || successResponse; + } + public async execute() { - return Promise.resolve({code: mockResponse.code, output: mockResponse.stdout}); + return {code: this.response.code, output: this.response.stdout}; } public static removeCommandFromOutput(output: string) : string { @@ -29,4 +37,20 @@ tmr.registerMock('./command-executor', { CommandExecutor: ExecutorMock }); +tmr.registerMock('https', { + request: (_uri, _options, callback) => new Writable({ + write(_chunk, _encoding, done) { + done(); + }, + final(done) { + const response = Object.assign( + Readable.from([JSON.stringify({oidcToken: 'test-oidc-token'})]), + {statusCode: 200, statusMessage: 'OK'} + ); + callback(response); + done(); + } + }) +}); + tmr.run();