Include client IP address in audit log (#147526)

Resolves #127481

## Release notes

Include IP address in audit log

## Testing

1. Update `kibana.dev.yaml`:

```yaml
xpack.security.audit.enabled: true
xpack.security.audit.appender:
  type: console
  layout:
    type: json
```

2. 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>
This commit is contained in:
Thom Heymann 2022-12-16 22:54:38 +00:00 committed by GitHub
parent a2036f0ecf
commit a02c7dce50
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
8 changed files with 168 additions and 44 deletions

View file

@ -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.

View file

@ -152,4 +152,11 @@ describe('KibanaSocket', () => {
expect(socket.authorizationError).toBe(authorizationError);
});
});
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');
});
});
});

View file

@ -20,6 +20,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;

View file

@ -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;
}

View file

@ -14,6 +14,71 @@ 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 {
headers?: {
'x-forwarded-for'?: string;
};
}
/**
* Audit http schema using ECS format
*/
export interface AuditHttp extends EcsHttp {
request?: AuditRequest;
}
/**
* Audit event schema using ECS format: https://www.elastic.co/guide/en/ecs/1.12/index.html
*
@ -24,48 +89,8 @@ import type { AuthenticationResult } from '../authentication/authentication_resu
*/
export interface AuditEvent extends LogMeta {
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?: AuditKibana;
http?: AuditHttp;
}
export interface HttpRequestParams {

View file

@ -5,6 +5,7 @@
* 2.0.
*/
import type { Socket } from 'net';
import { lastValueFrom, Observable, of } from 'rxjs';
import {
@ -22,6 +23,7 @@ import {
AuditService,
createLoggingConfig,
filterEvent,
getForwardedFor,
RECORD_USAGE_INTERVAL,
} from './audit_service';
@ -186,14 +188,26 @@ 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();
@ -424,6 +438,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;

View file

@ -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;
}

View file

@ -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');
});
});
}