diff --git a/gui/velociraptor/src/App.jsx b/gui/velociraptor/src/App.jsx index 39530550176..11a0c27025b 100644 --- a/gui/velociraptor/src/App.jsx +++ b/gui/velociraptor/src/App.jsx @@ -31,7 +31,6 @@ import LoginPage from './components/welcome/login.jsx'; import LogoffPage from './components/welcome/logoff.jsx'; import KeyboardHelp from './components/core/keyboard-help.jsx'; import { UserSettings } from './components/core/user.jsx'; -import { ContextMenuPopup } from './components/utils/context.jsx'; import { Switch, Route, withRouter } from "react-router-dom"; import { Join } from './components/utils/paths.jsx'; import SecretManager from './components/secrets/secrets.jsx'; @@ -249,7 +248,6 @@ class App extends Component { - ); diff --git a/gui/velociraptor/src/components/core/paged-table.jsx b/gui/velociraptor/src/components/core/paged-table.jsx index 6aa5c5b0727..0f9cd370915 100644 --- a/gui/velociraptor/src/components/core/paged-table.jsx +++ b/gui/velociraptor/src/components/core/paged-table.jsx @@ -621,7 +621,11 @@ class VeloPagedTable extends Component { } defaultFormatter = (cell, row, rowIndex) => { - return ; + let row_data = {}; + _.each(this.activeColumns(), x=>{ + row_data[x] = row[x]; + }); + return ; } getColumnRenderer = column => { diff --git a/gui/velociraptor/src/components/timeline/timeline.jsx b/gui/velociraptor/src/components/timeline/timeline.jsx index 721827521f2..448e31bc07a 100644 --- a/gui/velociraptor/src/components/timeline/timeline.jsx +++ b/gui/velociraptor/src/components/timeline/timeline.jsx @@ -48,6 +48,18 @@ const FixedColumns = { "Message": 1, }; +const ms_to_ns = (t)=>{ + return (t || 0) * 1000000; +}; + +const ns_to_ms = (t)=>{ + return (t || 0) / 1000000; +}; + +const sec_to_ms = t=>{ + return (t || 0) * 1000; +} + class DeleteComponentDialog extends Component { static propTypes = { notebook_id: PropTypes.string, @@ -532,7 +544,7 @@ export default class TimelineRenderer extends React.Component { return true; } - if (!_.isEqual(prevState.start_time, this.state.start_time)) { + if (!_.isEqual(prevState.start_time_ms, this.state.start_time_ms)) { this.fetchRows(); return true; }; @@ -560,9 +572,9 @@ export default class TimelineRenderer extends React.Component { state = { start_time_iso: "", - start_time: 0, - table_start: 0, - table_end: 0, + start_time_ms: 0, + table_start_ms: 0, + table_end_ms: 0, loading: true, disabled: {}, version: 0, @@ -575,15 +587,15 @@ export default class TimelineRenderer extends React.Component { }; setStartTime = ts_ms=>{ - let ts = ToStandardTime(ts_ms); + let ts = new Date(ts_ms); let timezone = this.context.traits.timezone || "UTC"; this.setState({ - start_time: ts_ms, + start_time_ms: ts_ms, start_time_iso: FormatRFC3339(ts, timezone), }); } - fetchRows = (go_to_start_time) => { + fetchRows = (go_to_start_time_ms) => { let skip_components = []; _.map(this.state.disabled, (v,k)=>{ if(v) { @@ -591,9 +603,9 @@ export default class TimelineRenderer extends React.Component { }; }); - let start_time = (go_to_start_time || this.state.start_time) * 1000000; - if (start_time < 1000000000) { - start_time = 0; + let start_time_ms = this.state.start_time_ms || 0; + if (go_to_start_time_ms) { + start_time_ms = go_to_start_time_ms; } let transform = this.state.transform || {}; @@ -601,7 +613,7 @@ export default class TimelineRenderer extends React.Component { let params = { type: "TIMELINE", timeline: this.props.name, - start_time: start_time, + start_time: ms_to_ns(start_time_ms), rows: this.state.row_count, skip_components: skip_components, notebook_id: this.props.notebook_id, @@ -620,13 +632,16 @@ export default class TimelineRenderer extends React.Component { if (response.cancel) { return; } - let start_time = (response.data.start_time / 1000000) || 0; + let start_time_ms = ns_to_ms(response.data.start_time); let pageData = PrepareData(response.data); let timelines = response.data.timelines; + if (_.isEmpty(pageData.rows)) { + return; + } this.setState({ - table_start: start_time, - table_end: response.data.end_time / 1000000 || 0, + table_start_ms: start_time_ms, + table_end_ms: ns_to_ms(response.data.end_time), columns: pageData.columns, rows: pageData.rows, version: Date(), @@ -636,18 +651,17 @@ export default class TimelineRenderer extends React.Component { // If the visible table is outside the view port, adjust // the view port. if (this.state.visibleTimeStart === 0 || - start_time > this.state.visibleTimeEnd || - start_time < this.state.visibleTimeStart) { + start_time_ms > this.state.visibleTimeEnd || + start_time_ms < this.state.visibleTimeStart) { let diff = (this.state.visibleTimeEnd - this.state.visibleTimeStart) || (60 * 60 * 10000); - let visibleTimeStart = start_time - diff * 0.1; - let visibleTimeEnd = start_time + diff * 0.9; + let visibleTimeStart = start_time_ms - diff * 0.1; + let visibleTimeEnd = start_time_ms + diff * 0.9; this.setState({visibleTimeStart: visibleTimeStart, visibleTimeEnd: visibleTimeEnd}); - this.setStartTime(start_time); - + this.setStartTime(start_time_ms); } this.updateToggles(pageData.rows); @@ -672,8 +686,8 @@ export default class TimelineRenderer extends React.Component { }; nextPage = ()=>{ - if (this.state.table_end > 0) { - this.setStartTime(this.state.table_end + 1); + if (this.state.table_end_ms > 0) { + this.setStartTime(this.state.table_end_ms + 1); } } @@ -719,8 +733,8 @@ export default class TimelineRenderer extends React.Component { let timelines = this.state.timelines || []; let last_event = 0; for(let i=0;i largest) { - largest = end; + if (end_ms > largest) { + largest = end_ms; } // Handle the annotation timeline specifically if (timeline.id === "Annotation") { items.push({ id: i+1, group: timeline.id, - start_time: this.toLocalTZ(start), - end_time: this.toLocalTZ(end), + start_time: this.toLocalTZ(start_ms), + end_time: this.toLocalTZ(end_ms), canMove: false, canResize: false, canChangeGroup: false, @@ -819,8 +833,8 @@ export default class TimelineRenderer extends React.Component { items.push({ id: i+1, group: timeline.id, - start_time: this.toLocalTZ(start), - end_time: this.toLocalTZ(end), + start_time: this.toLocalTZ(start_ms), + end_time: this.toLocalTZ(end_ms), canMove: false, canResize: false, canChangeGroup: false, @@ -869,7 +883,9 @@ export default class TimelineRenderer extends React.Component { { this.renderColumnSelector() } this.fetchRows(1)} + onClick={()=>{ + this.fetchRows(1); + }} variant="default"> @@ -942,7 +958,7 @@ export default class TimelineRenderer extends React.Component { > + date={this.toLocalTZ(this.state.start_time_ms)} > { ({ styles, date }) => { styles.backgroundColor = undefined; styles.width = undefined; diff --git a/gui/velociraptor/src/components/utils/annotations.jsx b/gui/velociraptor/src/components/utils/annotations.jsx new file mode 100644 index 00000000000..a42fdd2e959 --- /dev/null +++ b/gui/velociraptor/src/components/utils/annotations.jsx @@ -0,0 +1,213 @@ +import _ from 'lodash'; + +import PropTypes from 'prop-types'; +import React, { Component } from 'react'; +import Modal from 'react-bootstrap/Modal'; +import T from '../i8n/i8n.jsx'; +import {CancelToken} from 'axios'; +import VeloValueRenderer from '../utils/value.jsx'; +import Button from 'react-bootstrap/Button'; +import { sprintf } from 'sprintf-js'; +import api from '../core/api-service.jsx'; +import Select from 'react-select'; +import Col from 'react-bootstrap/Col'; +import Form from 'react-bootstrap/Form'; +import Row from 'react-bootstrap/Row'; + +const timestampRegex = /\d{4}-\d{2}-\d{2}[T ]\d{1,2}:\d{2}:\d{2}/; + +import { ToStandardTime } from '../utils/time.jsx'; + +export default class AnnotateDialog extends Component { + static propTypes = { + row: PropTypes.object, + onClose: PropTypes.func.isRequired, + } + + componentDidMount = () => { + this.source = CancelToken.source(); + this.fetchGlobalTimelines(); + } + + state = { + collapsed: true, + global_timelines: [], + note: "", + notebook_id: "", + title: "", + name: "", + super_timeline: "", + time_column: "", + message_column: "", + } + + fetchGlobalTimelines = ()=>{ + api.get("v1/GetNotebooks", { + count: 100, + offset: 0, + }, this.source.token).then(response=>{ + if (response.cancel) { + return; + } + + if(response && response.data && response.data.items) { + let timelines = []; + _.each(response.data.items, x=>{ + _.each(x.timelines, y=>{ + timelines.push({notebook_id: x.notebook_id, + title: x.name, + name: y}); + }); + }); + this.setState({global_timelines: timelines}); + } + }); + } + + isTimestamp = x=>{ + if (timestampRegex.test(x)) { + return true; + } + + return false; + } + + annotateRow = ()=>{ + let timestamp = ""; + if (this.state.time_column) { + timestamp = this.props.row[this.state.time_column]; + } + + let ts = ToStandardTime(timestamp || "1980-01-01T00:00:00Z"); + let event = Object.assign({}, this.props.row); + if (this.state.message_column) { + event.Message = this.props.row[this.state.message_column]; + } + api.post("v1/AnnotateTimeline", { + notebook_id: this.state.notebook_id, + super_timeline: this.state.super_timeline, + timestamp: ts.getTime() * 1000000, + note: this.state.note, + event_json: JSON.stringify(event), + }, this.source.token).then(response=>{ + this.props.onClose(); + }); + } + + render() { + let clsname = this.state.collapsed ? "compact": ""; + + let global_timeline_options = _.map(this.state.global_timelines, x=>{ + return {value: x.name, + label: sprintf(T("%s (notebook %s)"), x.name, x.title), + notebook_id: x.notebook_id, + isFixed: true, + color: "#00B8D9"}; + }); + + let time_column_options = []; + _.each(this.props.row, (v, k)=>{ + if(this.isTimestamp(v)) { + time_column_options.push( {value: k, label: k, isFixed: true}); + }; + }); + + let message_column_options = _.map(this.props.row, (v, k)=>{ + return {value: k, label: k, isFixed: true}; + }); + + return + + {T("Annotate Row")} + + + + + {T("Global Timeline")} + + + this.setState({ + super_timeline: e && e.value, + notebook_id: e && e.notebook_id, + })} + placeholder={T("Select timeline name from global notebook")} + spellCheck="false" + /> + + + + + {T("Time column")} + + this.setState({time_column: e && e.value})} + placeholder={T("Time Column")} + spellCheck="false" + /> + + + + {T("Message column")} + + this.setState({ + message_column: e && e.value})} + placeholder={T("Message Column")} + spellCheck="false" + /> + + + + + {T("Note")} + + this.setState({note: e.target.value})} + /> + + + + {T("Extra Data")} + + this.setState({ + collapsed: !this.state.collapsed})}> + + + + + + + + + {T("Cancel")} + + + {T("Submit")} + + + ; + } +} diff --git a/gui/velociraptor/src/components/utils/context.jsx b/gui/velociraptor/src/components/utils/context.jsx index 430ec73dbda..8460cc15dc5 100644 --- a/gui/velociraptor/src/components/utils/context.jsx +++ b/gui/velociraptor/src/components/utils/context.jsx @@ -12,8 +12,7 @@ import { import "react-contexify/dist/ReactContexify.css"; import T from '../i8n/i8n.jsx'; import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'; - -const MENU_ID = "menu-id"; +import AnnotateDialog from './annotations.jsx'; function guessValue(x) { if(_.isObject(x) && x.SHA256 ) { @@ -27,27 +26,32 @@ function guessValue(x) { return JSON.stringify(x); } - -export default function ContextMenu({children, value}) { - const { show } = useContextMenu({ - id: MENU_ID, - props: { - value: guessValue(value), - }, - }); - - return ( - - {children} - - ); +export default function ContextMenu({children, value, row}) { + const MENU_ID = "id" + (Math.random() + 1).toString(36).substring(7); + const { show } = useContextMenu({ + id: MENU_ID, + props: { + value: guessValue(value), + row: row, + }, + }); + + return ( + <> + + {children} + + + > + ); } ContextMenu.propTypes = { children: PropTypes.node.isRequired, value: PropTypes.any, + row: PropTypes.object, }; // Render the main popup menu on the root DOM page. Should only be @@ -57,9 +61,10 @@ export class ContextMenuPopup extends Component { static propTypes = { value: PropTypes.any, + row: PropTypes.object, + id: PropTypes.string, } - // https://stackoverflow.com/questions/133925/javascript-post-request-like-a-form-submit post(path, params, method='post') { @@ -137,36 +142,54 @@ export class ContextMenuPopup extends Component { navigator.clipboard.writeText(cell); } + state = { + showAnnotateDialog: false, + } + render() { let context_links = _.filter( this.context.traits ? this.context.traits.links : [], x=>x.type === "context" && !x.disabled); - return - { - if (e.props) { - this.copyToClipboard(e.props.value); - }}}> - - {T("Clipboard")} - - {_.map(context_links, x=>{ - return ( - this.handleClick(x, e.props && e.props.value)}> - {x.icon_url && - - - } - {x.text} - - ); - })} - ; - + return <> + + { + if (e.props) { + this.copyToClipboard(e.props.value); + }}}> + + {T("Clipboard")} + + {this.props.row && + { + this.setState({showAnnotateDialog: true}); + }}> + + {T("Annotate")} + } + {_.map(context_links, x=>{ + return ( + this.handleClick(x, e.props && e.props.value)}> + {x.icon_url && + + + } + {x.text} + + ); + })} + + { this.state.showAnnotateDialog && + this.setState({ + showAnnotateDialog: false})} + /> } + >; } } diff --git a/gui/velociraptor/src/components/utils/value.jsx b/gui/velociraptor/src/components/utils/value.jsx index 54431fb1687..50866b19206 100644 --- a/gui/velociraptor/src/components/utils/value.jsx +++ b/gui/velociraptor/src/components/utils/value.jsx @@ -40,6 +40,7 @@ export default class VeloValueRenderer extends React.Component { static contextType = UserConfig; static propTypes = { value: PropTypes.any, + row: PropTypes.object, collapsed: PropTypes.bool, }; @@ -66,9 +67,11 @@ export default class VeloValueRenderer extends React.Component { render() { let v = this.props.value; - if (_.isString(v)) { - return {this.maybeFormatTime(v)}; + return + {this.maybeFormatTime(v)} + ; } if (_.isNumber(v)) { @@ -87,7 +90,8 @@ export default class VeloValueRenderer extends React.Component { ; } - return + return {button && { button } } { this.state.showDialog && diff --git a/gui/velociraptor/src/components/widgets/datetime.jsx b/gui/velociraptor/src/components/widgets/datetime.jsx index 35b606a6444..0428ed45d05 100644 --- a/gui/velociraptor/src/components/widgets/datetime.jsx +++ b/gui/velociraptor/src/components/widgets/datetime.jsx @@ -41,7 +41,8 @@ class Calendar extends Component { } componentDidUpdate = (prevProps, prevState, rootNode) => { - if (_.isUndefined(this.state.focus)) { + if (_.isUndefined(this.state.focus) || + this.props.value !== prevProps.value) { let ts = this.parseDate(this.props.value); if(ts.isValid()) { this.setState({focus: ts}); diff --git a/services/notebook/timelines.go b/services/notebook/timelines.go index b63f21efe38..8c9b536174e 100644 --- a/services/notebook/timelines.go +++ b/services/notebook/timelines.go @@ -5,6 +5,7 @@ import ( "encoding/base64" "encoding/binary" "errors" + "fmt" "os" "sync" "time" @@ -73,6 +74,16 @@ func (self *NotebookManager) DeleteTimeline(ctx context.Context, scope vfilter.S func (self *NotebookStoreImpl) Timelines(ctx context.Context, notebook_id string) ([]*timelines_proto.SuperTimeline, error) { + + self.mu.Lock() + defer self.mu.Unlock() + + _, pres := self.global_notebooks[notebook_id] + if !pres { + return nil, fmt.Errorf("Global notebook %v not found: %w", + notebook_id, utils.NotFoundError) + } + dir := paths.NewNotebookPathManager(notebook_id).SuperTimelineDir() db, err := datastore.GetDB(self.config_obj) if err != nil { @@ -436,7 +447,7 @@ func (self *NotebookStoreImpl) AnnotateTimeline( } // Add the annotation event only if the time is valid. - if timestamp.After(epoch) { + if !timestamp.IsZero() && timestamp.After(epoch) { row := event.Update(constants.TIMELINE_DEFAULT_KEY, timestamp). Set("Notes", message). Set(AnnotatedBy, principal). diff --git a/vql/server/hunts/info.go b/vql/server/hunts/info.go new file mode 100644 index 00000000000..afffe58a87a --- /dev/null +++ b/vql/server/hunts/info.go @@ -0,0 +1,84 @@ +package hunts + +import ( + "context" + "strings" + + "github.com/Velocidex/ordereddict" + "www.velocidex.com/golang/velociraptor/acls" + "www.velocidex.com/golang/velociraptor/json" + "www.velocidex.com/golang/velociraptor/services" + "www.velocidex.com/golang/velociraptor/vql" + vql_subsystem "www.velocidex.com/golang/velociraptor/vql" + "www.velocidex.com/golang/vfilter" + "www.velocidex.com/golang/vfilter/arg_parser" +) + +type HuntInfoFunctionArg struct { + HuntId string `vfilter:"optional,field=hunt_id,doc=Hunt Id to look up or a flow id created by that hunt (e.g. F.CRUU3KIE5D73G.H )."` +} + +type HuntInfoFunction struct{} + +func (self *HuntInfoFunction) Call(ctx context.Context, + scope vfilter.Scope, + args *ordereddict.Dict) vfilter.Any { + + err := vql_subsystem.CheckAccess(scope, acls.READ_RESULTS) + if err != nil { + scope.Log("hunt_info: %s", err) + return &vfilter.Null{} + } + + arg := &HuntInfoFunctionArg{} + err = arg_parser.ExtractArgsWithContext(ctx, scope, args, arg) + if err != nil { + scope.Log("hunt_info: %v", err) + return &vfilter.Null{} + } + + err = services.RequireFrontend() + if err != nil { + scope.Log("hunt_info: %v", err) + return &vfilter.Null{} + } + + config_obj, ok := vql_subsystem.GetServerConfig(scope) + if !ok { + scope.Log("hunt_info: Command can only run on the server") + return &vfilter.Null{} + } + + if strings.HasSuffix(arg.HuntId, ".H") && + strings.HasPrefix(arg.HuntId, "F.") { + arg.HuntId = "H." + strings.TrimSuffix( + strings.TrimPrefix(arg.HuntId, "F."), ".H") + } + + hunt_dispatcher_service, err := services.GetHuntDispatcher(config_obj) + if err != nil { + scope.Log("hunt_info: %v", err) + return &vfilter.Null{} + } + + hunt_obj, pres := hunt_dispatcher_service.GetHunt(ctx, arg.HuntId) + if !pres { + return &vfilter.Null{} + } + + return json.ConvertProtoToOrderedDict(hunt_obj) +} + +func (self HuntInfoFunction) Info(scope vfilter.Scope, + type_map *vfilter.TypeMap) *vfilter.FunctionInfo { + return &vfilter.FunctionInfo{ + Name: "hunt_info", + Doc: "Retrieve the hunt information.", + ArgType: type_map.AddType(scope, &HuntInfoFunctionArg{}), + Metadata: vql.VQLMetadata().Permissions(acls.READ_RESULTS).Build(), + } +} + +func init() { + vql_subsystem.RegisterFunction(&HuntInfoFunction{}) +}