Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
5 changes: 2 additions & 3 deletions src/MicrosoftSecurityDevOps/v1/container-mapping.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down
103 changes: 77 additions & 26 deletions test/MicrosoftSecurityDevOps/v1/container-mapping.tests.ts
Original file line number Diff line number Diff line change
@@ -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'));
Expand All @@ -13,6 +14,14 @@ describe('Container Mapping tests', function () {

var curTime = new Date().toISOString();

async function runTask(): Promise<ttm.MockTestRunner> {
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(() => {
Expand All @@ -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, []);
});
});
28 changes: 26 additions & 2 deletions test/MicrosoftSecurityDevOps/v1/msdoMock.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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');

Expand All @@ -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<string, TaskLibAnswerExecResult> = 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 {
Expand All @@ -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();
Loading