mirror of
https://github.com/elastic/kibana.git
synced 2025-04-23 01:13:23 -04:00
Include client IP address in audit log (#148055)
Follow up to #147526 which had to be reverted. Resolves #127481 ## Release notes Include IP address in audit log ## Testing 1. Start Elasticsearch with trial license: `yarn es snapshot --license trial` 2. Update `kibana.dev.yaml`: ```yaml xpack.security.audit.enabled: true xpack.security.audit.appender: type: console layout: type: json ``` 3. Observe audit logs in console when interacting with Kibana: ```json { "@timestamp": "2022-12-13T15:50:42.236+00:00", "message": "User is requesting [/dev/internal/security/me] endpoint", "client": { "ip": "127.0.0.1" }, "http": { "request": { "headers": { "x-forwarded-for": "1.1.1.1, 127.0.0.1" } } } } ``` Note: You will see the `x-forwarded-for` field populated when running Kibana in development mode (`yarn start`) since Kibana runs behind a development proxy. Co-authored-by: gchaps <33642766+gchaps@users.noreply.github.com> Co-authored-by: kibanamachine <42973632+kibanamachine@users.noreply.github.com>
This commit is contained in:
parent
1d0a8c9ddd
commit
ee6170be7a
12 changed files with 240 additions and 47 deletions
|
@ -407,11 +407,19 @@ Example: `[marketing]`
|
|||
| *Field*
|
||||
| *Description*
|
||||
|
||||
| `client.ip`
|
||||
| Client IP address.
|
||||
|
||||
| `http.request.method`
|
||||
| HTTP request method.
|
||||
|
||||
Example: `get`, `post`, `put`, `delete`
|
||||
|
||||
| `http.request.headers.x-forwarded-for`
|
||||
| `X-Forwarded-For` request header used to identify the originating client IP address when connecting through proxy servers.
|
||||
|
||||
Example: `161.66.20.177, 236.198.214.101`
|
||||
|
||||
| `url.domain`
|
||||
| Domain of the URL.
|
||||
|
||||
|
|
|
@ -153,6 +153,13 @@ describe('KibanaSocket', () => {
|
|||
});
|
||||
});
|
||||
|
||||
describe('remoteAddress', () => {
|
||||
it('mirrors the value of net.Socket instance', () => {
|
||||
const socket = new KibanaSocket({ remoteAddress: '1.1.1.1' } as Socket);
|
||||
expect(socket.remoteAddress).toBe('1.1.1.1');
|
||||
});
|
||||
});
|
||||
|
||||
describe('getFakeSocket', () => {
|
||||
it('returns a stub', async () => {
|
||||
const fakeSocket = KibanaSocket.getFakeSocket();
|
||||
|
|
|
@ -28,6 +28,10 @@ export class KibanaSocket implements IKibanaSocket {
|
|||
return this.socket instanceof TLSSocket ? this.socket.authorizationError : undefined;
|
||||
}
|
||||
|
||||
public get remoteAddress() {
|
||||
return this.socket.remoteAddress;
|
||||
}
|
||||
|
||||
constructor(private readonly socket: Socket) {}
|
||||
|
||||
getPeerCertificate(detailed: true): DetailedPeerCertificate | null;
|
||||
|
|
|
@ -51,4 +51,11 @@ export interface IKibanaSocket {
|
|||
* only when `authorized` is `false`.
|
||||
*/
|
||||
readonly authorizationError?: Error;
|
||||
|
||||
/**
|
||||
* The string representation of the remote IP address. For example,`'74.125.127.100'` or
|
||||
* `'2001:4860:a005::68'`. Value may be `undefined` if the socket is destroyed (for example, if
|
||||
* the client disconnected).
|
||||
*/
|
||||
readonly remoteAddress?: string;
|
||||
}
|
||||
|
|
|
@ -14,6 +14,77 @@ import type { EcsEventOutcome, EcsEventType, KibanaRequest, LogMeta } from '@kbn
|
|||
import type { AuthenticationProvider } from '../../common/model';
|
||||
import type { AuthenticationResult } from '../authentication/authentication_result';
|
||||
|
||||
/**
|
||||
* Audit kibana schema using ECS format
|
||||
*/
|
||||
export interface AuditKibana {
|
||||
/**
|
||||
* The ID of the space associated with this event.
|
||||
*/
|
||||
space_id?: string;
|
||||
/**
|
||||
* The ID of the user session associated with this event. Each login attempt
|
||||
* results in a unique session id.
|
||||
*/
|
||||
session_id?: string;
|
||||
/**
|
||||
* Saved object that was created, changed, deleted or accessed as part of this event.
|
||||
*/
|
||||
saved_object?: {
|
||||
type: string;
|
||||
id: string;
|
||||
};
|
||||
/**
|
||||
* Name of authentication provider associated with a login event.
|
||||
*/
|
||||
authentication_provider?: string;
|
||||
/**
|
||||
* Type of authentication provider associated with a login event.
|
||||
*/
|
||||
authentication_type?: string;
|
||||
/**
|
||||
* Name of Elasticsearch realm that has authenticated the user.
|
||||
*/
|
||||
authentication_realm?: string;
|
||||
/**
|
||||
* Name of Elasticsearch realm where the user details were retrieved from.
|
||||
*/
|
||||
lookup_realm?: string;
|
||||
/**
|
||||
* Set of space IDs that a saved object was shared to.
|
||||
*/
|
||||
add_to_spaces?: readonly string[];
|
||||
/**
|
||||
* Set of space IDs that a saved object was removed from.
|
||||
*/
|
||||
delete_from_spaces?: readonly string[];
|
||||
}
|
||||
|
||||
type EcsHttp = Required<LogMeta>['http'];
|
||||
type EcsRequest = Required<EcsHttp>['request'];
|
||||
|
||||
/**
|
||||
* Audit request schema using ECS format
|
||||
*/
|
||||
export interface AuditRequest extends EcsRequest {
|
||||
/**
|
||||
* HTTP request headers
|
||||
*/
|
||||
headers?: {
|
||||
'x-forwarded-for'?: string;
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Audit http schema using ECS format
|
||||
*/
|
||||
export interface AuditHttp extends EcsHttp {
|
||||
/**
|
||||
* HTTP request details
|
||||
*/
|
||||
request?: AuditRequest;
|
||||
}
|
||||
|
||||
/**
|
||||
* Audit event schema using ECS format: https://www.elastic.co/guide/en/ecs/1.12/index.html
|
||||
*
|
||||
|
@ -23,49 +94,20 @@ import type { AuthenticationResult } from '../authentication/authentication_resu
|
|||
* @public
|
||||
*/
|
||||
export interface AuditEvent extends LogMeta {
|
||||
/**
|
||||
* Log message
|
||||
*/
|
||||
message: string;
|
||||
kibana?: {
|
||||
/**
|
||||
* The ID of the space associated with this event.
|
||||
*/
|
||||
space_id?: string;
|
||||
/**
|
||||
* The ID of the user session associated with this event. Each login attempt
|
||||
* results in a unique session id.
|
||||
*/
|
||||
session_id?: string;
|
||||
/**
|
||||
* Saved object that was created, changed, deleted or accessed as part of this event.
|
||||
*/
|
||||
saved_object?: {
|
||||
type: string;
|
||||
id: string;
|
||||
};
|
||||
/**
|
||||
* Name of authentication provider associated with a login event.
|
||||
*/
|
||||
authentication_provider?: string;
|
||||
/**
|
||||
* Type of authentication provider associated with a login event.
|
||||
*/
|
||||
authentication_type?: string;
|
||||
/**
|
||||
* Name of Elasticsearch realm that has authenticated the user.
|
||||
*/
|
||||
authentication_realm?: string;
|
||||
/**
|
||||
* Name of Elasticsearch realm where the user details were retrieved from.
|
||||
*/
|
||||
lookup_realm?: string;
|
||||
/**
|
||||
* Set of space IDs that a saved object was shared to.
|
||||
*/
|
||||
add_to_spaces?: readonly string[];
|
||||
/**
|
||||
* Set of space IDs that a saved object was removed from.
|
||||
*/
|
||||
delete_from_spaces?: readonly string[];
|
||||
};
|
||||
|
||||
/**
|
||||
* Kibana specific fields
|
||||
*/
|
||||
kibana?: AuditKibana;
|
||||
|
||||
/**
|
||||
* Fields describing an HTTP request
|
||||
*/
|
||||
http?: AuditHttp;
|
||||
}
|
||||
|
||||
export interface HttpRequestParams {
|
||||
|
|
|
@ -5,8 +5,11 @@
|
|||
* 2.0.
|
||||
*/
|
||||
|
||||
import type { Socket } from 'net';
|
||||
import { lastValueFrom, Observable, of } from 'rxjs';
|
||||
|
||||
import type { FakeRawRequest } from '@kbn/core/server';
|
||||
import { CoreKibanaRequest } from '@kbn/core/server';
|
||||
import {
|
||||
coreMock,
|
||||
httpServerMock,
|
||||
|
@ -22,6 +25,7 @@ import {
|
|||
AuditService,
|
||||
createLoggingConfig,
|
||||
filterEvent,
|
||||
getForwardedFor,
|
||||
RECORD_USAGE_INTERVAL,
|
||||
} from './audit_service';
|
||||
|
||||
|
@ -173,7 +177,7 @@ describe('#setup', () => {
|
|||
});
|
||||
|
||||
describe('#asScoped', () => {
|
||||
it('logs event enriched with meta data', async () => {
|
||||
it('logs event enriched with meta data from request', async () => {
|
||||
const audit = new AuditService(logger);
|
||||
const auditSetup = audit.setup({
|
||||
license,
|
||||
|
@ -186,19 +190,78 @@ describe('#asScoped', () => {
|
|||
recordAuditLoggingUsage,
|
||||
});
|
||||
const request = httpServerMock.createKibanaRequest({
|
||||
socket: { remoteAddress: '3.3.3.3' } as Socket,
|
||||
headers: {
|
||||
'x-forwarded-for': '1.1.1.1, 2.2.2.2',
|
||||
},
|
||||
kibanaRequestState: { requestId: 'REQUEST_ID', requestUuid: 'REQUEST_UUID' },
|
||||
});
|
||||
|
||||
await auditSetup.asScoped(request).log({ message: 'MESSAGE', event: { action: 'ACTION' } });
|
||||
expect(logger.info).toHaveBeenCalledWith('MESSAGE', {
|
||||
await auditSetup.asScoped(request).log({
|
||||
message: 'MESSAGE',
|
||||
event: { action: 'ACTION' },
|
||||
http: { request: { method: 'GET' } },
|
||||
});
|
||||
expect(logger.info).toHaveBeenLastCalledWith('MESSAGE', {
|
||||
event: { action: 'ACTION' },
|
||||
kibana: { space_id: 'default', session_id: 'SESSION_ID' },
|
||||
trace: { id: 'REQUEST_ID' },
|
||||
client: { ip: '3.3.3.3' },
|
||||
http: {
|
||||
request: { method: 'GET', headers: { 'x-forwarded-for': '1.1.1.1, 2.2.2.2' } },
|
||||
},
|
||||
user: { id: 'uid', name: 'jdoe', roles: ['admin'] },
|
||||
});
|
||||
audit.stop();
|
||||
});
|
||||
|
||||
it('logs event enriched with meta data from fake request', async () => {
|
||||
const audit = new AuditService(logger);
|
||||
const auditSetup = audit.setup({
|
||||
license,
|
||||
config,
|
||||
logging,
|
||||
http,
|
||||
getCurrentUser,
|
||||
getSpaceId: () => undefined,
|
||||
getSID: () => Promise.resolve(undefined),
|
||||
recordAuditLoggingUsage,
|
||||
});
|
||||
|
||||
const fakeRawRequest: FakeRawRequest = {
|
||||
headers: {},
|
||||
path: '/',
|
||||
};
|
||||
const request = CoreKibanaRequest.from(fakeRawRequest);
|
||||
|
||||
await auditSetup.asScoped(request).log({
|
||||
message: 'MESSAGE',
|
||||
event: { action: 'ACTION' },
|
||||
});
|
||||
expect(logger.info).toHaveBeenLastCalledWith('MESSAGE', {
|
||||
client: {
|
||||
ip: undefined,
|
||||
},
|
||||
event: {
|
||||
action: 'ACTION',
|
||||
},
|
||||
http: undefined,
|
||||
kibana: {
|
||||
session_id: undefined,
|
||||
space_id: undefined,
|
||||
},
|
||||
trace: {
|
||||
id: expect.any(String),
|
||||
},
|
||||
user: {
|
||||
id: 'uid',
|
||||
name: 'jdoe',
|
||||
roles: ['admin'],
|
||||
},
|
||||
});
|
||||
audit.stop();
|
||||
});
|
||||
|
||||
it('does not log to audit logger if event matches ignore filter', async () => {
|
||||
const audit = new AuditService(logger);
|
||||
const auditSetup = audit.setup({
|
||||
|
@ -424,6 +487,32 @@ describe('#createLoggingConfig', () => {
|
|||
});
|
||||
});
|
||||
|
||||
describe('#getForwardedFor', () => {
|
||||
it('extracts x-forwarded-for header from request', () => {
|
||||
const request = httpServerMock.createKibanaRequest({
|
||||
headers: {
|
||||
'x-forwarded-for': '1.1.1.1',
|
||||
},
|
||||
});
|
||||
expect(getForwardedFor(request)).toBe('1.1.1.1');
|
||||
});
|
||||
|
||||
it('concatenates multiple headers into single string in correct order', () => {
|
||||
const request = httpServerMock.createKibanaRequest({
|
||||
headers: {
|
||||
// @ts-expect-error Headers can be arrays but HAPI mocks are incorrectly typed
|
||||
'x-forwarded-for': ['1.1.1.1, 2.2.2.2', '3.3.3.3'],
|
||||
},
|
||||
});
|
||||
expect(getForwardedFor(request)).toBe('1.1.1.1, 2.2.2.2, 3.3.3.3');
|
||||
});
|
||||
|
||||
it('returns undefined when header not present', () => {
|
||||
const request = httpServerMock.createKibanaRequest();
|
||||
expect(getForwardedFor(request)).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe('#filterEvent', () => {
|
||||
let event: AuditEvent;
|
||||
|
||||
|
|
|
@ -162,6 +162,8 @@ export class AuditService {
|
|||
const spaceId = getSpaceId(request);
|
||||
const user = getCurrentUser(request);
|
||||
const sessionId = await getSID(request);
|
||||
const forwardedFor = getForwardedFor(request);
|
||||
|
||||
log({
|
||||
...event,
|
||||
user:
|
||||
|
@ -177,6 +179,18 @@ export class AuditService {
|
|||
...event.kibana,
|
||||
},
|
||||
trace: { id: request.id },
|
||||
client: { ip: request.socket.remoteAddress },
|
||||
http: forwardedFor
|
||||
? {
|
||||
...event.http,
|
||||
request: {
|
||||
...event.http?.request,
|
||||
headers: {
|
||||
'x-forwarded-for': forwardedFor,
|
||||
},
|
||||
},
|
||||
}
|
||||
: event.http,
|
||||
});
|
||||
},
|
||||
enabled,
|
||||
|
@ -243,3 +257,16 @@ export function filterEvent(
|
|||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Extracts `X-Forwarded-For` header(s) from `KibanaRequest`.
|
||||
*/
|
||||
export function getForwardedFor(request: KibanaRequest) {
|
||||
const forwardedFor = request.headers['x-forwarded-for'];
|
||||
|
||||
if (Array.isArray(forwardedFor)) {
|
||||
return forwardedFor.join(', ');
|
||||
}
|
||||
|
||||
return forwardedFor;
|
||||
}
|
||||
|
|
|
@ -7,7 +7,7 @@
|
|||
|
||||
export type { AuditServiceSetup, AuditLogger } from './audit_service';
|
||||
export { AuditService } from './audit_service';
|
||||
export type { AuditEvent } from './audit_events';
|
||||
export type { AuditEvent, AuditHttp, AuditKibana, AuditRequest } from './audit_events';
|
||||
export {
|
||||
userLoginEvent,
|
||||
userLogoutEvent,
|
||||
|
|
|
@ -10,6 +10,7 @@ export type {
|
|||
InvalidateAPIKeyResult,
|
||||
CreateAPIKeyParams,
|
||||
InvalidateAPIKeysParams,
|
||||
ValidateAPIKeyParams,
|
||||
GrantAPIKeyResult,
|
||||
} from './api_keys';
|
||||
export { APIKeys, CreateApiKeyValidationError } from './api_keys';
|
||||
|
|
|
@ -32,5 +32,6 @@ export type {
|
|||
InvalidateAPIKeyResult,
|
||||
CreateAPIKeyParams,
|
||||
InvalidateAPIKeysParams,
|
||||
ValidateAPIKeyParams,
|
||||
GrantAPIKeyResult,
|
||||
} from './api_keys';
|
||||
|
|
|
@ -26,11 +26,12 @@ export type {
|
|||
InvalidateAPIKeysParams,
|
||||
InvalidateAPIKeyResult,
|
||||
GrantAPIKeyResult,
|
||||
ValidateAPIKeyParams,
|
||||
AuthenticationServiceStart,
|
||||
} from './authentication';
|
||||
export type { CheckPrivilegesPayload, CasesSupportedOperations } from './authorization';
|
||||
export type AuthorizationServiceSetup = SecurityPluginStart['authz'];
|
||||
export type { AuditLogger, AuditEvent } from './audit';
|
||||
export type { AuditLogger, AuditEvent, AuditHttp, AuditKibana, AuditRequest } from './audit';
|
||||
export type { SecurityPluginSetup, SecurityPluginStart };
|
||||
export type { AuthenticatedUser } from '../common/model';
|
||||
export { ROUTE_TAG_CAN_REDIRECT } from './routes/tags';
|
||||
|
|
|
@ -55,6 +55,7 @@ export default function ({ getService }: FtrProviderContext) {
|
|||
await supertest
|
||||
.post('/internal/security/login')
|
||||
.set('kbn-xsrf', 'xxx')
|
||||
.set('X-Forwarded-For', '1.1.1.1, 2.2.2.2')
|
||||
.send({
|
||||
providerType: 'basic',
|
||||
providerName: 'basic',
|
||||
|
@ -71,12 +72,15 @@ export default function ({ getService }: FtrProviderContext) {
|
|||
expect(loginEvent.event.outcome).to.be('success');
|
||||
expect(loginEvent.trace.id).to.be.ok();
|
||||
expect(loginEvent.user.name).to.be(username);
|
||||
expect(loginEvent.client.ip).to.be.ok();
|
||||
expect(loginEvent.http.request.headers['x-forwarded-for']).to.be('1.1.1.1, 2.2.2.2');
|
||||
});
|
||||
|
||||
it('logs audit events when failing to log in', async () => {
|
||||
await supertest
|
||||
.post('/internal/security/login')
|
||||
.set('kbn-xsrf', 'xxx')
|
||||
.set('X-Forwarded-For', '1.1.1.1, 2.2.2.2')
|
||||
.send({
|
||||
providerType: 'basic',
|
||||
providerName: 'basic',
|
||||
|
@ -93,6 +97,8 @@ export default function ({ getService }: FtrProviderContext) {
|
|||
expect(loginEvent.event.outcome).to.be('failure');
|
||||
expect(loginEvent.trace.id).to.be.ok();
|
||||
expect(loginEvent.user).not.to.be.ok();
|
||||
expect(loginEvent.client.ip).to.be.ok();
|
||||
expect(loginEvent.http.request.headers['x-forwarded-for']).to.be('1.1.1.1, 2.2.2.2');
|
||||
});
|
||||
});
|
||||
}
|
||||
|
|
Loading…
Add table
Add a link
Reference in a new issue