Skip to content

Commit

Permalink
WIP: Detection History
Browse files Browse the repository at this point in the history
Previous versions of a Detection's history are now presented in the History tab. With Refresh, Sort, Search, and Details.
  • Loading branch information
coreyogburn committed Feb 2, 2024
1 parent 587b9b3 commit 868e656
Show file tree
Hide file tree
Showing 7 changed files with 269 additions and 7 deletions.
172 changes: 168 additions & 4 deletions html/index.html
Original file line number Diff line number Diff line change
Expand Up @@ -1376,9 +1376,173 @@ <h2 id="detection-title" @click="startEdit('detection-title', 'title')" v-if="!i
</div>
</v-tab-item>
<v-tab-item value="history">
<div class="col" style="background-color: rgb(53, 53, 53); border-radius: 4px;">
Coming Soon!
</div>
<v-row>
<v-col>
<v-toolbar class="elevation-0" fixed>
<v-btn :title="i18n.refreshDetectionHistoryHelp" color="secondary" @click.stop="loadHistory()" id="refresh-history-button">
<v-icon>fa-sync</v-icon>
</v-btn>
</v-toolbar>
</v-col>
</v-row>
<!-- <div class="col" style="background-color: rgb(53, 53, 53); border-radius: 4px;"> -->
<v-text-field v-model="historyTableOpts.search" clearable prepend-icon="fa-filter" :label="i18n.filterResults" single-line hide-details></v-text-field>
<v-data-table ref="historyTable" :sort-by.sync="historyTableOpts.sortBy" :sort-desc.sync="historyTableOpts.sortDesc" :items-per-page.sync="historyTableOpts.itemsPerPage"
:search="historyTableOpts.search" :footer-props="historyTableOpts.footerProps" must-sort :headers="historyTableOpts.headers"
:items="history" item-key="id" :loading="historyTableOpts.loading" :expanded="historyTableOpts.expanded" class="history-table">
<template v-slot:item="props">
<tr>
<td class="associated actions">
<v-btn :id="`history-${props.index}-expand`" icon small @click="expandRow(props.item)">
<v-icon v-if="isExpanded(props.item)" :alt="i18n.expand" :title="i18n.expandHelp">fa-angle-down</v-icon>
<v-icon v-if="!isExpanded(props.item)" :alt="i18n.expand" :title="i18n.expandHelp">fa-angle-right</v-icon>
</v-btn>
</td>
<td class="associated">
<v-avatar color="primary" class="mr-2 white--text" size="28">
<div class="font-weight-bold" :title="props.item.owner">
{{ $root.getAvatar(props.item.owner) }}
</div>
</v-avatar>
{{ props.item.owner }}
</td>
<td class="associated">{{ props.item.updateTime | formatDateTime }}</td>
<td class="associated">
<v-icon>fa-magnifying-glass</v-icon>
<span class="rounded ml-2">{{ props.item.kind }}</span>
</td>
<td class="associated">
<v-icon v-if="props.item.operation.toLowerCase() == i18n.create.toLowerCase()">fa-plus</v-icon>
<v-icon v-if="props.item.operation.toLowerCase() == i18n.delete.toLowerCase()">fa-circle-xmark</v-icon>
<v-icon v-if="props.item.operation.toLowerCase() == i18n.update.toLowerCase()">fa-pencil-alt</v-icon>
<span class="rounded ml-2">{{ props.item.operation }}</span>
</td>
</tr>
</template>
<template v-slot:expanded-item="props">
<tr>
<td colspan="5" class="case">
<div class="mt-2">
<v-row no-gutters class="py-1 case tabular-row">
<v-col cols="4"><span class="case tabular-label">{{ i18n.id }}:</span></v-col>
<v-col cols="8"><span class="case">{{ props.item.id }}</span></v-col>
</v-row>
</div>
<div>
<v-row no-gutters class="py-1 case tabular-row">
<v-col cols="4"><span class="case tabular-label">{{ i18n.kind }}:</span></v-col>
<v-col cols="8"><span class="case">{{ props.item.kind }}</span></v-col>
</v-row>
</div>
<div>
<v-row no-gutters class="py-1 case tabular-row">
<v-col cols="4"><span class="case tabular-label">{{ i18n.operation }}:</span></v-col>
<v-col cols="8"><span class="case">{{ props.item.operation }}</span></v-col>
</v-row>
</div>
<div>
<v-row no-gutters class="py-1 case tabular-row">
<v-col cols="4"><span class="case tabular-label">{{ i18n.dateCreated }}:</span></v-col>
<v-col cols="8"><span class="case">{{ props.item.createTime | formatDateTime }}</span></v-col>
</v-row>
</div>
<div>
<v-row no-gutters class="py-1 case tabular-row">
<v-col cols="4"><span class="case tabular-label">{{ i18n.dateModified }}:</span></v-col>
<v-col cols="8"><span class="case">{{ props.item.updateTime | formatDateTime }}</span></v-col>
</v-row>
</div>
<div>
<v-row no-gutters class="py-1 case tabular-row">
<v-col cols="4"><span class="case tabular-label">{{ i18n.title }}:</span></v-col>
<v-col cols="8"><span class="case">{{ props.item.title }}</span></v-col>
</v-row>
</div>
<div>
<v-row no-gutters class="py-1 case tabular-row">
<v-col cols="4"><span class="case tabular-label">{{ i18n.description }}:</span></v-col>
<v-col cols="8"><span class="case">{{ props.item.description }}</span></v-col>
</v-row>
</div>
<div>
<v-row no-gutters class="py-1 case tabular-row">
<v-col cols="4"><span class="case tabular-label">{{ i18n.author }}:</span></v-col>
<v-col cols="8"><span class="case">{{ props.item.author }}</span></v-col>
</v-row>
</div>
<div>
<v-row no-gutters class="py-1 case tabular-row">
<v-col cols="4"><span class="case tabular-label">{{ i18n.enabled }}:</span></v-col>
<v-col cols="8"><span class="case">{{ props.item.isEnabled ? 'True' : 'False' }}</span></v-col>
</v-row>
</div>
<div>
<v-row no-gutters class="py-1 case tabular-row">
<v-col cols="4"><span class="case tabular-label">{{ i18n.community }}:</span></v-col>
<v-col cols="8"><span class="case">{{ props.item.isCommunity ? 'True' : 'False' }}</span></v-col>
</v-row>
</div>
<div>
<v-row no-gutters class="py-1 case tabular-row">
<v-col cols="4"><span class="case tabular-label">{{ i18n.reporting }}:</span></v-col>
<v-col cols="8"><span class="case">{{ props.item.isReporting ? 'True' : 'False' }}</span></v-col>
</v-row>
</div>
<div>
<v-row no-gutters class="py-1 case tabular-row">
<v-col cols="4"><span class="case tabular-label">{{ i18n.severity }}:</span></v-col>
<v-col cols="8"><span class="case">{{ props.item.severity }}</span></v-col>
</v-row>
</div>
<div>
<v-row no-gutters class="py-1 case tabular-row">
<v-col cols="4"><span class="case tabular-label">{{ i18n.engine }}:</span></v-col>
<v-col cols="8"><span class="case">{{ props.item.engine }}</span></v-col>
</v-row>
</div>
<div>
<v-row no-gutters class="py-1 case tabular-row">
<v-col cols="4"><span class="case tabular-label">{{ i18n.content }}:</span></v-col>
<v-col cols="8"><span class="case">{{ props.item.content }}</span></v-col>
</v-row>
</div>
<div>
<v-row no-gutters class="py-1 case tabular-row">
<v-col cols="4"><span class="case tabular-label">{{ i18n.note }}:</span></v-col>
<v-col cols="8"><span class="case">{{ props.item.note }}</span></v-col>
</v-row>
</div>

<div class="d-flex flex-column flex-sm-row align-end align-sm-center align-self-end text-body-2">
<v-spacer/>
<v-avatar color="primary" size="24" class="mr-2 d-none d-sm-inline-flex white--text">
<div class="case avatar-font" :title="props.item.owner">
{{ $root.getAvatar(props.item.owner) }}
</div>
</v-avatar>
<div class="mr-1">
{{ props.item.owner }}
</div>
<div class="mr-1 d-none d-sm-block">
&bull;
</div>
<div>
{{ props.item.updateTime | formatDateTime }}
</div>
</div>
</td>
</tr>
</template>
<template v-slot:no-results>
<v-alert :value="true" color="info" icon="fa-info">
{{ i18n.noSearchResults }}
</v-alert>
</template>
</v-data-table>
<div class="text-xs-center pt-2 d-none">
<v-btn v-text="i18n.loadMore" @click="loadHistory()"></v-btn>
</div>
<!-- </div> -->
</v-tab-item>
</v-tabs>
</div>
Expand Down Expand Up @@ -3091,7 +3255,7 @@ <h3 class="text--primary">{{ i18n.evidenceAdd }}</h3>
<v-row>
<v-col>
<v-toolbar class="elevation-0" fixed>
<v-btn :title="i18n.refreshHistoryHelp" color="secondary" @click.stop="reloadAssociation('history', true)" id="refresh-history-button">
<v-btn :title="i18n.refreshCaseHistoryHelp" color="secondary" @click.stop="reloadAssociation('history', true)" id="refresh-history-button">
<v-icon>fa-sync</v-icon>
</v-btn>
</v-toolbar>
Expand Down
7 changes: 6 additions & 1 deletion html/js/i18n.js
Original file line number Diff line number Diff line change
Expand Up @@ -169,6 +169,7 @@ const i18n = {
commentHours: 'Work Hours',
commentHoursHelp: 'Hours spent working on this update (leave blank for 0 hours)',
commentRequired: 'Comments cannot be empty.',
community: 'Community',
completed: 'Completed',
config: 'Configuration',
configTitle: 'Grid Configuration',
Expand Down Expand Up @@ -207,6 +208,7 @@ const i18n = {
configQLZeekThreads: 'Change number of Zeek workers (threads)',
container: 'Container',
containerStatus: 'Container Status',
content: 'Content',
continue: 'Would you like to continue?',
contributors: 'Contributors',
copyEventToClipboardAsJson: 'Copy full event as JSON',
Expand Down Expand Up @@ -293,6 +295,7 @@ const i18n = {
emailRequired: 'An email address must be specified.',
enable: 'Enable',
enabled: 'Enabled',
enabledLabel: 'Enabled:',
endTime: 'Filter End',
endTimeHelp: 'Filter end time in RFC 3339 format (Ex: 2020-10-16 15:30:00.230-04:00). Unused for imported PCAPs.',
engine: 'Engine',
Expand Down Expand Up @@ -596,10 +599,11 @@ const i18n = {
redisQueueSize: 'Redis Queue Size',
refresh: 'Refresh',
refreshAttachmentsHelp: 'Refresh to view all recently added attachments for this case.',
refreshCaseHistoryHelp: 'Refresh to view the latest history for this case.',
refreshDetectionHistoryHelp: 'Refresh to view the latest history for this case.',
refreshCommentsHelp: 'Refresh to view all recently added comments for this case.',
refreshObservablesHelp: 'Refresh to view all recently added observables for this case.',
refreshEventsHelp: 'Refresh to view all recently escalated events for this case.',
refreshHistoryHelp: 'Refresh to view the latest history for this case.',
regex: 'Regex',
reject: 'Reject',
rejected: 'Rejected',
Expand Down Expand Up @@ -755,6 +759,7 @@ const i18n = {
timestampFormat: 'YYYY-MM-DD HH:mm:ss.SSS Z',
timezone: 'Time Zone:',
timezoneHelp: 'Time Zone',
title: 'Title',
toggleLegend: 'Toggle Legend',
toolCyberchef: 'CyberChef',
toolCyberchefHelp: 'Data decoding and transformation tools',
Expand Down
55 changes: 53 additions & 2 deletions html/js/routes/detection.js
Original file line number Diff line number Diff line change
Expand Up @@ -72,6 +72,24 @@ routes.push({ path: '/detection/:id', name: 'detection', component: {
{ value: 'limit', text: 'Limit' },
{ value: 'both', text: 'Both' }
],
historyTableOpts: {
sortBy: 'updateTime',
sortDesc: false,
search: '',
headers: [
{ text: this.$root.i18n.actions, width: '10.0em' },
{ text: this.$root.i18n.username, value: 'owner' },
{ text: this.$root.i18n.time, value: 'updateTime' },
{ text: this.$root.i18n.kind, value: 'kind' },
{ text: this.$root.i18n.operation, value: 'operation' },
],
itemsPerPage: 10,
footerProps: { 'items-per-page-options': [10,50,250,1000] },
count: 500,
expanded: [],
loading: false,
},
history: [],
}},
created() {
this.onDetectionChange = debounce(this.onDetectionChange, 300);
Expand Down Expand Up @@ -126,6 +144,7 @@ routes.push({ path: '/detection/:id', name: 'detection', component: {
const response = await this.$root.papi.get('detection/' + encodeURIComponent(this.$route.params.id));
this.detect = response.data;
this.tagOverrides();
this.loadAssociations();
} catch (error) {
if (error.response != undefined && error.response.status == 404) {
this.$root.showError(this.i18n.notFound);
Expand All @@ -136,6 +155,17 @@ routes.push({ path: '/detection/:id', name: 'detection', component: {

this.$root.stopLoading();
},
loadAssociations() {
this.loadHistory();
},
async loadHistory() {
const route = this;
const id = route.$route.params.id;
const response = await this.$root.papi.get(`detection/${id}/history`);
if (response && response.data) {
this.history = response.data;
}
},
getDefaultPreset(preset) {
if (this.presets) {
const presets = this.presets[preset];
Expand Down Expand Up @@ -632,6 +662,8 @@ routes.push({ path: '/detection/:id', name: 'detection', component: {
}
}
}
} else if (this.detect.engine === 'strelka') {
return false;
}

return true;
Expand All @@ -641,10 +673,29 @@ routes.push({ path: '/detection/:id', name: 'detection', component: {
for (let i = 0; i < this.detect.overrides.length; i++) {
this.detect.overrides[i].index = i;
}
} else {
this.detect.overrides = [];
}
},
print(x) {
console.log(x);
isExpanded(row) {
const expanded = this.historyTableOpts.expanded;
for (var i = 0; i < expanded.length; i++) {
if (expanded[i].id == row.id) {
return true;
}
}
return false;
},
async expandRow(row) {
const expanded = this.historyTableOpts.expanded;
for (var i = 0; i < expanded.length; i++) {
if (expanded[i].id == row.id) {
expanded.splice(i, 1);
return;
}
}

expanded.push(row);
}
}
}});
19 changes: 19 additions & 0 deletions server/detectionhandler.go
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,8 @@ func RegisterDetectionRoutes(srv *Server, r chi.Router, prefix string) {
r.Post("/", h.postDetection)
r.Post("/{id}/duplicate", h.duplicateDetection)

r.Get("/{id}/history", h.getDetectionHistory)

r.Put("/", h.putDetection)

r.Delete("/{id}", h.deleteDetection)
Expand Down Expand Up @@ -115,6 +117,23 @@ func (h *DetectionHandler) postDetection(w http.ResponseWriter, r *http.Request)
web.Respond(w, r, http.StatusOK, detect)
}

func (h *DetectionHandler) getDetectionHistory(w http.ResponseWriter, r *http.Request) {
ctx := r.Context()

id := chi.URLParam(r, "id")
if id == "" {
id = r.URL.Query().Get("id")
}

obj, err := h.server.Detectionstore.GetDetectionHistory(ctx, id)
if err != nil {
web.Respond(w, r, http.StatusNotFound, err)
return
}

web.Respond(w, r, http.StatusOK, obj)
}

func (h *DetectionHandler) duplicateDetection(w http.ResponseWriter, r *http.Request) {
ctx := r.Context()

Expand Down
1 change: 1 addition & 0 deletions server/detectionstore.go
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ type Detectionstore interface {
DeleteDetection(ctx context.Context, detectID string) (*model.Detection, error)
GetAllCommunitySIDs(ctx context.Context, engine *model.EngineName) (map[string]*model.Detection, error) // map[detection.PublicId]detection
Query(ctx context.Context, query string, max int) ([]interface{}, error)
GetDetectionHistory(ctx context.Context, detectID string) ([]interface{}, error)
}

//go:generate mockgen -destination mock/mock_detectionstore.go -package mock . Detectionstore
15 changes: 15 additions & 0 deletions server/mock/mock_detectionstore.go

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

7 changes: 7 additions & 0 deletions server/modules/elastic/elasticdetectionstore.go
Original file line number Diff line number Diff line change
Expand Up @@ -457,6 +457,13 @@ func (store *ElasticDetectionstore) GetAllCommunitySIDs(ctx context.Context, eng
return sids, nil
}

func (store *ElasticDetectionstore) GetDetectionHistory(ctx context.Context, detectID string) ([]interface{}, error) {
query := fmt.Sprintf(`_index:"%s" AND %s%s:"%s" | sortby @timestamp^`, store.auditIndex, store.schemaPrefix, AUDIT_DOC_ID, detectID)
history, err := store.Query(ctx, query, store.maxAssociations)

return history, err
}

func (store *ElasticDetectionstore) audit(ctx context.Context, document map[string]interface{}, id string) error {
var err error

Expand Down

0 comments on commit 868e656

Please sign in to comment.