[Endpoint] Sample data generator for endpoint app (#58936)
* scaffolding and notes.md * add skeleton event generator to kibana * add optional entityID param to generateEvent * add tree generation * add tests * working tests * fix up tests * fix linting * fix event types * make process parent types consistent * make generator match types * move test resolver node out of common types * fix random string generation * fix typecheck errors * remove extraneous stuff * address PR comments * add test for full resolver tree * cleanup * make tests clearer * add seedrandom to endpoint plugin. contains DONOTMERGE example code * remove robs test * start replacing random with seedrandom * use seeded random for uuidv4 * separate out IP randomization * typecheck fixes Co-authored-by: oatkiller <robert.austin@elastic.co> Co-authored-by: Elastic Machine <elasticmachine@users.noreply.github.com>
This commit is contained in:
parent
0ed7176e4d
commit
435cb0b959
168
x-pack/plugins/endpoint/common/generate_data.test.ts
Normal file
168
x-pack/plugins/endpoint/common/generate_data.test.ts
Normal file
|
@ -0,0 +1,168 @@
|
|||
/*
|
||||
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
|
||||
* or more contributor license agreements. Licensed under the Elastic License;
|
||||
* you may not use this file except in compliance with the Elastic License.
|
||||
*/
|
||||
import { EndpointDocGenerator, Event } from './generate_data';
|
||||
|
||||
interface Node {
|
||||
events: Event[];
|
||||
children: Node[];
|
||||
parent_entity_id?: string;
|
||||
}
|
||||
|
||||
describe('data generator', () => {
|
||||
let generator: EndpointDocGenerator;
|
||||
beforeEach(() => {
|
||||
generator = new EndpointDocGenerator('seed');
|
||||
});
|
||||
|
||||
it('creates the same documents with same random seed', () => {
|
||||
const generator1 = new EndpointDocGenerator('seed');
|
||||
const generator2 = new EndpointDocGenerator('seed');
|
||||
const timestamp = new Date().getTime();
|
||||
const metadata1 = generator1.generateEndpointMetadata(timestamp);
|
||||
const metadata2 = generator2.generateEndpointMetadata(timestamp);
|
||||
expect(metadata1).toEqual(metadata2);
|
||||
});
|
||||
|
||||
it('creates different documents with different random seeds', () => {
|
||||
const generator1 = new EndpointDocGenerator('seed');
|
||||
const generator2 = new EndpointDocGenerator('different seed');
|
||||
const timestamp = new Date().getTime();
|
||||
const metadata1 = generator1.generateEndpointMetadata(timestamp);
|
||||
const metadata2 = generator2.generateEndpointMetadata(timestamp);
|
||||
expect(metadata1).not.toEqual(metadata2);
|
||||
});
|
||||
|
||||
it('creates endpoint metadata documents', () => {
|
||||
const timestamp = new Date().getTime();
|
||||
const metadata = generator.generateEndpointMetadata(timestamp);
|
||||
expect(metadata['@timestamp']).toEqual(timestamp);
|
||||
expect(metadata.event.created).toEqual(timestamp);
|
||||
expect(metadata.endpoint).not.toBeNull();
|
||||
expect(metadata.agent).not.toBeNull();
|
||||
expect(metadata.host).not.toBeNull();
|
||||
});
|
||||
|
||||
it('creates alert event documents', () => {
|
||||
const timestamp = new Date().getTime();
|
||||
const alert = generator.generateAlert(timestamp);
|
||||
expect(alert['@timestamp']).toEqual(timestamp);
|
||||
expect(alert.event.action).not.toBeNull();
|
||||
expect(alert.endpoint).not.toBeNull();
|
||||
expect(alert.agent).not.toBeNull();
|
||||
expect(alert.host).not.toBeNull();
|
||||
expect(alert.process.entity_id).not.toBeNull();
|
||||
});
|
||||
|
||||
it('creates process event documents', () => {
|
||||
const timestamp = new Date().getTime();
|
||||
const processEvent = generator.generateEvent({ timestamp });
|
||||
expect(processEvent['@timestamp']).toEqual(timestamp);
|
||||
expect(processEvent.event.category).toEqual('process');
|
||||
expect(processEvent.event.kind).toEqual('event');
|
||||
expect(processEvent.event.type).toEqual('creation');
|
||||
expect(processEvent.agent).not.toBeNull();
|
||||
expect(processEvent.host).not.toBeNull();
|
||||
expect(processEvent.process.entity_id).not.toBeNull();
|
||||
});
|
||||
|
||||
it('creates other event documents', () => {
|
||||
const timestamp = new Date().getTime();
|
||||
const processEvent = generator.generateEvent({ timestamp, eventCategory: 'dns' });
|
||||
expect(processEvent['@timestamp']).toEqual(timestamp);
|
||||
expect(processEvent.event.category).toEqual('dns');
|
||||
expect(processEvent.event.kind).toEqual('event');
|
||||
expect(processEvent.event.type).toEqual('creation');
|
||||
expect(processEvent.agent).not.toBeNull();
|
||||
expect(processEvent.host).not.toBeNull();
|
||||
expect(processEvent.process.entity_id).not.toBeNull();
|
||||
});
|
||||
|
||||
describe('creates alert ancestor tree', () => {
|
||||
let events: Event[];
|
||||
|
||||
beforeEach(() => {
|
||||
events = generator.generateAlertEventAncestry(3);
|
||||
});
|
||||
|
||||
it('with n-1 process events', () => {
|
||||
for (let i = 1; i < events.length - 1; i++) {
|
||||
expect(events[i].process.parent?.entity_id).toEqual(events[i - 1].process.entity_id);
|
||||
expect(events[i].event.kind).toEqual('event');
|
||||
expect(events[i].event.category).toEqual('process');
|
||||
}
|
||||
});
|
||||
|
||||
it('with a corresponding alert at the end', () => {
|
||||
// The alert should be last and have the same entity_id as the previous process event
|
||||
expect(events[events.length - 1].process.entity_id).toEqual(
|
||||
events[events.length - 2].process.entity_id
|
||||
);
|
||||
expect(events[events.length - 1].process.parent?.entity_id).toEqual(
|
||||
events[events.length - 2].process.parent?.entity_id
|
||||
);
|
||||
expect(events[events.length - 1].event.kind).toEqual('alert');
|
||||
expect(events[events.length - 1].event.category).toEqual('malware');
|
||||
});
|
||||
});
|
||||
|
||||
function buildResolverTree(events: Event[]): Node {
|
||||
// First pass we gather up all the events by entity_id
|
||||
const tree: Record<string, Node> = {};
|
||||
events.forEach(event => {
|
||||
if (event.process.entity_id in tree) {
|
||||
tree[event.process.entity_id].events.push(event);
|
||||
} else {
|
||||
tree[event.process.entity_id] = {
|
||||
events: [event],
|
||||
children: [],
|
||||
parent_entity_id: event.process.parent?.entity_id,
|
||||
};
|
||||
}
|
||||
});
|
||||
// Second pass add child references to each node
|
||||
for (const value of Object.values(tree)) {
|
||||
if (value.parent_entity_id) {
|
||||
tree[value.parent_entity_id].children.push(value);
|
||||
}
|
||||
}
|
||||
// The root node must be first in the array or this fails
|
||||
return tree[events[0].process.entity_id];
|
||||
}
|
||||
|
||||
function countResolverEvents(rootNode: Node, generations: number): number {
|
||||
// Start at the root, traverse N levels of the tree and check that we found all nodes
|
||||
let nodes = [rootNode];
|
||||
let visitedEvents = 0;
|
||||
for (let i = 0; i < generations + 1; i++) {
|
||||
let nextNodes: Node[] = [];
|
||||
nodes.forEach(node => {
|
||||
nextNodes = nextNodes.concat(node.children);
|
||||
visitedEvents += node.events.length;
|
||||
});
|
||||
nodes = nextNodes;
|
||||
}
|
||||
return visitedEvents;
|
||||
}
|
||||
|
||||
it('creates tree of process children', () => {
|
||||
const timestamp = new Date().getTime();
|
||||
const root = generator.generateEvent({ timestamp });
|
||||
const generations = 2;
|
||||
const events = generator.generateDescendantsTree(root, generations);
|
||||
const rootNode = buildResolverTree(events);
|
||||
const visitedEvents = countResolverEvents(rootNode, generations);
|
||||
expect(visitedEvents).toEqual(events.length);
|
||||
});
|
||||
|
||||
it('creates full resolver tree', () => {
|
||||
const alertAncestors = 3;
|
||||
const generations = 2;
|
||||
const events = generator.generateFullResolverTree(alertAncestors, generations);
|
||||
const rootNode = buildResolverTree(events);
|
||||
const visitedEvents = countResolverEvents(rootNode, alertAncestors + generations);
|
||||
expect(visitedEvents).toEqual(events.length);
|
||||
});
|
||||
});
|
431
x-pack/plugins/endpoint/common/generate_data.ts
Normal file
431
x-pack/plugins/endpoint/common/generate_data.ts
Normal file
|
@ -0,0 +1,431 @@
|
|||
/*
|
||||
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
|
||||
* or more contributor license agreements. Licensed under the Elastic License;
|
||||
* you may not use this file except in compliance with the Elastic License.
|
||||
*/
|
||||
|
||||
import uuid from 'uuid';
|
||||
import seedrandom from 'seedrandom';
|
||||
import { AlertEvent, EndpointEvent, EndpointMetadata, OSFields } from './types';
|
||||
|
||||
export type Event = AlertEvent | EndpointEvent;
|
||||
|
||||
interface EventOptions {
|
||||
timestamp?: number;
|
||||
entityID?: string;
|
||||
parentEntityID?: string;
|
||||
eventType?: string;
|
||||
eventCategory?: string;
|
||||
}
|
||||
|
||||
const Windows: OSFields[] = [
|
||||
{
|
||||
name: 'windows 10.0',
|
||||
full: 'Windows 10',
|
||||
version: '10.0',
|
||||
variant: 'Windows Pro',
|
||||
},
|
||||
{
|
||||
name: 'windows 10.0',
|
||||
full: 'Windows Server 2016',
|
||||
version: '10.0',
|
||||
variant: 'Windows Server',
|
||||
},
|
||||
{
|
||||
name: 'windows 6.2',
|
||||
full: 'Windows Server 2012',
|
||||
version: '6.2',
|
||||
variant: 'Windows Server',
|
||||
},
|
||||
{
|
||||
name: 'windows 6.3',
|
||||
full: 'Windows Server 2012R2',
|
||||
version: '6.3',
|
||||
variant: 'Windows Server Release 2',
|
||||
},
|
||||
];
|
||||
|
||||
const Linux: OSFields[] = [];
|
||||
|
||||
const Mac: OSFields[] = [];
|
||||
|
||||
const OS: OSFields[] = [...Windows, ...Mac, ...Linux];
|
||||
|
||||
const POLICIES: Array<{ name: string; id: string }> = [
|
||||
{
|
||||
name: 'Default',
|
||||
id: '00000000-0000-0000-0000-000000000000',
|
||||
},
|
||||
{
|
||||
name: 'With Eventing',
|
||||
id: 'C2A9093E-E289-4C0A-AA44-8C32A414FA7A',
|
||||
},
|
||||
];
|
||||
|
||||
const FILE_OPERATIONS: string[] = ['creation', 'open', 'rename', 'execution', 'deletion'];
|
||||
|
||||
// These are from the v1 schemas and aren't all valid ECS event categories, still in flux
|
||||
const OTHER_EVENT_CATEGORIES: string[] = ['driver', 'file', 'library', 'network', 'registry'];
|
||||
|
||||
export class EndpointDocGenerator {
|
||||
agentId: string;
|
||||
hostId: string;
|
||||
hostname: string;
|
||||
macAddress: string[];
|
||||
ip: string[];
|
||||
agentVersion: string;
|
||||
os: OSFields;
|
||||
policy: { name: string; id: string };
|
||||
random: seedrandom.prng;
|
||||
|
||||
constructor(seed = Math.random().toString()) {
|
||||
this.random = seedrandom(seed);
|
||||
this.hostId = this.seededUUIDv4();
|
||||
this.agentId = this.seededUUIDv4();
|
||||
this.hostname = this.randomHostname();
|
||||
this.ip = this.randomArray(3, () => this.randomIP());
|
||||
this.macAddress = this.randomArray(3, () => this.randomMac());
|
||||
this.agentVersion = this.randomVersion();
|
||||
this.os = this.randomChoice(OS);
|
||||
this.policy = this.randomChoice(POLICIES);
|
||||
}
|
||||
|
||||
public randomizeIPs() {
|
||||
this.ip = this.randomArray(3, () => this.randomIP());
|
||||
}
|
||||
|
||||
public generateEndpointMetadata(ts = new Date().getTime()): EndpointMetadata {
|
||||
return {
|
||||
'@timestamp': ts,
|
||||
event: {
|
||||
created: ts,
|
||||
},
|
||||
endpoint: {
|
||||
policy: {
|
||||
id: this.policy.id,
|
||||
},
|
||||
},
|
||||
agent: {
|
||||
version: this.agentVersion,
|
||||
id: this.agentId,
|
||||
},
|
||||
host: {
|
||||
id: this.hostId,
|
||||
hostname: this.hostname,
|
||||
ip: this.ip,
|
||||
mac: this.macAddress,
|
||||
os: this.os,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
public generateAlert(
|
||||
ts = new Date().getTime(),
|
||||
entityID = this.randomString(10),
|
||||
parentEntityID?: string
|
||||
): AlertEvent {
|
||||
return {
|
||||
'@timestamp': ts,
|
||||
agent: {
|
||||
id: this.agentId,
|
||||
version: this.agentVersion,
|
||||
},
|
||||
event: {
|
||||
action: this.randomChoice(FILE_OPERATIONS),
|
||||
kind: 'alert',
|
||||
category: 'malware',
|
||||
id: this.seededUUIDv4(),
|
||||
dataset: 'endpoint',
|
||||
module: 'endpoint',
|
||||
type: 'creation',
|
||||
},
|
||||
endpoint: {
|
||||
policy: {
|
||||
id: this.policy.id,
|
||||
},
|
||||
},
|
||||
file: {
|
||||
owner: 'SYSTEM',
|
||||
name: 'fake_malware.exe',
|
||||
path: 'C:/fake_malware.exe',
|
||||
accessed: ts,
|
||||
mtime: ts,
|
||||
created: ts,
|
||||
size: 3456,
|
||||
hash: {
|
||||
md5: 'fake file md5',
|
||||
sha1: 'fake file sha1',
|
||||
sha256: 'fake file sha256',
|
||||
},
|
||||
code_signature: {
|
||||
trusted: false,
|
||||
subject_name: 'bad signer',
|
||||
},
|
||||
malware_classifier: {
|
||||
identifier: 'endpointpe',
|
||||
score: 1,
|
||||
threshold: 0.66,
|
||||
version: '3.0.33',
|
||||
},
|
||||
temp_file_path: 'C:/temp/fake_malware.exe',
|
||||
},
|
||||
host: {
|
||||
id: this.hostId,
|
||||
hostname: this.hostname,
|
||||
ip: this.ip,
|
||||
mac: this.macAddress,
|
||||
os: this.os,
|
||||
},
|
||||
process: {
|
||||
pid: 2,
|
||||
name: 'malware writer',
|
||||
start: ts,
|
||||
uptime: 0,
|
||||
user: 'SYSTEM',
|
||||
entity_id: entityID,
|
||||
executable: 'C:/malware.exe',
|
||||
parent: parentEntityID ? { entity_id: parentEntityID, pid: 1 } : undefined,
|
||||
token: {
|
||||
domain: 'NT AUTHORITY',
|
||||
integrity_level: 16384,
|
||||
integrity_level_name: 'system',
|
||||
privileges: [
|
||||
{
|
||||
description: 'Replace a process level token',
|
||||
enabled: false,
|
||||
name: 'SeAssignPrimaryTokenPrivilege',
|
||||
},
|
||||
],
|
||||
sid: 'S-1-5-18',
|
||||
type: 'tokenPrimary',
|
||||
user: 'SYSTEM',
|
||||
},
|
||||
code_signature: {
|
||||
trusted: false,
|
||||
subject_name: 'bad signer',
|
||||
},
|
||||
hash: {
|
||||
md5: 'fake md5',
|
||||
sha1: 'fake sha1',
|
||||
sha256: 'fake sha256',
|
||||
},
|
||||
},
|
||||
dll: [
|
||||
{
|
||||
pe: {
|
||||
architecture: 'x64',
|
||||
imphash: 'c30d230b81c734e82e86e2e2fe01cd01',
|
||||
},
|
||||
code_signature: {
|
||||
subject_name: 'Cybereason Inc',
|
||||
trusted: true,
|
||||
},
|
||||
compile_time: 1534424710,
|
||||
hash: {
|
||||
md5: '1f2d082566b0fc5f2c238a5180db7451',
|
||||
sha1: 'ca85243c0af6a6471bdaa560685c51eefd6dbc0d',
|
||||
sha256: '8ad40c90a611d36eb8f9eb24fa04f7dbca713db383ff55a03aa0f382e92061a2',
|
||||
},
|
||||
malware_classifier: {
|
||||
identifier: 'Whitelisted',
|
||||
score: 0,
|
||||
threshold: 0,
|
||||
version: '3.0.0',
|
||||
},
|
||||
mapped_address: 5362483200,
|
||||
mapped_size: 0,
|
||||
path: 'C:\\Program Files\\Cybereason ActiveProbe\\AmSvc.exe',
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
public generateEvent(options: EventOptions = {}): EndpointEvent {
|
||||
return {
|
||||
'@timestamp': options.timestamp ? options.timestamp : new Date().getTime(),
|
||||
agent: {
|
||||
id: this.agentId,
|
||||
version: this.agentVersion,
|
||||
type: 'endpoint',
|
||||
},
|
||||
ecs: {
|
||||
version: '1.4.0',
|
||||
},
|
||||
event: {
|
||||
category: options.eventCategory ? options.eventCategory : 'process',
|
||||
kind: 'event',
|
||||
type: options.eventType ? options.eventType : 'creation',
|
||||
id: this.seededUUIDv4(),
|
||||
},
|
||||
host: {
|
||||
id: this.hostId,
|
||||
hostname: this.hostname,
|
||||
ip: this.ip,
|
||||
mac: this.macAddress,
|
||||
os: this.os,
|
||||
},
|
||||
process: {
|
||||
entity_id: options.entityID ? options.entityID : this.randomString(10),
|
||||
parent: options.parentEntityID ? { entity_id: options.parentEntityID } : undefined,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
public generateFullResolverTree(
|
||||
alertAncestors?: number,
|
||||
childGenerations?: number,
|
||||
maxChildrenPerNode?: number,
|
||||
relatedEventsPerNode?: number,
|
||||
percentNodesWithRelated?: number,
|
||||
percentChildrenTerminated?: number
|
||||
): Event[] {
|
||||
const ancestry = this.generateAlertEventAncestry(alertAncestors);
|
||||
// ancestry will always have at least 2 elements, and the second to last element will be the process associated with the alert
|
||||
const descendants = this.generateDescendantsTree(
|
||||
ancestry[ancestry.length - 2],
|
||||
childGenerations,
|
||||
maxChildrenPerNode,
|
||||
relatedEventsPerNode,
|
||||
percentNodesWithRelated,
|
||||
percentChildrenTerminated
|
||||
);
|
||||
return ancestry.concat(descendants);
|
||||
}
|
||||
|
||||
public generateAlertEventAncestry(alertAncestors = 3): Event[] {
|
||||
const events = [];
|
||||
const startDate = new Date().getTime();
|
||||
const root = this.generateEvent({ timestamp: startDate + 1000 });
|
||||
events.push(root);
|
||||
let ancestor = root;
|
||||
for (let i = 0; i < alertAncestors; i++) {
|
||||
ancestor = this.generateEvent({
|
||||
timestamp: startDate + 1000 * (i + 1),
|
||||
parentEntityID: ancestor.process.entity_id,
|
||||
});
|
||||
events.push(ancestor);
|
||||
}
|
||||
events.push(
|
||||
this.generateAlert(
|
||||
startDate + 1000 * alertAncestors,
|
||||
ancestor.process.entity_id,
|
||||
ancestor.process.parent?.entity_id
|
||||
)
|
||||
);
|
||||
return events;
|
||||
}
|
||||
|
||||
public generateDescendantsTree(
|
||||
root: Event,
|
||||
generations = 2,
|
||||
maxChildrenPerNode = 2,
|
||||
relatedEventsPerNode = 3,
|
||||
percentNodesWithRelated = 100,
|
||||
percentChildrenTerminated = 100
|
||||
): Event[] {
|
||||
let events: Event[] = [root];
|
||||
let parents = [root];
|
||||
let timestamp = root['@timestamp'];
|
||||
for (let i = 0; i < generations; i++) {
|
||||
const newParents: EndpointEvent[] = [];
|
||||
parents.forEach(element => {
|
||||
// const numChildren = randomN(maxChildrenPerNode);
|
||||
const numChildren = maxChildrenPerNode;
|
||||
for (let j = 0; j < numChildren; j++) {
|
||||
timestamp = timestamp + 1000;
|
||||
const child = this.generateEvent({
|
||||
timestamp,
|
||||
parentEntityID: element.process.entity_id,
|
||||
});
|
||||
newParents.push(child);
|
||||
}
|
||||
});
|
||||
events = events.concat(newParents);
|
||||
parents = newParents;
|
||||
}
|
||||
const terminationEvents: EndpointEvent[] = [];
|
||||
let relatedEvents: EndpointEvent[] = [];
|
||||
events.forEach(element => {
|
||||
if (this.randomN(100) < percentChildrenTerminated) {
|
||||
timestamp = timestamp + 1000;
|
||||
terminationEvents.push(
|
||||
this.generateEvent({
|
||||
timestamp,
|
||||
entityID: element.process.entity_id,
|
||||
parentEntityID: element.process.parent?.entity_id,
|
||||
eventCategory: 'process',
|
||||
eventType: 'end',
|
||||
})
|
||||
);
|
||||
}
|
||||
if (this.randomN(100) < percentNodesWithRelated) {
|
||||
relatedEvents = relatedEvents.concat(
|
||||
this.generateRelatedEvents(element, relatedEventsPerNode)
|
||||
);
|
||||
}
|
||||
});
|
||||
events = events.concat(terminationEvents);
|
||||
events = events.concat(relatedEvents);
|
||||
return events;
|
||||
}
|
||||
|
||||
public generateRelatedEvents(node: Event, numRelatedEvents = 10): EndpointEvent[] {
|
||||
const ts = node['@timestamp'] + 1000;
|
||||
const relatedEvents: EndpointEvent[] = [];
|
||||
for (let i = 0; i < numRelatedEvents; i++) {
|
||||
relatedEvents.push(
|
||||
this.generateEvent({
|
||||
timestamp: ts,
|
||||
entityID: node.process.entity_id,
|
||||
parentEntityID: node.process.parent?.entity_id,
|
||||
eventCategory: this.randomChoice(OTHER_EVENT_CATEGORIES),
|
||||
})
|
||||
);
|
||||
}
|
||||
return relatedEvents;
|
||||
}
|
||||
|
||||
private randomN(n: number): number {
|
||||
return Math.floor(this.random() * n);
|
||||
}
|
||||
|
||||
private *randomNGenerator(max: number, count: number) {
|
||||
while (count > 0) {
|
||||
yield this.randomN(max);
|
||||
count--;
|
||||
}
|
||||
}
|
||||
|
||||
private randomArray<T>(lengthLimit: number, generator: () => T): T[] {
|
||||
const rand = this.randomN(lengthLimit) + 1;
|
||||
return [...Array(rand).keys()].map(generator);
|
||||
}
|
||||
|
||||
private randomMac(): string {
|
||||
return [...this.randomNGenerator(255, 6)].map(x => x.toString(16)).join('-');
|
||||
}
|
||||
|
||||
private randomIP(): string {
|
||||
return [10, ...this.randomNGenerator(255, 3)].map(x => x.toString()).join('.');
|
||||
}
|
||||
|
||||
private randomVersion(): string {
|
||||
return [6, ...this.randomNGenerator(10, 2)].map(x => x.toString()).join('.');
|
||||
}
|
||||
|
||||
private randomChoice<T>(choices: T[]): T {
|
||||
return choices[this.randomN(choices.length)];
|
||||
}
|
||||
|
||||
private randomString(length: number): string {
|
||||
return [...this.randomNGenerator(36, length)].map(x => x.toString(36)).join('');
|
||||
}
|
||||
|
||||
private randomHostname(): string {
|
||||
return `Host-${this.randomString(10)}`;
|
||||
}
|
||||
|
||||
private seededUUIDv4(): string {
|
||||
return uuid.v4({ random: [...this.randomNGenerator(255, 16)] });
|
||||
}
|
||||
}
|
|
@ -167,29 +167,34 @@ export type AlertEvent = Immutable<{
|
|||
module: string;
|
||||
type: string;
|
||||
};
|
||||
endpoint: {
|
||||
policy: {
|
||||
id: string;
|
||||
};
|
||||
};
|
||||
process: {
|
||||
code_signature: {
|
||||
subject_name: string;
|
||||
trusted: boolean;
|
||||
};
|
||||
command_line: string;
|
||||
domain: string;
|
||||
command_line?: string;
|
||||
domain?: string;
|
||||
pid: number;
|
||||
ppid: number;
|
||||
ppid?: number;
|
||||
entity_id: string;
|
||||
parent: {
|
||||
parent?: {
|
||||
pid: number;
|
||||
entity_id: string;
|
||||
};
|
||||
name: string;
|
||||
hash: HashFields;
|
||||
pe: {
|
||||
pe?: {
|
||||
imphash: string;
|
||||
};
|
||||
executable: string;
|
||||
sid: string;
|
||||
sid?: string;
|
||||
start: number;
|
||||
malware_classifier: MalwareClassifierFields;
|
||||
malware_classifier?: MalwareClassifierFields;
|
||||
token: {
|
||||
domain: string;
|
||||
type: string;
|
||||
|
@ -197,9 +202,9 @@ export type AlertEvent = Immutable<{
|
|||
sid: string;
|
||||
integrity_level: number;
|
||||
integrity_level_name: string;
|
||||
privileges: PrivilegesFields[];
|
||||
privileges?: PrivilegesFields[];
|
||||
};
|
||||
thread: ThreadFields[];
|
||||
thread?: ThreadFields[];
|
||||
uptime: number;
|
||||
user: string;
|
||||
};
|
||||
|
@ -212,32 +217,20 @@ export type AlertEvent = Immutable<{
|
|||
created: number;
|
||||
size: number;
|
||||
hash: HashFields;
|
||||
pe: {
|
||||
pe?: {
|
||||
imphash: string;
|
||||
};
|
||||
code_signature: {
|
||||
trusted: boolean;
|
||||
subject_name: string;
|
||||
};
|
||||
malware_classifier: {
|
||||
features: {
|
||||
data: {
|
||||
buffer: string;
|
||||
decompressed_size: number;
|
||||
encoding: string;
|
||||
};
|
||||
};
|
||||
} & MalwareClassifierFields;
|
||||
malware_classifier: MalwareClassifierFields;
|
||||
temp_file_path: string;
|
||||
};
|
||||
host: HostFields;
|
||||
thread: {};
|
||||
dll: DllFields[];
|
||||
dll?: DllFields[];
|
||||
}>;
|
||||
|
||||
/**
|
||||
* Metadata associated with an alert event.
|
||||
*/
|
||||
interface AlertMetadata {
|
||||
id: string;
|
||||
|
||||
|
@ -252,9 +245,9 @@ interface AlertMetadata {
|
|||
export type AlertData = AlertEvent & AlertMetadata;
|
||||
|
||||
export interface EndpointMetadata {
|
||||
'@timestamp': string;
|
||||
'@timestamp': number;
|
||||
event: {
|
||||
created: Date;
|
||||
created: number;
|
||||
};
|
||||
endpoint: {
|
||||
policy: {
|
||||
|
@ -262,8 +255,8 @@ export interface EndpointMetadata {
|
|||
};
|
||||
};
|
||||
agent: {
|
||||
version: string;
|
||||
id: string;
|
||||
version: string;
|
||||
};
|
||||
host: HostFields;
|
||||
}
|
||||
|
@ -310,22 +303,32 @@ export interface LegacyEndpointEvent {
|
|||
|
||||
export interface EndpointEvent {
|
||||
'@timestamp': number;
|
||||
agent: {
|
||||
id: string;
|
||||
version: string;
|
||||
type: string;
|
||||
};
|
||||
ecs: {
|
||||
version: string;
|
||||
};
|
||||
event: {
|
||||
category: string;
|
||||
type: string;
|
||||
id: string;
|
||||
kind: string;
|
||||
};
|
||||
endpoint: {
|
||||
process: {
|
||||
entity_id: string;
|
||||
parent: {
|
||||
entity_id: string;
|
||||
};
|
||||
};
|
||||
};
|
||||
agent: {
|
||||
host: {
|
||||
id: string;
|
||||
type: string;
|
||||
hostname: string;
|
||||
ip: string[];
|
||||
mac: string[];
|
||||
os: OSFields;
|
||||
};
|
||||
process: {
|
||||
entity_id: string;
|
||||
parent?: {
|
||||
entity_id: string;
|
||||
};
|
||||
};
|
||||
}
|
||||
|
||||
|
|
|
@ -6,9 +6,11 @@
|
|||
"license": "Elastic-License",
|
||||
"scripts": {},
|
||||
"dependencies": {
|
||||
"react-redux": "^7.1.0"
|
||||
"react-redux": "^7.1.0",
|
||||
"seedrandom": "^3.0.5"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/seedrandom": ">=2.0.0 <4.0.0",
|
||||
"@types/react-redux": "^7.1.0",
|
||||
"redux-devtools-extension": "^2.13.8"
|
||||
}
|
||||
|
|
|
@ -5,6 +5,7 @@
|
|||
*/
|
||||
|
||||
import { AlertResultList } from '../../../../../common/types';
|
||||
import { EndpointDocGenerator } from '../../../../../common/generate_data';
|
||||
|
||||
export const mockAlertResultList: (options?: {
|
||||
total?: number;
|
||||
|
@ -24,160 +25,15 @@ export const mockAlertResultList: (options?: {
|
|||
const actualCountToReturn = Math.max(Math.min(total - numberToSkip, requestPageSize), 0);
|
||||
|
||||
const alerts = [];
|
||||
const generator = new EndpointDocGenerator();
|
||||
for (let index = 0; index < actualCountToReturn; index++) {
|
||||
alerts.push({
|
||||
'@timestamp': 1542341895000,
|
||||
id: 'xDUYMHABAJk0XnHd8rrd',
|
||||
agent: {
|
||||
id: 'ced9c68e-b94a-4d66-bb4c-6106514f0a2f',
|
||||
version: '3.0.0',
|
||||
...generator.generateAlert(new Date().getTime() + index * 1000),
|
||||
...{
|
||||
id: 'xDUYMHABAJk0XnHd8rrd' + index,
|
||||
prev: null,
|
||||
next: null,
|
||||
},
|
||||
host: {
|
||||
id: 'xrctvybuni',
|
||||
hostname: 'HD-c15-bc09190a',
|
||||
ip: ['10.179.244.14'],
|
||||
mac: ['xsertcyvbunimkn56edtyf'],
|
||||
os: {
|
||||
full: 'Windows 10',
|
||||
name: 'windows',
|
||||
version: '10',
|
||||
variant: '3',
|
||||
},
|
||||
},
|
||||
thread: {},
|
||||
prev: null,
|
||||
next: null,
|
||||
event: {
|
||||
id: '2f1c0928-3876-4e11-acbb-9199257c7b1c',
|
||||
action: 'creation',
|
||||
category: 'malware',
|
||||
dataset: 'endpoint',
|
||||
kind: 'alert',
|
||||
module: 'endpoint',
|
||||
type: 'creation',
|
||||
},
|
||||
file: {
|
||||
accessed: 1542789400,
|
||||
created: 1542789400,
|
||||
hash: {
|
||||
md5: '4ace3baaa509d08510405e1b169e325b',
|
||||
sha1: '27fb21cf5db95ffca43b234affa99becc4023b9d',
|
||||
sha256: '6ed1c836dbf099be7845bdab7671def2c157643761b52251e04e9b6ee109ec75',
|
||||
},
|
||||
pe: {
|
||||
imphash: '835d619dfdf3cc727cebd91300ab3462',
|
||||
},
|
||||
mtime: 1542789400,
|
||||
owner: 'Administrators',
|
||||
name: 'test name',
|
||||
path: 'C:\\Windows\\TEMP\\tmp0000008f\\tmp00001be5',
|
||||
size: 188416,
|
||||
code_signature: {
|
||||
subject_name: 'Cybereason Inc',
|
||||
trusted: false,
|
||||
},
|
||||
malware_classifier: {
|
||||
features: {
|
||||
data: {
|
||||
buffer:
|
||||
'eAHtnU1oHHUUwHsQ7MGDiIIUD4sH8WBBxJtopiLoUY0pYo2ZTbJJ0yQ17m4+ms/NRzeVWpuUWCL4sWlEYvFQ8KJQ6NCTEA8eRD30sIo3PdSriLi7837Pko3LbHZ2M5m+XObHm/d/X////83O7jCZvzacHBpPplNdfalkdjSdyty674Ft59dN71Dpb9v5eKh8LMEHjsCF2wIfVlRKsHROYPGkQO5+gY2vBSYYdWZFYGwEO/cITHMqkxPYnBBY+07gtCuQ9gSGigJ5lPPYGXcE+jA4z3Ad1ZtAUiDUyrEEPYzqRnIKgxd/Rgc7gygPo5wn95PouN7OeEYJ1UXiJgRmvscgp/LOziIkkSyT+xRVnXhZ4DKh5goCkzidRHkGO4uvCyw9LDDtCay8ILCAzrJOJaGuZwUuvSewivJVIPsklq8JbL4qMJsTSCcExrGs83WKU295ZFo5lr2TaZbcUw5FeJy8tgTeLpCy2iGeS67ABXzlgbEi1UC5FxcZnA4y/CLK82Qxi847FGGZRTLsCUxR1aWEwOp1AmOjDRYYzgwusL9WfqBiGJxnVAanixTq7Dp22LBdlWMJzlOx8wmBK2Rx5WmBLJIRwtAijOQE+ooCb2B5xBOYRtlfNeXpLpA7oyZRTqHzGenkmIJPnhBIMrzTwSA6H93CO5l+c1NA99f6IwLH8fUKdjTmDpTbgS50+gGVnECnE4PpooC2guPoaPADSHrcncNHmEHtAFkq3+EI+A37zsrrTvH3WTkvJLoOTyBp10wx2JcgVCRahA4NrICE4a+hrMXsA3qAHItW188E8ejO7XV3eh/KCYwxlamEwCgL8lN2wTntfrhY/U0g/5KAdvUpT+AszWqBdqH7VLeeZrExK9Cv1UgIDKA8g/cx7QAEP+AhAfRaMKB2HOJh+BSFSqKjSytNGBlc6PrpxvK7lCVDxbSG3Z7AhCMwx6gelwgLAltXBXJUTH29j+U1LHdipx/QprfKfGnF0sBpdBYxmEQyTzW0h6/0khcuhhJYRufym+i4VKMocJMs/KvfoW3/UJb4PeZOSZVONThZz4djP/75TAXa/CVfOvX3RgVLIDreLPN1pP1osW7lGmHsEhjBOzf+EPBE4vndvWz5xb/cChxGcv1LAb+tluALKnZ47isf1MXvz1ZMlsCXbXtPceqhrcp1ps6YHwQeBXLEPCf7q23tl9uJui0bGBgYRAccv7uXr/g5Af+2oNTrpgTa/vnpjBvpLAwM4gRBPvIZGBgYGBgYGBgYGBgYGBgYGBgYGBgYNAOc9oMXs4GBgYFBcNBnww5QzDXgRtPSaZ5lg/itsRaslgZ3bnWEEVnhMetIBwiiVnlbCbWrEftrt11zdwWnseFW1QO63w1is3ptD1pV9xG0t+zvfUrzrvh380qwXWAVCw6h78GIfG7ZlzltXu6hd+y92fECRFhjuH3bXG8N43oXEHperdzvUbteaDxhVTUeq25fqhG1X6Ai8mtF6BDXz2wR+dzSgg4Qsxls5T11XMG+82y8GkG+b7kL69xg7mF1SFvhBgYGsYH/Xi7HE+PVkiB2jt1bNZxT+k4558jR53ydz5//1m1KOgYGBgYGBgYGEQfnsYaG2z1sdPJS79XQSu91ndobOAHCaN5vNzUk1bceQVzUpbw3iOuT+UFmR18bHrp3gyhDC56lCd1y85w2+HSNUwVhhdGC7blLf+bV/fqtvhMg1NDjCcugB1QXswbs8ekj/v1BgzFHBIIsyP+HfwFdMpzu',
|
||||
decompressed_size: 27831,
|
||||
encoding: 'zlib',
|
||||
},
|
||||
},
|
||||
identifier: 'endpointpe',
|
||||
score: 1,
|
||||
threshold: 0.66,
|
||||
version: '3.0.33',
|
||||
},
|
||||
temp_file_path: 'C:\\Windows\\TEMP\\1bb9abfc-ca14-47b2-9f2c-10c323df42f9',
|
||||
},
|
||||
process: {
|
||||
pid: 1076,
|
||||
ppid: 432,
|
||||
entity_id: 'wertqwer',
|
||||
parent: {
|
||||
pid: 432,
|
||||
entity_id: 'adsfsdaf',
|
||||
},
|
||||
name: 'test name',
|
||||
code_signature: {
|
||||
subject_name: 'Cybereason Inc',
|
||||
trusted: true,
|
||||
},
|
||||
command_line: '"C:\\Program Files\\Cybereason ActiveProbe\\AmSvc.exe"',
|
||||
domain: 'NT AUTHORITY',
|
||||
executable: 'C:\\Program Files\\Cybereason ActiveProbe\\AmSvc.exe',
|
||||
hash: {
|
||||
md5: '1f2d082566b0fc5f2c238a5180db7451',
|
||||
sha1: 'ca85243c0af6a6471bdaa560685c51eefd6dbc0d',
|
||||
sha256: '8ad40c90a611d36eb8f9eb24fa04f7dbca713db383ff55a03aa0f382e92061a2',
|
||||
},
|
||||
pe: {
|
||||
imphash: 'c30d230b81c734e82e86e2e2fe01cd01',
|
||||
},
|
||||
malware_classifier: {
|
||||
identifier: 'Whitelisted',
|
||||
score: 0,
|
||||
threshold: 0,
|
||||
version: '3.0.0',
|
||||
},
|
||||
thread: [
|
||||
{
|
||||
id: 1652,
|
||||
service_name: 'CybereasonAntiMalware',
|
||||
start: 1542788400,
|
||||
start_address: 8791698721056,
|
||||
start_address_module: 'C:\\Program Files\\Cybereason ActiveProbe\\gzfltum.dll',
|
||||
},
|
||||
],
|
||||
sid: 'S-1-5-18',
|
||||
start: 1542788400,
|
||||
token: {
|
||||
domain: 'NT AUTHORITY',
|
||||
integrity_level: 16384,
|
||||
integrity_level_name: 'system',
|
||||
privileges: [
|
||||
{
|
||||
description: 'Replace a process level token',
|
||||
enabled: false,
|
||||
name: 'SeAssignPrimaryTokenPrivilege',
|
||||
},
|
||||
],
|
||||
sid: 'S-1-5-18',
|
||||
type: 'tokenPrimary',
|
||||
user: 'SYSTEM',
|
||||
},
|
||||
uptime: 1025,
|
||||
user: 'SYSTEM',
|
||||
},
|
||||
dll: [
|
||||
{
|
||||
pe: {
|
||||
architecture: 'x64',
|
||||
imphash: 'c30d230b81c734e82e86e2e2fe01cd01',
|
||||
},
|
||||
code_signature: {
|
||||
subject_name: 'Cybereason Inc',
|
||||
trusted: true,
|
||||
},
|
||||
compile_time: 1534424710,
|
||||
hash: {
|
||||
md5: '1f2d082566b0fc5f2c238a5180db7451',
|
||||
sha1: 'ca85243c0af6a6471bdaa560685c51eefd6dbc0d',
|
||||
sha256: '8ad40c90a611d36eb8f9eb24fa04f7dbca713db383ff55a03aa0f382e92061a2',
|
||||
},
|
||||
malware_classifier: {
|
||||
identifier: 'Whitelisted',
|
||||
score: 0,
|
||||
threshold: 0,
|
||||
version: '3.0.0',
|
||||
},
|
||||
mapped_address: 5362483200,
|
||||
mapped_size: 0,
|
||||
path: 'C:\\Program Files\\Cybereason ActiveProbe\\AmSvc.exe',
|
||||
},
|
||||
],
|
||||
});
|
||||
}
|
||||
const mock: AlertResultList = {
|
||||
|
|
|
@ -7,44 +7,20 @@
|
|||
import { createStore, Dispatch, Store } from 'redux';
|
||||
import { ManagementAction, managementListReducer } from './index';
|
||||
import { EndpointMetadata } from '../../../../../common/types';
|
||||
import { EndpointDocGenerator } from '../../../../../common/generate_data';
|
||||
import { ManagementListState } from '../../types';
|
||||
import { listData } from './selectors';
|
||||
|
||||
describe('endpoint_list store concerns', () => {
|
||||
let store: Store<ManagementListState>;
|
||||
let dispatch: Dispatch<ManagementAction>;
|
||||
const generator = new EndpointDocGenerator();
|
||||
const createTestStore = () => {
|
||||
store = createStore(managementListReducer);
|
||||
dispatch = store.dispatch;
|
||||
};
|
||||
const generateEndpoint = (): EndpointMetadata => {
|
||||
return {
|
||||
'@timestamp': new Date(1582231151055).toString(),
|
||||
event: {
|
||||
created: new Date(0),
|
||||
},
|
||||
endpoint: {
|
||||
policy: {
|
||||
id: '',
|
||||
},
|
||||
},
|
||||
agent: {
|
||||
version: '',
|
||||
id: '',
|
||||
},
|
||||
host: {
|
||||
id: '',
|
||||
hostname: '',
|
||||
ip: [''],
|
||||
mac: [''],
|
||||
os: {
|
||||
name: '',
|
||||
full: '',
|
||||
version: '',
|
||||
variant: '',
|
||||
},
|
||||
},
|
||||
};
|
||||
return generator.generateEndpointMetadata(new Date().getTime());
|
||||
};
|
||||
const loadDataToStore = () => {
|
||||
dispatch({
|
||||
|
|
|
@ -9,6 +9,7 @@ import { coreMock } from '../../../../../../../../src/core/public/mocks';
|
|||
import { History, createBrowserHistory } from 'history';
|
||||
import { managementListReducer, managementMiddlewareFactory } from './index';
|
||||
import { EndpointMetadata, EndpointResultList } from '../../../../../common/types';
|
||||
import { EndpointDocGenerator } from '../../../../../common/generate_data';
|
||||
import { ManagementListState } from '../../types';
|
||||
import { AppAction } from '../action';
|
||||
import { listData } from './selectors';
|
||||
|
@ -19,38 +20,14 @@ describe('endpoint list saga', () => {
|
|||
let store: Store<ManagementListState>;
|
||||
let getState: typeof store['getState'];
|
||||
let dispatch: Dispatch<AppAction>;
|
||||
let history: History<never>;
|
||||
|
||||
const generator = new EndpointDocGenerator();
|
||||
// https://github.com/elastic/endpoint-app-team/issues/131
|
||||
const generateEndpoint = (): EndpointMetadata => {
|
||||
return {
|
||||
'@timestamp': new Date(1582231151055).toString(),
|
||||
event: {
|
||||
created: new Date(0),
|
||||
},
|
||||
endpoint: {
|
||||
policy: {
|
||||
id: '',
|
||||
},
|
||||
},
|
||||
agent: {
|
||||
version: '',
|
||||
id: '',
|
||||
},
|
||||
host: {
|
||||
id: '',
|
||||
hostname: '',
|
||||
ip: [''],
|
||||
mac: [''],
|
||||
os: {
|
||||
name: '',
|
||||
full: '',
|
||||
version: '',
|
||||
variant: '',
|
||||
},
|
||||
},
|
||||
};
|
||||
return generator.generateEndpointMetadata(new Date().getTime());
|
||||
};
|
||||
|
||||
let history: History<never>;
|
||||
const getEndpointListApiResponse = (): EndpointResultList => {
|
||||
return {
|
||||
endpoints: [generateEndpoint()],
|
||||
|
|
|
@ -5,6 +5,7 @@
|
|||
*/
|
||||
|
||||
import { EndpointResultList } from '../../../../../common/types';
|
||||
import { EndpointDocGenerator } from '../../../../../common/generate_data';
|
||||
|
||||
export const mockHostResultList: (options?: {
|
||||
total?: number;
|
||||
|
@ -25,33 +26,8 @@ export const mockHostResultList: (options?: {
|
|||
|
||||
const endpoints = [];
|
||||
for (let index = 0; index < actualCountToReturn; index++) {
|
||||
endpoints.push({
|
||||
'@timestamp': new Date(1582231151055).toString(),
|
||||
event: {
|
||||
created: new Date('2020-02-20T20:39:11.055Z'),
|
||||
},
|
||||
endpoint: {
|
||||
policy: {
|
||||
id: '00000000-0000-0000-0000-000000000000',
|
||||
},
|
||||
},
|
||||
agent: {
|
||||
version: '6.9.2',
|
||||
id: '9a87fdac-e6c0-4f27-a25c-e349e7093cb1',
|
||||
},
|
||||
host: {
|
||||
id: '3ca26fe5-1c7d-42b8-8763-98256d161c9f',
|
||||
hostname: 'bea-0.example.com',
|
||||
ip: ['10.154.150.114', '10.43.37.62', '10.217.73.149'],
|
||||
mac: ['ea-5a-a8-c0-5-95', '7e-d8-fe-7f-b6-4e', '23-31-5d-af-e6-2b'],
|
||||
os: {
|
||||
name: 'windows 6.2',
|
||||
full: 'Windows Server 2012',
|
||||
version: '6.2',
|
||||
variant: 'Windows Server Release 2',
|
||||
},
|
||||
},
|
||||
});
|
||||
const generator = new EndpointDocGenerator('seed');
|
||||
endpoints.push(generator.generateEndpointMetadata());
|
||||
}
|
||||
const mock: EndpointResultList = {
|
||||
endpoints,
|
||||
|
|
|
@ -51,13 +51,13 @@ export const SourceProcessAccordion = memo(({ alertData }: { alertData: Immutabl
|
|||
title: i18n.translate('xpack.endpoint.application.endpoint.alertDetails.malwareScore', {
|
||||
defaultMessage: 'MalwareScore',
|
||||
}),
|
||||
description: alertData.process.malware_classifier.score,
|
||||
description: alertData.process.malware_classifier?.score || '-',
|
||||
},
|
||||
{
|
||||
title: i18n.translate('xpack.endpoint.application.endpoint.alertDetails.parentProcessID', {
|
||||
defaultMessage: 'Parent Process ID',
|
||||
}),
|
||||
description: alertData.process.parent.pid,
|
||||
description: alertData.process.parent?.pid || '-',
|
||||
},
|
||||
{
|
||||
title: i18n.translate('xpack.endpoint.application.endpoint.alertDetails.signer', {
|
||||
|
|
|
@ -21,7 +21,7 @@ export function extractEntityID(event: ResolverEvent) {
|
|||
if (isLegacyData(event)) {
|
||||
return String(event.endgame.unique_pid);
|
||||
}
|
||||
return event.endpoint.process.entity_id;
|
||||
return event.process.entity_id;
|
||||
}
|
||||
|
||||
export function extractParentEntityID(event: ResolverEvent) {
|
||||
|
@ -29,5 +29,5 @@ export function extractParentEntityID(event: ResolverEvent) {
|
|||
const ppid = event.endgame.unique_ppid;
|
||||
return ppid && String(ppid); // if unique_ppid is undefined return undefined
|
||||
}
|
||||
return event.endpoint.process.parent?.entity_id;
|
||||
return event.process.parent?.entity_id;
|
||||
}
|
||||
|
|
|
@ -5169,6 +5169,11 @@
|
|||
"@types/tough-cookie" "*"
|
||||
form-data "^2.5.0"
|
||||
|
||||
"@types/seedrandom@>=2.0.0 <4.0.0":
|
||||
version "2.4.28"
|
||||
resolved "https://registry.yarnpkg.com/@types/seedrandom/-/seedrandom-2.4.28.tgz#9ce8fa048c1e8c85cb71d7fe4d704e000226036f"
|
||||
integrity sha512-SMA+fUwULwK7sd/ZJicUztiPs8F1yCPwF3O23Z9uQ32ME5Ha0NmDK9+QTsYE4O2tHXChzXomSWWeIhCnoN1LqA==
|
||||
|
||||
"@types/selenium-webdriver@^4.0.5":
|
||||
version "4.0.5"
|
||||
resolved "https://registry.yarnpkg.com/@types/selenium-webdriver/-/selenium-webdriver-4.0.5.tgz#23041a4948c82daf2df9836e4d2358fec10d3e24"
|
||||
|
|
Loading…
Reference in a new issue