Rework Shell GUI to utilize sessions. (#4839)

Implemented persistent Bash, Powershell, CMD and VQL shells

Added full screen and transcript download. The transcript allows the
user to download a textual transcript of the
interactive session.
This commit is contained in:
Mike Cohen
2026-05-21 16:17:08 +10:00
committed by GitHub
parent e851953bd5
commit 2d4cb24ae7
61 changed files with 1805 additions and 656 deletions
+45 -26
View File
@@ -45,40 +45,59 @@ func (self *Pipe) Stat() (os.FileInfo, error) {
return nil, errors.New("Not implemented")
}
// Read a non empty data from the query.
func (self *Pipe) Read(buff []byte) (int, error) {
select {
case <-self.ctx.Done():
return 0, io.EOF
case row, ok := <-self.output_chan:
if !ok {
// Keep trying to read something until the first non empty result
getrow:
for {
select {
case <-self.ctx.Done():
return 0, io.EOF
}
switch t := row.(type) {
case *ordereddict.Dict:
for _, v := range t.Values() {
switch t := v.(type) {
case string:
out := append([]byte(t), self.sep...)
return utils.MemCpy(buff, out), nil
case row, ok := <-self.output_chan:
if !ok {
return 0, io.EOF
}
case []byte:
return utils.MemCpy(buff,
append(t, self.sep...)), nil
switch t := row.(type) {
case *ordereddict.Dict:
for _, v := range t.Values() {
switch t := v.(type) {
case string:
if t == "" {
continue getrow
}
default:
data := fmt.Sprintf("%v", v)
out := append([]byte(data), self.sep...)
return utils.MemCpy(buff, out), nil
out := append([]byte(t), self.sep...)
return utils.MemCpy(buff, out), nil
case []byte:
if len(t) == 0 {
continue getrow
}
return utils.MemCpy(buff,
append(t, self.sep...)), nil
default:
data := fmt.Sprintf("%v", v)
if len(data) == 0 {
continue getrow
}
out := append([]byte(data), self.sep...)
return utils.MemCpy(buff, out), nil
}
}
}
}
data := fmt.Sprintf("%v", row)
out := append([]byte(data), self.sep...)
return utils.MemCpy(buff, out), nil
data := fmt.Sprintf("%v", row)
if len(data) == 0 {
continue getrow
}
out := append([]byte(data), self.sep...)
return utils.MemCpy(buff, out), nil
}
}
}
+1 -1
View File
@@ -17,9 +17,9 @@ import (
"www.velocidex.com/golang/velociraptor/vtesting/assert"
// For execve and query
_ "www.velocidex.com/golang/velociraptor/vql/common"
_ "www.velocidex.com/golang/velociraptor/vql/protocols"
_ "www.velocidex.com/golang/velociraptor/vql/tools"
_ "www.velocidex.com/golang/velociraptor/vql/tools/shell"
)
type ClientVQLTestSuite struct {
@@ -11,4 +11,10 @@ parameters:
sources:
- query: |
SELECT * FROM query(query=Command, env=dict(config=config))
SELECT _SessionId, *
FROM query(query=Command, env=dict(config=config))
- name: Overview
query: |
SELECT _SessionId, timestamp(epoch=now()) AS Timestamp, Command
FROM scope()
+57 -2
View File
@@ -11,6 +11,9 @@ description: |
elevated permissions (specifically the `EXECVE` permission).
Typically it is only available with the `administrator` role.
In recent Velociraptor releases, the artifact will create a
persistent session.
required_permissions:
- EXECVE
@@ -19,9 +22,52 @@ parameters:
default: "ls -l /"
sources:
- query: |
- precondition: |
SELECT * FROM info() WHERE version(function="shell_session")
query: |
// Get the core flow id to key a unique session off.
LET FLOWID <= split(string=_SessionId, sep="/")[0]
// Newer clients have support for true shell sessions.
LET Session <= shell_session(name=FLOWID, argv=["bash"])
LET _ <= shell_session_control(name=FLOWID, stdin=Command + "\n")
SELECT * FROM if(condition=NOT Session.IsRunning,
then={
SELECT Stdin AS Command,
timestamp(epoch=now(ns=TRUE)) AS Timestamp,
Stdout,
NULL AS StdoutUpload,
Stderr,
NULL AS StderrUpload
FROM foreach(row=Session.Query)
})
notebook:
- type: vql
name: Transcript
template: |
/*
# View session transcript
*/
LET Transcript = pipe(query={
SELECT Command + Stdout AS Line
FROM source()
WHERE Line
})
SELECT upload(accessor="pipe",
file="Transcript", name="transcript.txt") AS Transcript
FROM scope()
- precondition: |
SELECT * FROM info() WHERE NOT version(function="shell_session")
notebook:
- type: none
query: |
LET SizeLimit <= 4096
LET Now <= str(str=now())
LET Now = str(str=now())
LET Output = SELECT "" AS Command,
timestamp(epoch=now()) AS Timestamp,
@@ -51,6 +97,15 @@ sources:
FROM scope()
}, b=Output)
resources:
# By default the shell session is up for an hour or until cancelled
# by the GUI.
timeout: 3600
column_types:
- name: StdoutUpload
type: preview_upload
- name: StderrUpload
type: preview_upload
- name: Transcript
type: preview_upload
@@ -21,6 +21,11 @@ tools:
serve_locally: true
version: <VERSION>
- name: VelociraptorLinuxSumo
url: https://github.com/Velocidex/velociraptor/releases/download/v<VERSION_BARE>/velociraptor-v<VERSION>-linux-amd64-sumo-musl
serve_locally: true
version: <VERSION>
# On MacOS we cannot embed the config in the binary so we use a
# shell script stub instead. See
# https://github.com/Velocidex/velociraptor/issues/2898
@@ -21,6 +21,11 @@ tools:
serve_locally: true
version: 0.76.5
- name: VelociraptorLinuxSumo
url: https://github.com/Velocidex/velociraptor/releases/download/v0.76/velociraptor-v0.76.5-linux-amd64-sumo-musl
serve_locally: true
version: 0.76.5
# On MacOS we cannot embed the config in the binary so we use a
# shell script stub instead. See
# https://github.com/Velocidex/velociraptor/issues/2898
@@ -2,9 +2,10 @@ name: Server.Utils.CreateLinuxPackages
description: |
Builds Debian (.deb) and RPM packages with embedded client config for the current organization.
This artifact depends on the following tool:
This artifact depends on the following tools:
* <velo-tool-viewer name="VelociraptorLinux" />
* Clients <velo-tool-viewer name="VelociraptorLinux" />
* Server <velo-tool-viewer name="VelociraptorLinuxSumo" />
You can replace this with suitable Velociraptor Linux build, or else
the current release binary will be used by default.
@@ -20,15 +20,57 @@ description: |
required_permissions:
- EXECVE
precondition:
SELECT OS From info() where OS = 'windows'
parameters:
- name: Command
default: "dir C:\\"
sources:
- query: |
- precondition: |
SELECT * FROM info()
WHERE OS = 'windows' AND version(function="shell_session")
query: |
// Get the core flow id to key a unique session off.
LET FLOWID <= split(string=_SessionId, sep="/")[0]
// Newer clients have support for true shell sessions.
LET Session <= shell_session(name=FLOWID, argv=["cmd.exe"])
LET _ <= shell_session_control(name=FLOWID, stdin=Command + "\n")
SELECT * FROM if(condition=NOT Session.IsRunning,
then={
SELECT Stdin AS Command,
timestamp(epoch=now(ns=TRUE)) AS Timestamp,
Stdout,
NULL AS StdoutUpload,
Stderr,
NULL AS StderrUpload
FROM foreach(row=Session.Query)
})
notebook:
- type: vql
name: Transcript
template: |
/*
# View session transcript
*/
LET Transcript = pipe(query={
SELECT Command + Stdout AS Line
FROM source()
WHERE Line
})
SELECT upload(accessor="pipe",
file="Transcript", name="transcript.txt") AS Transcript
FROM scope()
- precondition: |
SELECT * FROM info()
WHERE OS = "windows" AND NOT version(function="shell_session")
notebook:
- type: none
query: |
LET SizeLimit <= 4096
LET Now <= str(str=now())
@@ -61,3 +103,7 @@ sources:
column_types:
- name: StdoutUpload
type: preview_upload
- name: StderrUpload
type: preview_upload
- name: Transcript
type: preview_upload
@@ -31,9 +31,6 @@ description: |
required_permissions:
- EXECVE
precondition:
SELECT OS From info() where OS = 'windows'
parameters:
- name: Command
default: "dir C:/"
@@ -41,7 +38,53 @@ parameters:
default: "C:\\Windows\\System32\\WindowsPowerShell\\v1.0\\powershell.exe"
sources:
- query: |
- precondition: |
SELECT * FROM info()
WHERE OS = 'windows' AND version(function="shell_session")
query: |
// Get the core flow id to key a unique session off.
LET FLOWID <= split(string=_SessionId, sep="/")[0]
// Newer clients have support for true shell sessions.
LET Session <= shell_session(name=FLOWID,
argv=[PowerShellExe, "-ExecutionPolicy", "Unrestricted"])
LET _ <= shell_session_control(name=FLOWID, stdin=Command + "\n")
SELECT * FROM if(condition=NOT Session.IsRunning,
then={
SELECT Stdin AS Command,
timestamp(epoch=now(ns=TRUE)) AS Timestamp,
Stdout,
NULL AS StdoutUpload,
Stderr,
NULL AS StderrUpload
FROM foreach(row=Session.Query)
})
notebook:
- type: vql
name: Transcript
template: |
/*
# View session transcript
*/
LET Transcript = pipe(query={
SELECT Stdout AS Line
FROM source()
WHERE Line
})
SELECT upload(accessor="pipe",
file="Transcript", name="transcript.txt") AS Transcript
FROM scope()
- precondition: |
SELECT * FROM info()
WHERE OS = "windows" AND NOT version(function="shell_session")
notebook:
- type: none
query: |
LET SizeLimit <= 4096
LET Now <= str(str=now())
@@ -81,3 +124,5 @@ column_types:
type: preview_upload
- name: StderrUpload
type: preview_upload
- name: Transcript
type: preview_upload
File diff suppressed because one or more lines are too long
+3 -3
View File
@@ -48,7 +48,7 @@ var (
config_command = app.Command(
"config", "Manipulate the configuration.")
config_command_org = config_command.Flag(
org_id = app.Flag(
"org", "Org ID to show").String()
config_show_command = config_command.Command(
@@ -163,7 +163,7 @@ func doShowConfig() error {
}
defer sm.Close()
config_obj, err = maybeGetOrgConfig(*config_command_org, config_obj)
config_obj, err = maybeGetOrgConfig(*org_id, config_obj)
if err != nil {
return err
}
@@ -342,7 +342,7 @@ func doDumpClientConfig() error {
}
defer sm.Close()
config_obj, err = maybeGetOrgConfig(*config_command_org, config_obj)
config_obj, err = maybeGetOrgConfig(*org_id, config_obj)
if err != nil {
return err
}
+20
View File
@@ -151,6 +151,11 @@ func doLS(path, accessor string) error {
}
defer sm.Close()
config_obj, err = maybeGetOrgConfig(*org_id, config_obj)
if err != nil {
return err
}
matches := accessor_reg.FindStringSubmatch(path)
if matches != nil {
accessor = matches[1]
@@ -219,6 +224,11 @@ func doRM(path, accessor string) error {
}
defer sm.Close()
config_obj, err = maybeGetOrgConfig(*org_id, config_obj)
if err != nil {
return err
}
matches := accessor_reg.FindStringSubmatch(path)
if matches != nil {
accessor = matches[1]
@@ -281,6 +291,11 @@ func doCp(path, accessor string, dump_dir string) error {
}
defer sm.Close()
config_obj, err = maybeGetOrgConfig(*org_id, config_obj)
if err != nil {
return err
}
matches := accessor_reg.FindStringSubmatch(path)
if matches != nil {
accessor = matches[1]
@@ -385,6 +400,11 @@ func doCat(path, accessor_name string) error {
}
defer sm.Close()
config_obj, err = maybeGetOrgConfig(*org_id, config_obj)
if err != nil {
return err
}
logger := &LogWriter{config_obj: config_obj}
builder := services.ScopeBuilder{
Config: config_obj,
+1 -3
View File
@@ -33,8 +33,6 @@ var (
grant_command = acl_command.Command(
"grant", "Grant a principal a policy.")
grant_command_org = grant_command.Flag("org", "OrgID to grant").String()
grant_command_principal = grant_command.Arg(
"principal", "Name of principal (User or cert) to grant.").
Required().String()
@@ -88,7 +86,7 @@ func doGrant() error {
return err
}
org_config_obj, err := org_manager.GetOrgConfig(*grant_command_org)
org_config_obj, err := org_manager.GetOrgConfig(*org_id)
if err != nil {
return err
}
+3 -7
View File
@@ -60,10 +60,6 @@ var (
"timeout", "Time collection out after this many seconds.").
Default("0").Float64()
query_org_id = query.Flag(
"org", "The Org ID to target with this query").
Default("root").String()
query_command_collect_cpu_limit = query.Flag(
"cpu_limit", "A number between 0 to 100 representing maximum CPU utilization.").
Default("0").Float64()
@@ -312,16 +308,16 @@ func doQuery() error {
logging.GetLogger(config_obj, &logging.ToolComponent).
Info("API Client configuration loaded - will make gRPC connection.")
return doRemoteQuery(
config_obj, *format, *query_org_id, vql_queries, env)
config_obj, *format, *org_id, vql_queries, env)
}
if *query_org_id != "" {
if *org_id != "" {
org_manager, err := services.GetOrgManager()
if err != nil {
return err
}
org_config_obj, err := org_manager.GetOrgConfig(*query_org_id)
org_config_obj, err := org_manager.GetOrgConfig(*org_id)
if err != nil {
return err
}
+74 -6
View File
@@ -1345,12 +1345,16 @@
you will often need to specify a field name containing full
stop. You can escape this using the backticks like the example above.
type: Function
version: 2
version: 3
args:
- name: client_id
type: string
description: The client id to schedule a collection on
required: true
- name: flow_id
type: string
description: If a flow id is specified we do not create a new flow, but instead
add the collection to this flow.
- name: artifacts
type: string
description: A list of artifacts to collect
@@ -2240,15 +2244,20 @@
- windows_386_cgo
- windows_amd64_cgo
- name: encode
description: Encodes a string as a different type. Currently supported types include
'hex', 'base64'.
description: |
Encode an object as a string.
This function is an alias to `serialize()`
type: Function
version: 3
args:
- name: string
type: Any
description: The item to encode
required: true
- name: type
type: string
description: Encoding format (csv,json,yaml,hex,base64)
required: true
category: encode
platforms:
@@ -6307,6 +6316,11 @@
- [timestamp]({{< ref "/vql_reference/popular/timestamp/" >}})
type: Function
version: 2
args:
- name: ns
type: bool
description: Return the time in ns
category: popular
platforms:
- darwin_amd64_cgo
@@ -6514,7 +6528,7 @@
type: Plugin
category: developer
metadata:
permissions: MACHINE_STATE
permissions: SERVER_ADMIN
platforms:
- darwin_amd64_cgo
- darwin_arm64_cgo
@@ -9704,6 +9718,8 @@
WHERE DNSInfo AND Name
})
```
NOTE: An alternative to this plugin is a `Sigma Correlation rule`
type: Plugin
args:
- name: query
@@ -9736,9 +9752,12 @@
example:
```vql
SELECT * FROM Artifact.Linux.Search.FileFinder(SearchFilesGlobTable=serialize(format="csv",item=tlist),...)
SELECT * FROM Artifact.Linux.Search.FileFinder(
SearchFilesGlobTable=serialize(format="csv",item=tlist),...)
```
will pass a list in CSV format to the artifact's `SearchFilesGlobTable` parameter.
will pass a list in CSV format to the artifact's
`SearchFilesGlobTable` parameter.
### See also
@@ -9874,6 +9893,55 @@
- linux_amd64_cgo
- windows_386_cgo
- windows_amd64_cgo
- name: shell_session
description: Recreate or retrieve a shell session handle.
type: Function
version: 1
args:
- name: argv
type: string
description: Argv to run the command with.
repeated: true
required: true
- name: env
type: ordereddict.Dict
description: Environment variables to launch with.
- name: cwd
type: string
description: If specified we change to this working directory first.
- name: secret
type: string
description: The name of a secret to use.
- name: name
type: string
description: The name of the shell session. If the session already exists, we
just return a handle to it.
metadata:
permissions: EXECVE
platforms:
- linux_amd64_cgo
- name: shell_session_control
description: Control a previously created shell session.
type: Function
version: 1
args:
- name: name
type: string
description: The name of the shell session.
- name: stdin
type: string
description: Write this string to the session stdin.
- name: close_stdin
type: bool
description: If specified we close the stdin of the session. This will usually
terminate the session gracefully.
- name: kill
type: bool
description: If specified we kill the session.
metadata:
permissions: EXECVE
platforms:
- linux_amd64_cgo
- name: sigma
description: |
Evaluate sigma rules.
-5
View File
@@ -102,11 +102,6 @@ func getTypeFromComponents(components []string) ([]string, api.PathType) {
return components, api.PATH_TYPE_FILESTORE_ANY
}
// Client uploads are all untyped
if len(components) > 4 && components[0] == "clients" {
return components, api.PATH_TYPE_FILESTORE_ANY
}
last_component := components[len(components)-1]
// Fallback, use the extension to deduce the type.
+25
View File
@@ -44,6 +44,31 @@ func TestFromGenericComponentList(t *testing.T) {
client_path: "/notebooks/N.D55OJV2COB544/NC.D56I6S6LK00FI-D56I71V0BJULE/uploads/data/file.zip",
path_type: api.PATH_TYPE_FILESTORE_ANY,
},
{
// Client collection uploads
components: []string{
"clients", "C.d7f8859f5e0e01f7", "collections", "F.D55T34A0NIDTC",
"uploads", "data", "file.json"},
client_path: "/clients/C.d7f8859f5e0e01f7/collections/F.D55T34A0NIDTC/uploads/data/file.json",
path_type: api.PATH_TYPE_FILESTORE_ANY,
},
{
// Client collection jsonl logs
components: []string{
"clients", "C.d7f8859f5e0e01f7",
"collections", "F.D55T34A0NIDTC", "logs.json"},
client_path: "/clients/C.d7f8859f5e0e01f7/collections/F.D55T34A0NIDTC/logs.json",
path_type: api.PATH_TYPE_FILESTORE_JSON,
},
{
// Client collection jsonl logs
components: []string{
"clients", "C.d7f8859f5e0e01f7",
"artifacts", "System.VFS.ListDirectory",
"F.D55T34A0NIDTC", "Stats.json"},
client_path: "/clients/C.d7f8859f5e0e01f7/artifacts/System.VFS.ListDirectory/F.D55T34A0NIDTC/Stats.json",
path_type: api.PATH_TYPE_FILESTORE_JSON,
},
{
// Client notebook attachments are always PATH_TYPE_FILESTORE_ANY
components: []string{
+11 -3
View File
@@ -312,7 +312,8 @@ func (self *ClientFlowRunner) ProcessSingleMessage(
}
if msg.LogMessage != nil {
err := self.LogMessage(ctx, client_id, flow_id, msg.LogMessage)
err := self.LogMessage(
ctx, client_id, flow_id, child_flow_id, msg.LogMessage)
if err != nil {
return fmt.Errorf("LogMessage: %w", err)
}
@@ -824,7 +825,7 @@ func (self *ClientFlowRunner) processAlert(
}
func (self *ClientFlowRunner) LogMessage(
ctx context.Context, client_id, flow_id string,
ctx context.Context, client_id, flow_id, child_flow_id string,
msg *crypto_proto.LogMessage) error {
flow_path_manager := paths.NewFlowPathManager(client_id, flow_id).Log()
@@ -840,7 +841,14 @@ func (self *ClientFlowRunner) LogMessage(
}
defer rs_writer.Close()
_ = rs_writer.SetStartRow(int64(msg.Id))
// If this is a child flow, append to the result set's end
// otherwise, write at the specified offset.
if child_flow_id == "" {
_ = rs_writer.SetStartRow(int64(msg.Id))
} else {
_ = rs_writer.SetStartRow(rs_writer.TotalRows())
}
// The JSON payload from the client.
payload := artifacts.DeobfuscateString(self.config_obj, msg.Jsonl)
+6 -1
View File
@@ -22,6 +22,7 @@ import FullScreenTable from './components/notebooks/table_view.jsx';
import FullScreenNotebook from './components/notebooks/full_notebook.jsx';
import FullScreenHuntNotebook from './components/hunts/hunt-full-notebook.jsx';
import FullScreenFlowNotebook from './components/flows/flow-full-notebook.jsx';
import { ShellViewerFullScreen } from "./components/clients/shell-viewer.jsx";
import ArtifactInspector from './components/artifacts/artifacts.jsx';
import UserInspector from './components/users/user-inspector.jsx';
import VeloHunts from './components/hunts/hunts.jsx';
@@ -44,7 +45,7 @@ import Navbar from 'react-bootstrap/Navbar';
import Nav from 'react-bootstrap/Nav';
import SidebarKeyNavigator from './components/sidebar/hotkeys.jsx';
import {getItem, setItem, schema} from './components/core/storage.jsx';
import {setItem, schema} from './components/core/storage.jsx';
import './themes/no-theme.css';
import './themes/veloci-light.css';
@@ -253,6 +254,10 @@ class App extends Component {
<SnackbarProvider>
<SidebarKeyNavigator client={this.state.client}/>
<Switch>
<Route path="/fullscreen/shell/:artifact/:client_id/:flow_id">
<ShellViewerFullScreen />
</Route>
<Route path="/fullscreen/notebooks/:notebook_id">
<FullScreenNotebook />
</Route>
@@ -9,7 +9,6 @@ import Col from 'react-bootstrap/Col';
import Button from 'react-bootstrap/Button';
import InputGroup from 'react-bootstrap/InputGroup';
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
import classNames from "classnames";
import api from '../core/api-service.jsx';
import {CancelToken} from 'axios';
import T from '../i8n/i8n.jsx';
@@ -71,7 +70,6 @@ export default class ArtifactsUpload extends React.Component {
{file: this.state.pack_file},
{name: "ArtifactPack", type: "upload_file"}).then(
response=>{
let url = response.data.url;
this.setState({
loading: false,
upload_info: response.data,
@@ -114,7 +112,6 @@ export default class ArtifactsUpload extends React.Component {
vfs_path: response.data.vfs_path,
uploaded: uploaded});
}).catch(err=>{
console.log(err);
this.setState({loading: false});
});
};
@@ -130,8 +127,6 @@ export default class ArtifactsUpload extends React.Component {
};
api.post("v1/LoadArtifactPack", request,
this.source.token).then(response => {
console.log(response);
if (response.data.cancel) {
return ;
}
@@ -4,8 +4,7 @@ import _ from 'lodash';
import React from 'react';
import PropTypes from 'prop-types';
import {CancelToken} from 'axios';
import { cleanupHTML } from '../core/sanitize.jsx';
import parseHTML from '../core/sanitize.jsx';
import parseHTML, { cleanupHTML } from '../core/sanitize.jsx';
import api from '../core/api-service.jsx';
import { VeloLineChart, VeloTimeChart } from './line-charts.jsx';
@@ -409,9 +409,13 @@ class VeloHostInfo extends Component {
}
if (this.state.mode === 'shell') {
let system = this.props.client && this.props.client.os_info &&
this.props.client.os_info.system;
return (
<div className="client-details shell">
<ShellViewer client={this.props.client} />
<ShellViewer
system={system}
client_id={this.props.client.client_id} />
</div>
);
}
@@ -25,6 +25,8 @@ class VeloClientSearch extends Component {
static propTypes = {
// Update the applications' search parameter.
setSearch: PropTypes.func.isRequired,
history: PropTypes.any,
};
componentDidMount = () => {
@@ -28,8 +28,14 @@
width: 100%;
}
.cell-toolbar.not_selected {
display: none;
.cell-toolbar {
width: 100%;
position: static;
background: var(--color-canvas-background);
}
.cell-toolbar .btn-toolbar {
display: block;
}
.notebook-output.collapsed {
@@ -46,6 +52,16 @@
max-height: 200em;
}
.shell-output-container {
height: 100%;
overflow-y: hidden;
width: 100%;
}
.fullscreen .shell-output-container {
height: 85vh;
}
div.dt-buttons {
margin-bottom: 6px;
}
@@ -77,10 +93,6 @@ div.dt-buttons {
display: block;
}
.shell-cell.expanded {
max-height: 200em;
}
.shell-command {
padding: 5px;
}
@@ -93,10 +105,10 @@ div.dt-buttons {
display: hidden;
}
.notebook-output pre, .notebook-input pre {
.shell-output-container pre {
display: block;
padding: 8px;
margin: 0 0 8.5px;
margin-bottom: 0px;
font-size: 12px;
line-height: 1.31;
word-break: break-all;
@@ -106,21 +118,17 @@ div.dt-buttons {
border-radius: 4px;
}
.shell-output-container .notebook-input pre {
.shell-output-container .accordion-header pre {
color: var(--color-sidebar-disabled-foreground);
margin: 0;
border-radius: 0;
}
.shell-output-container .notebook-output pre {
.shell-output-container .accordion-body pre {
color: var(--color-foreground);
border-radius: 0;
}
.shell-results {
overflow-y: none;
}
.shell-command .velo-ace-editor {
flex: auto;
width: 1%;
@@ -142,8 +150,42 @@ div.dt-buttons {
display: inline-flex;
}
.shell-info i {
margin-right: 10px;
}
.shell-output-container {
max-height: 50vh;
overflow-y: auto;
overflow-x: hidden;
}
.shell-output-container .row {
width: 100%;
}
.shell-results {
max-height: 70vh;
overflow-y: auto;
}
.shell-cell {
margin-bottom: 20px;
}
.stderr {
color: var(--color-warning-color);
}
.shell-vql-cell .velo-ace-editor {
height: 38px;
width: 70vw;
}
.fullscreen .shell-vql-cell.shell-output-container {
height: 65vh;
}
.fullscreen .vql-shell-input.velo-ace-editor {
height: 15vh;
width: 70vw;
}
File diff suppressed because it is too large Load Diff
@@ -642,11 +642,12 @@ class VeloPagedTable extends Component {
}
let page_size = getItem(schema.CurrentPageSizeKey) || 10;
let transform = this.props.params && this.props.params.transform;
this.setState({
guid: getID(),
page_size: page_size,
transform: transform || {},
});
this.fetchRows();
}
@@ -13,7 +13,6 @@ import VeloTimestamp from "../utils/time.jsx";
import URLViewer from "../utils/url.jsx";
import VeloAce, { SettingsButton } from '../core/ace.jsx';
import VeloValueRenderer from '../utils/value.jsx';
import { NavLink } from "react-router-dom";
import ClientLink from '../clients/client-link.jsx';
import FlowLink from '../flows/flow-link.jsx';
import { HexViewPopup } from '../utils/hex.jsx';
@@ -62,10 +62,6 @@ class _UserSettings extends React.Component {
let current_theme = this.state.traits.theme;
this.setState({
traits: traits,
messages: response.data.messages || []});
// Only update the theme if it changed.
if (current_theme !== traits.theme) {
document.body.classList.remove('no-theme');
@@ -86,6 +82,14 @@ class _UserSettings extends React.Component {
document.body.classList.add("veloci-light");
}
}
if(_.isEqual(traits, this.state.traits)) {
return;
};
this.setState({
traits: traits,
messages: response.data.messages || []});
});
}
@@ -16,7 +16,6 @@ import Alert from 'react-bootstrap/Alert';
import Accordion from 'react-bootstrap/Accordion';
import Badge from 'react-bootstrap/Badge';
import parseHTML from '../core/sanitize.jsx';
import { cleanupHTML } from '../core/sanitize.jsx';
import hljs from 'highlight.js';
import ToolViewer from '../tools/tool-viewer.jsx';
import markdownit from 'markdown-it';
@@ -34,6 +33,7 @@ class Admonition extends Component {
static propTypes = {
type: PropTypes.string,
caption: PropTypes.string,
children: PropTypes.node,
}
renderType = ()=>{
@@ -48,7 +48,7 @@ export default class Image extends Component {
render() {
return <>
<a className="image-popup"
<a className="image-popup" role="button"
onClick={()=>this.setState({expanded: true})}>
<span><img alt={this.props.alt} src={this.props.src} /></span>
<span className="figcaption">{this.props.alt}</span>
@@ -21,7 +21,7 @@ import T from '../i8n/i8n.jsx';
import ToolTip from '../widgets/tooltip.jsx';
import VeloLog from "../widgets/logs.jsx";
import { EditNotebook } from '../notebooks/new-notebook.jsx';
import {getItem, setItem, schema} from '../core/storage.jsx';
import {setItem, schema} from '../core/storage.jsx';
import Select from 'react-select';
import { withRouter } from "react-router-dom";
@@ -10,14 +10,8 @@ import FlowLogs from './flow-logs.jsx';
import FlowNotebook from './flow-notebook.jsx';
import { withRouter } from "react-router-dom";
import {getItem, setItem, schema} from '../core/storage.jsx';
// Typically subclassable by actual components.
import api from '../core/api-service.jsx';
import {CancelToken} from 'axios';
const POLL_TIME = 5000;
class FlowInspector extends React.Component {
static propTypes = {
flow: PropTypes.object,
@@ -32,7 +32,6 @@ import DeleteNotebookDialog from '../notebooks/notebook-delete.jsx';
import ExportNotebook from '../notebooks/export-notebook.jsx';
import T from '../i8n/i8n.jsx';
import UserConfig from '../core/user.jsx';
import {getItem, setItem} from '../core/storage.jsx';
import api from '../core/api-service.jsx';
import {CancelToken} from 'axios';
@@ -154,7 +154,7 @@ class NewNotebookLaunch extends React.Component {
export class NewNotebook extends React.Component {
static propTypes = {
notebook_paramerers: PropTypes.object,
notebook_parameters: PropTypes.object,
parameters: PropTypes.object,
closeDialog: PropTypes.func.isRequired,
updateNotebooks: PropTypes.func.isRequired,
@@ -201,12 +201,10 @@ export class NewNotebook extends React.Component {
}
setParameters = (params) => {
console.log("setParameters", params);
this.setState({parameters: params});
}
setNotebookParameters = (params) => {
console.log("setNotebookParameters", params);
this.setState({notebook_parameters: params});
}
@@ -1,12 +1,9 @@
import _ from 'lodash';
import "./notebook-navigator.css";
import Button from 'react-bootstrap/Button';
import PropTypes from 'prop-types';
import React, { Component } from 'react';
import T from '../i8n/i8n.jsx';
import VeloTable, { getFormatter } from '../core/table.jsx';
import VeloTimestamp from "../utils/time.jsx";
import classNames from "classnames";
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
import { withRouter } from "react-router-dom";
@@ -184,10 +184,12 @@ export default class NotebookRenderer extends React.Component {
}
getRef = cell_id=>{
let res = this.state.refs[cell_id];
let refs = this.state.refs;
let res = refs[cell_id];
if(!res) {
res = React.createRef();
this.state.refs[cell_id] = res;
refs[cell_id] = res;
this.setState({refs: refs});
}
return res;
}
@@ -12,7 +12,7 @@ import {CancelToken} from 'axios';
import api from '../core/api-service.jsx';
import Spinner from '../utils/spinner.jsx';
import { withRouter } from "react-router-dom";
import {getItem, setItem, schema} from '../core/storage.jsx';
import {setItem, schema} from '../core/storage.jsx';
// Poll for new notebooks list.
const POLL_TIME = 5000;
@@ -79,7 +79,7 @@ class ServerInfo extends Component {
return (
<div className="client-details shell">
<ShellViewer
client={{client_id: "server"}}
client_id={"server"}
default_shell="Bash"
/>
</div>
@@ -10,7 +10,7 @@ import classNames from "classnames";
import { NavLink } from "react-router-dom";
import T from '../i8n/i8n.jsx';
import ToolTip from '../widgets/tooltip.jsx';
import {getItem, setItem, schema} from '../core/storage.jsx';
import {getItem, schema} from '../core/storage.jsx';
import { EncodePathInURL } from '../utils/paths.jsx';
@@ -126,11 +126,6 @@ class VeloNavigator extends Component {
disabled = "disabled";
};
let vfs_path = "";
if (this.props.vfs_path.length) {
vfs_path = this.props.vfs_path;
}
// Only show the user management screen if the user is an
// admin in this org.
let user_is_admin = this.context.traits &&
@@ -1,13 +1,9 @@
import _ from 'lodash';
import PropTypes from 'prop-types';
import React, { Component } from 'react';
import Button from 'react-bootstrap/Button';
import UserConfig from '../core/user.jsx';
import Modal from 'react-bootstrap/Modal';
import T from '../i8n/i8n.jsx';
import Alert from 'react-bootstrap/Alert';
import VeloLog from "../widgets/logs.jsx";
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
import VeloPagedTable from '../core/paged-table.jsx';
import ButtonGroup from 'react-bootstrap/ButtonGroup';
@@ -21,7 +21,7 @@ import {CancelToken} from 'axios';
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
import VeloForm from "../forms/form.jsx";
import FlowLink from "../flows/flow-link.jsx";
import {getItem, setItem, schema} from '../core/storage.jsx';
import {setItem, schema} from '../core/storage.jsx';
import T from '../i8n/i8n.jsx';
@@ -12,7 +12,7 @@ import { EncodePathInURL, DecodePathInURL, SplitPathComponents, Join } from '../
import api from '../core/api-service.jsx';
import { withRouter } from "react-router-dom";
import { JSONparse } from '../utils/json_parse.jsx';
import {getItem, setItem, schema} from '../core/storage.jsx';
import {setItem, schema} from '../core/storage.jsx';
class VeloFileTree extends Component {
static contextType = UserConfig;
@@ -415,7 +415,7 @@ class InspectDialog extends React.Component {
size={this.props.size}/>
}
</Tab>
<Tab eventKey="details" title={T("Details")}>
<Tab eventKey="details" title={T("Download")}>
{ this.state.tab === "details" &&
<>
<Download fs_components={components}
+2 -1
View File
@@ -29,6 +29,7 @@ import { faHome, faCrosshairs, faWrench, faEye, faServer, faBook, faLaptop,
faChevronRight, faEllipsis, faLayerGroup, faBullseye, faPersonRunning,
faQuestion, faCalendarPlus, faForwardFast, faBackwardFast, faSliders,
faRepeat, faBorderAll, faBell, faCircleQuestion, faLightbulb, faBomb,
faAngleDown, faAngleUp,
} from '@fortawesome/free-solid-svg-icons';
import { faSquare, faSquareCheck, faSquareMinus,
@@ -54,7 +55,7 @@ library.add(faHome, faCrosshairs, faWrench, faEye, faServer, faBook, faLaptop,
faEllipsis, faLayerGroup, faBullseye, faPersonRunning, faQuestion,
faCalendarPlus, faForwardFast, faBackwardFast, faSquareCheck,
faSquare, faSquareMinus, faSliders, faRepeat, faBorderAll, faBell,
faCircleQuestion, faLightbulb, faBomb,
faCircleQuestion, faLightbulb, faBomb, faAngleDown, faAngleUp,
);
ReactDOM.render(
+17 -11
View File
@@ -69,7 +69,7 @@ type FlowContext struct {
error_message string // If an error occurs trap the error message
last_stats_timestamp uint64
frequency_msec uint64
frequency_ns uint64
// We ensure to only send the final flow complete message
// once. This will trigger a System.Flow.Completion event on the
@@ -93,26 +93,32 @@ func newFlowContext(ctx context.Context,
// How often do we send a FlowStat message to sync the server's
// flow progress stat.
frequency_msec := uint64(5000)
frequency := 5 * time.Second
if config_obj != nil &&
config_obj.Client != nil &&
config_obj.Client.DefaultServerFlowStatsUpdate > 0 {
frequency_msec = config_obj.Client.DefaultServerFlowStatsUpdate
frequency = time.Second * time.Duration(
config_obj.Client.DefaultServerFlowStatsUpdate)
}
if req.FlowRequest.FlowUpdateTime > 0 {
frequency_msec = req.FlowRequest.FlowUpdateTime
frequency = time.Second * time.Duration(
req.FlowRequest.FlowUpdateTime)
}
// Default is set by config file
batch_delay := uint64(5000)
batch_delay := 5 * time.Second
if config_obj != nil &&
config_obj.Frontend != nil &&
config_obj.Frontend.Resources != nil &&
config_obj.Frontend.Resources.DefaultLogBatchTime > 0 {
batch_delay = config_obj.Frontend.Resources.DefaultLogBatchTime
batch_delay = time.Millisecond *
time.Duration(config_obj.Frontend.Resources.DefaultLogBatchTime)
}
if req.FlowRequest.LogBatchTime > 0 {
batch_delay = req.FlowRequest.LogBatchTime
batch_delay = time.Millisecond *
time.Duration(req.FlowRequest.LogBatchTime)
}
// Allow the flow to be cancelled.
@@ -123,7 +129,7 @@ func newFlowContext(ctx context.Context,
wg: &sync.WaitGroup{},
output: output,
req: req.FlowRequest,
frequency_msec: frequency_msec,
frequency_ns: uint64(frequency.Nanoseconds()),
config_obj: config_obj,
flow_id: flow_id,
owner: owner,
@@ -140,7 +146,7 @@ func newFlowContext(ctx context.Context,
case <-sub_ctx.Done():
return
case <-time.After(time.Duration(batch_delay) * time.Millisecond):
case <-time.After(batch_delay):
stats := self.MaybeSendStats()
if stats != nil {
select {
@@ -436,10 +442,10 @@ func (self *FlowContext) MaybeSendStats() *crypto_proto.VeloMessage {
return nil
}
now := uint64(utils.GetTime().Now().UnixNano() / 1000)
now := uint64(utils.GetTime().Now().UnixNano())
last_timestamp := self.last_stats_timestamp
if self.isFlowComplete() ||
now-last_timestamp > self.frequency_msec {
now-last_timestamp > self.frequency_ns {
self.last_stats_timestamp = now
return self.getStats()
}
+4
View File
@@ -125,6 +125,10 @@ func (self *ResultSetWriterImpl) WriteJSONL(serialized []byte, total_rows uint64
total_rows = countLines(serialized)
}
if total_rows > constants.MAX_ROW_LIMIT {
return utils.MemoryError
}
if len(serialized) == 0 {
return nil
}
+23 -10
View File
@@ -118,7 +118,9 @@ package launcher
import (
"context"
"fmt"
"strings"
"sync"
"time"
"github.com/go-errors/errors"
"google.golang.org/protobuf/proto"
@@ -200,9 +202,10 @@ func (self *Launcher) CompileCollectorArgs(
// Batching control
var local_cpu_limit float32
var max_batch_wait, max_batch_rows uint64
var max_batch_wait uint64 // seconds
var max_batch_rows uint64
var max_batch_row_buffer uint64
var local_timeout uint64
var local_timeout uint64 // seconds
if config_obj != nil && config_obj.Defaults != nil {
max_batch_rows = config_obj.Defaults.MaxRows
@@ -211,7 +214,8 @@ func (self *Launcher) CompileCollectorArgs(
}
if collector_request.AllowCustomOverrides {
artifact, _ = repository.Get(ctx, config_obj, "Custom."+spec.Artifact)
artifact, _ = repository.Get(
ctx, config_obj, "Custom."+spec.Artifact)
}
if artifact == nil {
@@ -666,7 +670,12 @@ func (self *Launcher) WriteArtifactCollectionRecord(
// The session_id we send to the client.
var client_session_id string
if session_id == "" {
// If the session_id starts with a / it is a relative child flow.
if strings.HasPrefix(session_id, "/S") {
session_id = utils.NewFlowId(client_id)
client_session_id = session_id + "/S"
} else if session_id == "" {
session_id = utils.NewFlowId(client_id)
client_session_id = session_id
@@ -686,13 +695,15 @@ func (self *Launcher) WriteArtifactCollectionRecord(
}
// How long to batch log messages for on the client.
batch_delay := uint64(2000)
batch_delay := time.Second * 2
if collector_request.LogBatchTime > 0 {
batch_delay = collector_request.LogBatchTime
batch_delay = time.Second * time.Duration(
collector_request.LogBatchTime)
} else if config_obj.Frontend != nil &&
config_obj.Frontend.Resources != nil &&
config_obj.Frontend.Resources.DefaultLogBatchTime > 0 {
batch_delay = config_obj.Frontend.Resources.DefaultLogBatchTime
batch_delay = time.Second * time.Duration(
config_obj.Frontend.Resources.DefaultLogBatchTime)
}
// Compile all the requests into specific tasks to be sent to the
@@ -701,7 +712,7 @@ func (self *Launcher) WriteArtifactCollectionRecord(
SessionId: client_session_id,
RequestId: constants.ProcessVQLResponses,
FlowRequest: &crypto_proto.FlowRequest{
LogBatchTime: batch_delay,
LogBatchTime: uint64(batch_delay.Seconds()),
MaxRows: collector_request.MaxRows,
MaxLogs: collector_request.MaxLogs,
MaxUploadBytes: collector_request.MaxUploadBytes,
@@ -751,8 +762,10 @@ func (self *Launcher) WriteArtifactCollectionRecord(
}
if existing_flow != nil {
collection_context.PreviousFlows = append(
collection_context.PreviousFlows, existing_flow.Context)
previous_flows := existing_flow.Context.PreviousFlows
existing_flow.Context.PreviousFlows = nil
previous_flows = append(previous_flows, existing_flow.Context)
collection_context.PreviousFlows = previous_flows
}
// Record the tasks for provenance of what we actually did.
+19
View File
@@ -0,0 +1,19 @@
package utils
import (
"context"
"www.velocidex.com/golang/vfilter"
)
func SendToOutput(
ctx context.Context,
output_chan chan vfilter.Row,
event interface{}) bool {
select {
case <-ctx.Done():
return false
case output_chan <- vfilter.Row(event):
return true
}
}
+57 -18
View File
@@ -27,7 +27,7 @@ type EncodeFunction struct{}
func (self *EncodeFunction) Call(ctx context.Context,
scope vfilter.Scope,
args *ordereddict.Dict) vfilter.Any {
defer vql_subsystem.RegisterMonitor(ctx, "encode", args)()
defer vql_subsystem.RegisterMonitor(ctx, "serialize", args)()
arg := &EncodeFunctionArgs{}
err := arg_parser.ExtractArgsWithContext(ctx, scope, args, arg)
@@ -36,10 +36,17 @@ func (self *EncodeFunction) Call(ctx context.Context,
return vfilter.Null{}
}
result := arg.Item
switch t := result.(type) {
return _encode("serialize", ctx, scope, arg.Item, arg.Format)
}
func _encode(
name string,
ctx context.Context, scope vfilter.Scope,
item vfilter.Any, format string) vfilter.Any {
switch t := item.(type) {
case types.LazyExpr:
result = t.Reduce(ctx)
item = t.Reduce(ctx)
case types.StoredQuery:
result_rows := []vfilter.Row{}
@@ -47,53 +54,54 @@ func (self *EncodeFunction) Call(ctx context.Context,
result_rows = append(result_rows, row)
}
result = result_rows
item = result_rows
}
switch arg.Format {
switch format {
case "", "json":
opts := vql_subsystem.EncOptsFromScope(scope)
serialized_content, err := json.MarshalIndentWithOptions(result, opts)
serialized_content, err := json.MarshalIndentWithOptions(item, opts)
if err != nil {
scope.Log("serialize: %v", err)
scope.Log("%s: %v", name, err)
return vfilter.Null{}
}
return string(serialized_content)
case "yaml":
serialized, err := yaml.Marshal(result)
serialized, err := yaml.Marshal(item)
if err != nil {
scope.Log("serialize: %v", err)
scope.Log("%v: %v", name, err)
return vfilter.Null{}
}
return string(serialized)
case "hex":
switch t := result.(type) {
switch t := item.(type) {
case []byte:
return hex.EncodeToString(t)
case string:
return hex.EncodeToString([]byte(t))
default:
scope.Log("serialize: Unsupported type for hex encoding %T", result)
scope.Log("%s: Unsupported type for hex encoding %T", name, item)
return vfilter.Null{}
}
case "base64":
switch t := result.(type) {
switch t := item.(type) {
case []byte:
return base64.RawStdEncoding.EncodeToString(t)
case string:
return base64.RawStdEncoding.EncodeToString([]byte(t))
default:
scope.Log("serialize: Unsupported type for base64 encoding %T", result)
scope.Log("%v: Unsupported type for base64 encoding %T",
name, item)
return vfilter.Null{}
}
case "csv":
// Not actually a slice.
if reflect.TypeOf(result).Kind() != reflect.Slice {
if reflect.TypeOf(item).Kind() != reflect.Slice {
return vfilter.Null{}
}
@@ -105,7 +113,7 @@ func (self *EncodeFunction) Call(ctx context.Context,
true, /* write_headers */
json.DefaultEncOpts())
result_rows_value := reflect.ValueOf(result)
result_rows_value := reflect.ValueOf(item)
for i := 0; i < result_rows_value.Len(); i++ {
csv_writer.Write(result_rows_value.Index(i).Interface())
}
@@ -114,7 +122,7 @@ func (self *EncodeFunction) Call(ctx context.Context,
return buff.String()
default:
scope.Log("serialize: Unknown format %s", arg.Format)
scope.Log("%s: Unknown format %s", name, format)
}
return vfilter.Null{}
}
@@ -122,11 +130,42 @@ func (self *EncodeFunction) Call(ctx context.Context,
func (self EncodeFunction) Info(scope vfilter.Scope, type_map *vfilter.TypeMap) *vfilter.FunctionInfo {
return &vfilter.FunctionInfo{
Name: "serialize",
Doc: "Encode an object as a string (csv or json).",
Doc: "Encode an object as a string (json, yaml, hex, base64, csv). This function is an alias to `encode`",
ArgType: type_map.AddType(scope, &EncodeFunctionArgs{}),
}
}
type _EncodeOverrideArgs struct {
String types.Any `vfilter:"required,field=string,doc=The item to encode"`
Type string `vfilter:"required,field=type,doc=Encoding format (csv,json,yaml,hex,base64)"`
}
type EncodeOverride struct{}
func (self EncodeOverride) Call(
ctx context.Context,
scope types.Scope,
args *ordereddict.Dict) types.Any {
arg := &_EncodeOverrideArgs{}
err := arg_parser.ExtractArgsWithContext(ctx, scope, args, arg)
if err != nil {
scope.Log("encode: %s", err.Error())
return types.Null{}
}
return _encode("encode", ctx, scope, arg.String, arg.Type)
}
func (self EncodeOverride) Info(scope vfilter.Scope, type_map *vfilter.TypeMap) *vfilter.FunctionInfo {
return &vfilter.FunctionInfo{
Name: "encode",
Doc: "Encode an object as a string (json, yaml, hex, base64, csv).",
ArgType: type_map.AddType(scope, &_EncodeOverrideArgs{}),
Version: 3,
}
}
func init() {
vql_subsystem.RegisterFunction(&EncodeFunction{})
vql_subsystem.OverrideFunction(&EncodeOverride{})
}
+19 -2
View File
@@ -272,19 +272,36 @@ func (self _ParseFloat) Info(scope vfilter.Scope, type_map *vfilter.TypeMap) *vf
}
}
type _NowArgs struct {
NS bool `vfilter:"optional,field=ns,doc=Return the time in ns"`
}
type _Now struct{}
func (self _Now) Call(
ctx context.Context,
scope vfilter.Scope,
args *ordereddict.Dict) vfilter.Any {
arg := &_NowArgs{}
err := arg_parser.ExtractArgsWithContext(ctx, scope, args, arg)
if err != nil {
scope.Log("now: %s", err.Error())
return vfilter.Null{}
}
if arg.NS {
return time.Now().UnixNano()
}
return time.Now().Unix()
}
func (self _Now) Info(scope vfilter.Scope, type_map *vfilter.TypeMap) *vfilter.FunctionInfo {
return &vfilter.FunctionInfo{
Name: "now",
Doc: "Returns current time in seconds since epoch.",
Name: "now",
Doc: "Returns current time in seconds since epoch.",
ArgType: type_map.AddType(scope, &_NowArgs{}),
Version: 2,
}
}
+5 -1
View File
@@ -115,7 +115,11 @@ func (self CreatePackagePlugin) Call(ctx context.Context,
}
if arg.Target == "" {
arg.Target = "VelociraptorLinux"
if arg.Server {
arg.Target = "VelociraptorLinuxSumo"
} else {
arg.Target = "VelociraptorLinux"
}
}
config_obj, ok := vql_subsystem.GetServerConfig(scope)
@@ -15,7 +15,7 @@ GNU Affero General Public License for more details.
You should have received a copy of the GNU Affero General Public License
along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package common
package shell
import (
"context"
@@ -51,17 +51,18 @@ type ShellPluginArgs struct {
}
type ShellResult struct {
Stdin string
Stdout string
Stderr string
ReturnCode int64
Complete bool
}
type ShellPlugin struct {
type ExecvePlugin struct {
pipeReader pipeReaderFunc
}
func (self *ShellPlugin) maybeForceSecrets(
func maybeForceSecrets(
ctx context.Context, scope vfilter.Scope,
arg *ShellPluginArgs) error {
@@ -87,7 +88,7 @@ func (self *ShellPlugin) maybeForceSecrets(
return utils.SecretsEnforced
}
func (self ShellPlugin) Call(
func (self ExecvePlugin) Call(
ctx context.Context,
scope vfilter.Scope,
args *ordereddict.Dict) <-chan vfilter.Row {
@@ -117,7 +118,7 @@ func (self ShellPlugin) Call(
return
}
err = self.maybeForceSecrets(ctx, scope, arg)
err = maybeForceSecrets(ctx, scope, arg)
if err != nil {
scope.Error("execve: %v", err)
return
@@ -125,7 +126,7 @@ func (self ShellPlugin) Call(
original_args := append([]string{}, arg.Argv...)
if arg.Secret != "" {
arg, err = self.mergeSecretToRequest(ctx, scope, arg, arg.Secret)
arg, err = mergeSecretToRequest(ctx, scope, arg, arg.Secret)
if err != nil {
scope.Log("ERROR:execve: %v", err)
return
@@ -323,7 +324,7 @@ func (self ShellPlugin) Call(
return output_chan
}
func (self ShellPlugin) mergeSecretToRequest(
func mergeSecretToRequest(
ctx context.Context, scope vfilter.Scope,
arg *ShellPluginArgs, secret_name string) (*ShellPluginArgs, error) {
@@ -386,7 +387,7 @@ func (self ShellPlugin) mergeSecretToRequest(
return new_arg, nil
}
func (self ShellPlugin) Info(scope vfilter.Scope, type_map *vfilter.TypeMap) *vfilter.PluginInfo {
func (self ExecvePlugin) Info(scope vfilter.Scope, type_map *vfilter.TypeMap) *vfilter.PluginInfo {
return &vfilter.PluginInfo{
Name: "execve",
Doc: "Execute the commands given by argv.",
@@ -502,7 +503,7 @@ func defaultPipeReader(
}
func init() {
vql_subsystem.RegisterPlugin(&ShellPlugin{
vql_subsystem.RegisterPlugin(&ExecvePlugin{
pipeReader: defaultPipeReader,
})
}
@@ -1,4 +1,4 @@
package common
package shell
import (
"bytes"
+65
View File
@@ -0,0 +1,65 @@
package shell
import (
"context"
"io"
"sync"
"time"
"www.velocidex.com/golang/velociraptor/utils"
)
const (
BUFFER_SIZE = 1024 * 1024
)
func pumpToPipeFromInput(
ctx context.Context,
wg *sync.WaitGroup,
input chan string,
cb func(message string)) {
defer wg.Done()
for {
select {
case <-ctx.Done():
return
case in, ok := <-input:
if !ok {
return
}
cb(in)
}
}
}
func pumpFromPipeToOutput(
ctx context.Context,
wg *sync.WaitGroup,
pipe io.Reader,
cb func(message []byte)) {
defer wg.Done()
buff := make([]byte, BUFFER_SIZE)
for !utils.IsCtxDone(ctx) {
n, err := pipe.Read(buff)
if err != nil { // EOF on pipe close.
if n > 0 {
cb(buff[:n])
}
return
}
// If the buffer is not full, wait for it be filled. Sleep a
// small amount to allow the pipe to fill up so we dont end up
// making lots of small reads and generating many small rows.
if n < BUFFER_SIZE {
time.Sleep(300 * time.Millisecond)
}
cb(buff[:n])
}
}
+69
View File
@@ -0,0 +1,69 @@
package shell
import (
"context"
vfilter "www.velocidex.com/golang/vfilter"
"www.velocidex.com/golang/vfilter/types"
)
func (self *ShellSession) IsRunning() bool {
self.mu.Lock()
defer self.mu.Unlock()
return self._IsRunning
}
func (self *ShellSession) Query() types.StoredQuery {
return &SessionQuery{
session: self,
}
}
// Set the session into the running state and return the previous
// state.
func (self *ShellSession) startRunning() bool {
self.mu.Lock()
defer self.mu.Unlock()
if self._IsRunning {
return true
}
self._IsRunning = true
return false
}
type SessionQuery struct {
session *ShellSession
}
// The session is a query and can be SELECTed from. Only a single
// query can select from the session and this query controls the
// lifetime of the session - When that query exists the subprocess is
// terminated and the session is unmounted.
func (self *SessionQuery) Eval(
ctx context.Context, scope vfilter.Scope) <-chan vfilter.Row {
output_chan := make(chan types.Row)
// Only one runner is allowed
is_running := self.session.startRunning()
if is_running {
scope.Log("shell_session: Session %v is already running",
self.session.Name)
close(output_chan)
return output_chan
}
return self.session.output
}
func (self *ShellSession) Close() {
self.owner.Remove(self.Name)
self.stdin.Close()
self.cancel()
self.wg.Wait()
self.command.Wait()
close(self.output)
}
+75
View File
@@ -0,0 +1,75 @@
package shell
import (
"context"
"github.com/Velocidex/ordereddict"
"www.velocidex.com/golang/velociraptor/acls"
"www.velocidex.com/golang/velociraptor/artifacts"
vql_subsystem "www.velocidex.com/golang/velociraptor/vql"
vfilter "www.velocidex.com/golang/vfilter"
"www.velocidex.com/golang/vfilter/arg_parser"
)
type ShellSessionControlArgs struct {
Name string `vfilter:"optional,field=name,doc=The name of the shell session."`
Stdin string `vfilter:"optional,field=stdin,doc=Write this string to the session stdin."`
StdinClose bool `vfilter:"optional,field=close_stdin,doc=If specified we close the stdin of the session. This will usually terminate the session gracefully."`
Kill bool `vfilter:"optional,field=kill,doc=If specified we kill the session."`
}
type ShellSessionControl struct{}
func (self *ShellSessionControl) Call(ctx context.Context,
scope vfilter.Scope,
args *ordereddict.Dict) vfilter.Any {
err := vql_subsystem.CheckAccess(scope, acls.EXECVE)
if err != nil {
scope.Log("shell_session_control: %v", err)
return vfilter.Null{}
}
// Check the config if we are allowed to execve at all.
config_obj, ok := artifacts.GetConfig(scope)
if ok && config_obj.PreventExecve {
scope.Log("shell_session_control: Not allowed to execve by configuration.")
return vfilter.Null{}
}
arg := &ShellSessionControlArgs{}
err = arg_parser.ExtractArgsWithContext(ctx, scope, args, arg)
if err != nil {
scope.Error("shell_session_control: %v", err)
return vfilter.Null{}
}
session, pres := shellManager.GetByName(arg.Name)
if !pres {
scope.Error("shell_session_control: session %v not known", arg.Name)
return vfilter.Null{}
}
if arg.Stdin != "" {
session.WriteStdin(ctx, arg.Stdin)
}
if arg.StdinClose {
session.CloseStdin()
}
return session
}
func (self ShellSessionControl) Info(scope vfilter.Scope, type_map *vfilter.TypeMap) *vfilter.FunctionInfo {
return &vfilter.FunctionInfo{
Name: "shell_session_control",
Doc: "Control a previously created shell session.",
ArgType: type_map.AddType(scope, &ShellSessionControlArgs{}),
Metadata: vql_subsystem.VQLMetadata().Permissions(acls.EXECVE).Build(),
Version: 1,
}
}
func init() {
vql_subsystem.RegisterFunction(&ShellSessionControl{})
}
+15
View File
@@ -0,0 +1,15 @@
//go:build !windows
// +build !windows
package shell
import (
"os/exec"
"syscall"
)
// For posix systems we want the child to run in a different process
// group so it can not capture our signals.
func UpdateCommandForOS(command *exec.Cmd) {
command.SysProcAttr = &syscall.SysProcAttr{Setpgid: true}
}
+295
View File
@@ -0,0 +1,295 @@
package shell
import (
"context"
"errors"
"fmt"
"io"
"os/exec"
"sync"
"time"
"github.com/Velocidex/ordereddict"
"www.velocidex.com/golang/velociraptor/acls"
"www.velocidex.com/golang/velociraptor/artifacts"
vql_subsystem "www.velocidex.com/golang/velociraptor/vql"
vfilter "www.velocidex.com/golang/vfilter"
"www.velocidex.com/golang/vfilter/arg_parser"
"www.velocidex.com/golang/vfilter/types"
)
var (
shellManager = &ShellManager{
sessions: map[string]*ShellSession{},
}
)
type ShellManager struct {
mu sync.Mutex
sessions map[string]*ShellSession
}
func (self *ShellManager) Remove(name string) {
self.mu.Lock()
defer self.mu.Unlock()
delete(self.sessions, name)
}
func (self *ShellManager) GetByName(name string) (*ShellSession, bool) {
self.mu.Lock()
defer self.mu.Unlock()
res, pres := self.sessions[name]
return res, pres
}
func (self *ShellManager) Get(
ctx context.Context, scope vfilter.Scope,
args *StartShellFunctionArgs) (*ShellSession, error) {
self.mu.Lock()
defer self.mu.Unlock()
res, pres := self.sessions[args.Name]
if pres {
return res, nil
}
res, err := self.newShellSession(ctx, scope, args)
if err != nil {
return nil, err
}
self.sessions[args.Name] = res
return res, err
}
func (self *ShellManager) mergeSecretToRequest(
ctx context.Context, scope vfilter.Scope,
arg *StartShellFunctionArgs, secret_name string) (*StartShellFunctionArgs, error) {
execve_args := &ShellPluginArgs{
Argv: arg.Argv,
Env: arg.Env,
Cwd: arg.Cwd,
Secret: arg.Secret}
res, err := mergeSecretToRequest(ctx, scope, execve_args, arg.Secret)
if err != nil {
return nil, err
}
return &StartShellFunctionArgs{
Argv: res.Argv,
Env: res.Env,
Cwd: res.Cwd,
}, nil
}
func (self *ShellManager) newShellSession(
ctx context.Context, scope vfilter.Scope,
arg *StartShellFunctionArgs) (res *ShellSession, err error) {
if len(arg.Argv) == 0 {
return nil, errors.New("Argv not provided")
}
original_args := append([]string{}, arg.Argv...)
if arg.Secret != "" {
arg, err = self.mergeSecretToRequest(
ctx, scope, arg, arg.Secret)
if err != nil {
return nil, err
}
}
output_chan := make(chan types.Row)
wg := &sync.WaitGroup{}
sub_ctx, cancel := context.WithCancel(ctx)
err = scope.AddDestructor(cancel)
if err != nil {
cancel()
return
}
scope.Log("shell_session: Running external command %v", original_args)
command := exec.CommandContext(sub_ctx, arg.Argv[0], arg.Argv[1:]...)
if arg.Env != nil {
for _, i := range arg.Env.Items() {
command.Env = append(command.Env,
fmt.Sprintf("%s=%s", i.Key, i.Value))
}
}
command.Dir = arg.Cwd
command.WaitDelay = time.Second
command.Cancel = func() error {
cancel()
return nil
}
UpdateCommandForOS(command)
stdin_pipe, err := command.StdinPipe()
if err != nil {
return nil, err
}
stdout_pipe, err := command.StdoutPipe()
if err != nil {
return nil, err
}
stderr_pipe, err := command.StderrPipe()
if err != nil {
return nil, err
}
wg.Add(3)
go pumpFromPipeToOutput(sub_ctx, wg, stdout_pipe, func(data []byte) {
select {
case <-sub_ctx.Done():
return
case output_chan <- &ShellResult{
Stdout: string(data),
}:
}
})
go pumpFromPipeToOutput(sub_ctx, wg, stderr_pipe, func(data []byte) {
select {
case <-sub_ctx.Done():
return
case output_chan <- &ShellResult{
Stderr: string(data),
}:
}
})
// Also report any stdin messages to the output channel. This
// ensures they are delivered in order.
input_chan := make(chan string, 10)
go pumpToPipeFromInput(sub_ctx, wg, input_chan, func(data string) {
select {
case <-sub_ctx.Done():
return
case output_chan <- &ShellResult{
Stdin: data,
}:
stdin_pipe.Write([]byte(data))
}
})
err = command.Start()
if err != nil {
return nil, err
}
res = &ShellSession{
Name: arg.Name,
wg: wg,
owner: self,
cancel: cancel,
input: input_chan,
output: output_chan,
command: command,
stdin: stdin_pipe,
stdout: stdout_pipe,
stderr: stderr_pipe,
}
go func() {
wg.Wait()
res.Close()
}()
return res, nil
}
// A shell is a persistent session
type ShellSession struct {
mu sync.Mutex
wg *sync.WaitGroup
cancel func()
_IsRunning bool
Name string
owner *ShellManager
// Shell output will be pushed to this channel. SELECTing from
// ShellSession will read from this channel.
stdin io.WriteCloser
stdout io.ReadCloser
stderr io.ReadCloser
command *exec.Cmd
input chan string
output chan vfilter.Row
}
func (self *ShellSession) WriteStdin(ctx context.Context, data string) {
select {
case <-ctx.Done():
return
case self.input <- data:
}
}
func (self *ShellSession) CloseStdin() {
self.stdin.Close()
}
type StartShellFunctionArgs struct {
Argv []string `vfilter:"required,field=argv,doc=Argv to run the command with."`
Env *ordereddict.Dict `vfilter:"optional,field=env,doc=Environment variables to launch with."`
Cwd string `vfilter:"optional,field=cwd,doc=If specified we change to this working directory first."`
Secret string `vfilter:"optional,field=secret,doc=The name of a secret to use."`
Name string `vfilter:"optional,field=name,doc=The name of the shell session. If the session already exists, we just return a handle to it."`
}
type StartShellFunction struct{}
func (self StartShellFunction) Call(ctx context.Context,
scope vfilter.Scope,
args *ordereddict.Dict) vfilter.Any {
err := vql_subsystem.CheckAccess(scope, acls.EXECVE)
if err != nil {
scope.Log("shell_session: %v", err)
return vfilter.Null{}
}
// Check the config if we are allowed to execve at all.
config_obj, ok := artifacts.GetConfig(scope)
if ok && config_obj.PreventExecve {
scope.Log("shell_session: Not allowed to execve by configuration.")
return vfilter.Null{}
}
arg := &StartShellFunctionArgs{}
err = arg_parser.ExtractArgsWithContext(ctx, scope, args, arg)
if err != nil {
scope.Error("shell_session: %v", err)
return vfilter.Null{}
}
res, err := shellManager.Get(ctx, scope, arg)
if err != nil {
scope.Error("shell_session: %v", err)
return vfilter.Null{}
}
return res
}
func (self StartShellFunction) Info(scope vfilter.Scope, type_map *vfilter.TypeMap) *vfilter.FunctionInfo {
return &vfilter.FunctionInfo{
Name: "shell_session",
Doc: "Recreate or retrieve a shell session handle.",
ArgType: type_map.AddType(scope, &StartShellFunctionArgs{}),
Metadata: vql_subsystem.VQLMetadata().Permissions(acls.EXECVE).Build(),
Version: 1,
}
}
func init() {
vql_subsystem.RegisterFunction(&StartShellFunction{})
}
+11
View File
@@ -0,0 +1,11 @@
//go:build windows
// +build windows
package shell
import (
"os/exec"
)
func UpdateCommandForOS(command *exec.Cmd) {
}
+1
View File
@@ -51,4 +51,5 @@ import (
_ "www.velocidex.com/golang/velociraptor/vql/tools/packaging"
_ "www.velocidex.com/golang/velociraptor/vql/tools/process"
_ "www.velocidex.com/golang/velociraptor/vql/tools/rsyslog"
_ "www.velocidex.com/golang/velociraptor/vql/tools/shell"
)