[Fleet] sign unenroll action (#160932)

This commit is contained in:
Joey F. Poon 2023-07-13 09:33:18 -07:00 committed by GitHub
parent a17e85ec15
commit 2096290ac1
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
7 changed files with 242 additions and 48 deletions

View file

@ -407,5 +407,11 @@ export interface FleetServerAgentAction {
/** Trace id */
traceparent?: string | null;
// signed data + signature
signed?: {
data: string;
signature: string;
};
[k: string]: unknown;
}

View file

@ -178,8 +178,13 @@ export function createMessageSigningServiceMock(): MessageSigningServiceInterfac
return {
isEncryptionAvailable: true,
generateKeyPair: jest.fn(),
sign: jest.fn(),
getPublicKey: jest.fn(),
sign: jest.fn().mockImplementation((message: Record<string, unknown>) =>
Promise.resolve({
data: Buffer.from(JSON.stringify(message), 'utf8'),
signature: 'thisisasignature',
})
),
getPublicKey: jest.fn().mockResolvedValue('thisisapublickey'),
rotateKeyPair: jest.fn(),
};
}

View file

@ -438,19 +438,7 @@ describe('getFullAgentPolicy', () => {
});
it('should populate agent.protection and signed properties if encryption is available', async () => {
const mockContext = createAppContextStartContractMock();
mockContext.messageSigningService.sign = jest
.fn()
.mockImplementation((message: Record<string, unknown>) =>
Promise.resolve({
data: Buffer.from(JSON.stringify(message), 'utf8'),
signature: 'thisisasignature',
})
);
mockContext.messageSigningService.getPublicKey = jest
.fn()
.mockResolvedValue('thisisapublickey');
appContextService.start(mockContext);
appContextService.start(createAppContextStartContractMock());
mockAgentPolicy({});
const agentPolicy = await getFullAgentPolicy(savedObjectsClientMock.create(), 'agent-policy');
@ -461,7 +449,7 @@ describe('getFullAgentPolicy', () => {
signing_key: 'thisisapublickey',
});
expect(agentPolicy!.signed).toMatchObject({
data: 'eyJpZCI6ImFnZW50LXBvbGljeSIsImFnZW50Ijp7InByb3RlY3Rpb24iOnsiZW5hYmxlZCI6ZmFsc2UsInVuaW5zdGFsbF90b2tlbl9oYXNoIjoiIiwic2lnbmluZ19rZXkiOiJ0aGlzaXNhcHVibGlja2V5In19fQ==',
data: 'eyJpZCI6ImFnZW50LXBvbGljeSIsImFnZW50Ijp7ImZlYXR1cmVzIjp7fSwicHJvdGVjdGlvbiI6eyJlbmFibGVkIjpmYWxzZSwidW5pbnN0YWxsX3Rva2VuX2hhc2giOiIiLCJzaWduaW5nX2tleSI6InRoaXNpc2FwdWJsaWNrZXkifX0sImlucHV0cyI6W119',
signature: 'thisisasignature',
});
});

View file

@ -89,6 +89,15 @@ export async function getFullAgentPolicy(
})
);
const inputs = await storedPackagePoliciesToAgentInputs(
agentPolicy.package_policies as PackagePolicy[],
packageInfoCache,
getOutputIdForAgentPolicy(dataOutput)
);
const features = (agentPolicy.agent_features || []).reduce((acc, { name, ...featureConfig }) => {
acc[name] = featureConfig;
return acc;
}, {} as NonNullable<FullAgentPolicy['agent']>['features']);
const fullAgentPolicy: FullAgentPolicy = {
id: agentPolicy.id,
outputs: {
@ -102,11 +111,7 @@ export async function getFullAgentPolicy(
return acc;
}, {}),
},
inputs: await storedPackagePoliciesToAgentInputs(
agentPolicy.package_policies as PackagePolicy[],
packageInfoCache,
getOutputIdForAgentPolicy(dataOutput)
),
inputs,
secret_references: (agentPolicy?.package_policies || []).flatMap(
(policy) => policy.secret_references || []
),
@ -125,10 +130,7 @@ export async function getFullAgentPolicy(
metrics: agentPolicy.monitoring_enabled.includes(dataTypes.Metrics),
}
: { enabled: false, logs: false, metrics: false },
features: (agentPolicy.agent_features || []).reduce((acc, { name, ...featureConfig }) => {
acc[name] = featureConfig;
return acc;
}, {} as NonNullable<FullAgentPolicy['agent']>['features']),
features,
protection: {
enabled: agentPolicy.is_protected,
uninstall_token_hash: '',
@ -206,8 +208,15 @@ export async function getFullAgentPolicy(
const dataToSign = {
id: fullAgentPolicy.id,
agent: {
features,
protection: fullAgentPolicy.agent.protection,
},
inputs: inputs.map(({ id: inputId, name, revision, type }) => ({
id: inputId,
name,
revision,
type,
})),
};
const { data: signedData, signature } = await messageSigningService.sign(dataToSign);

View file

@ -7,9 +7,9 @@
import { elasticsearchServiceMock } from '@kbn/core/server/mocks';
import type { NewAgentAction } from '../../../common/types';
import type { NewAgentAction, AgentActionType } from '../../../common/types';
import { createAppContextStartContractMock } from '../../mocks';
import { createAppContextStartContractMock, type MockedFleetAppContext } from '../../mocks';
import { appContextService } from '../app_context';
import { auditLoggingService } from '../audit_logging';
@ -29,8 +29,11 @@ const mockedBulkUpdateAgents = bulkUpdateAgents as jest.MockedFunction<typeof bu
const mockedAuditLoggingService = auditLoggingService as jest.Mocked<typeof auditLoggingService>;
describe('Agent actions', () => {
let mockContext: MockedFleetAppContext;
beforeEach(async () => {
appContextService.start(createAppContextStartContractMock());
mockContext = createAppContextStartContractMock();
appContextService.start(mockContext);
});
afterEach(() => {
@ -66,6 +69,17 @@ describe('Agent actions', () => {
});
describe('createAgentAction', () => {
beforeEach(() => {
mockContext.messageSigningService.sign = jest
.fn()
.mockImplementation((message: Record<string, unknown>) =>
Promise.resolve({
data: Buffer.from(JSON.stringify(message), 'utf8'),
signature: 'thisisasignature',
})
);
});
it('should call audit logger', async () => {
const esClient = elasticsearchServiceMock.createInternalClient();
esClient.search.mockResolvedValue({
@ -93,6 +107,85 @@ describe('Agent actions', () => {
message: expect.stringMatching(/User created Fleet action/),
});
});
it.each(['UNENROLL', 'UPGRADE'] as AgentActionType[])(
'should sign %s action',
async (actionType: AgentActionType) => {
const esClient = elasticsearchServiceMock.createInternalClient();
esClient.search.mockResolvedValue({
hits: {
hits: [
{
_source: {
type: actionType,
action_id: 'action1',
agents: ['agent1', 'agent2'],
expiration: new Date().toISOString(),
},
},
],
},
} as any);
await createAgentAction(esClient, {
id: 'action1',
type: actionType,
agents: ['agent1'],
});
expect(esClient.create).toBeCalledWith(
expect.objectContaining({
body: expect.objectContaining({
signed: {
data: expect.any(String),
signature: expect.any(String),
},
}),
})
);
}
);
it.each([
'SETTINGS',
'POLICY_REASSIGN',
'CANCEL',
'FORCE_UNENROLL',
'UPDATE_TAGS',
'REQUEST_DIAGNOSTICS',
'POLICY_CHANGE',
'INPUT_ACTION',
] as AgentActionType[])('should not sign %s action', async (actionType: AgentActionType) => {
const esClient = elasticsearchServiceMock.createInternalClient();
esClient.search.mockResolvedValue({
hits: {
hits: [
{
_source: {
type: actionType,
action_id: 'action1',
agents: ['agent1', 'agent2'],
expiration: new Date().toISOString(),
},
},
],
},
} as any);
await createAgentAction(esClient, {
id: 'action1',
type: actionType,
agents: ['agent1'],
});
expect(esClient.create).toBeCalledWith(
expect.objectContaining({
body: expect.not.objectContaining({
signed: expect.any(Object),
}),
})
);
});
});
describe('bulkCreateAgentAction', () => {
@ -125,6 +218,75 @@ describe('Agent actions', () => {
});
}
});
it('should sign UNENROLL and UPGRADE actions', async () => {
const esClient = elasticsearchServiceMock.createInternalClient();
const newActions: NewAgentAction[] = (['UNENROLL', 'UPGRADE'] as AgentActionType[]).map(
(actionType, i) => {
const actionId = `action${i + 1}`;
return {
id: actionId,
type: actionType,
agents: [actionId],
};
}
);
await bulkCreateAgentActions(esClient, newActions);
expect(esClient.bulk).toHaveBeenCalledWith(
expect.objectContaining({
body: expect.arrayContaining([
expect.arrayContaining([
expect.objectContaining({
signed: {
data: expect.any(String),
signature: expect.any(String),
},
}),
]),
]),
})
);
});
it('should not sign actions other than UNENROLL and UPGRADE', async () => {
const esClient = elasticsearchServiceMock.createInternalClient();
const newActions: NewAgentAction[] = (
[
'SETTINGS',
'POLICY_REASSIGN',
'CANCEL',
'FORCE_UNENROLL',
'UPDATE_TAGS',
'REQUEST_DIAGNOSTICS',
'POLICY_CHANGE',
'INPUT_ACTION',
] as AgentActionType[]
).map((actionType, i) => {
const actionId = `action${i + 1}`;
return {
id: actionId,
type: actionType,
agents: [actionId],
};
});
await bulkCreateAgentActions(esClient, newActions);
expect(esClient.bulk).toHaveBeenCalledWith(
expect.objectContaining({
body: expect.arrayContaining([
expect.arrayContaining([
expect.not.objectContaining({
signed: {
data: expect.any(String),
signature: expect.any(String),
},
}),
]),
]),
})
);
});
});
describe('bulkCreateAgentActionResults', () => {

View file

@ -13,6 +13,7 @@ import { appContextService } from '../app_context';
import type {
Agent,
AgentAction,
AgentActionType,
NewAgentAction,
FleetServerAgentAction,
} from '../../../common/types/models';
@ -31,6 +32,8 @@ const ONE_MONTH_IN_MS = 2592000000;
export const NO_EXPIRATION = 'NONE';
const SIGNED_ACTIONS: Set<Partial<AgentActionType>> = new Set(['UNENROLL', 'UPGRADE']);
export async function createAgentAction(
esClient: ElasticsearchClient,
newAgentAction: NewAgentAction
@ -54,6 +57,15 @@ export async function createAgentAction(
traceparent: apm.currentTraceparent,
};
const messageSigningService = appContextService.getMessageSigningService();
if (SIGNED_ACTIONS.has(newAgentAction.type) && messageSigningService) {
const signedBody = await messageSigningService.sign(body);
body.signed = {
data: signedBody.data.toString('base64'),
signature: signedBody.signature,
};
}
await esClient.create({
index: AGENT_ACTIONS_INDEX,
id: uuidv4(),
@ -88,30 +100,41 @@ export async function bulkCreateAgentActions(
return [];
}
const messageSigningService = appContextService.getMessageSigningService();
await esClient.bulk({
index: AGENT_ACTIONS_INDEX,
body: actions.flatMap((action) => {
const body: FleetServerAgentAction = {
'@timestamp': new Date().toISOString(),
expiration: action.expiration ?? new Date(Date.now() + ONE_MONTH_IN_MS).toISOString(),
start_time: action.start_time,
rollout_duration_seconds: action.rollout_duration_seconds,
agents: action.agents,
action_id: action.id,
data: action.data,
type: action.type,
traceparent: apm.currentTraceparent,
};
body: await Promise.all(
actions.flatMap(async (action) => {
const body: FleetServerAgentAction = {
'@timestamp': new Date().toISOString(),
expiration: action.expiration ?? new Date(Date.now() + ONE_MONTH_IN_MS).toISOString(),
start_time: action.start_time,
rollout_duration_seconds: action.rollout_duration_seconds,
agents: action.agents,
action_id: action.id,
data: action.data,
type: action.type,
traceparent: apm.currentTraceparent,
};
return [
{
create: {
_id: action.id,
if (SIGNED_ACTIONS.has(action.type) && messageSigningService) {
const signedBody = await messageSigningService.sign(body);
body.signed = {
data: signedBody.data.toString('base64'),
signature: signedBody.signature,
};
}
return [
{
create: {
_id: action.id,
},
},
},
body,
];
}),
body,
];
})
),
});
for (const action of actions) {

View file

@ -21,6 +21,7 @@ jest.mock('../app_context', () => {
appContextService: {
getLogger: () => loggerMock.create(),
getConfig: () => {},
getMessageSigningService: jest.fn(),
},
};
});