Implement osquery options page (#11)

Created 3 new components: <OsqueryOptionsPage /> , <OsqueryOptionsForm />, and <YamlAce />

The <OsqueryOptionsPage /> component is rendered at the new route /admin/osquery. The user navigates to this route by selecting the "Osquery Options" sub-navigation in the admin dropdown menu.

On the Osquery Options page, the user is presented with a ACE editor filled with the current osquery options. The current osquery options are retrieved from the serve when the page component mounts. These current osquery options are stored in the osquery slice of state.

Clicking "UPDATE OPTIONS" will trigger a form submit and hit the v1/kolide/spec/osquery_options endpoint if the yaml is valid. If the yaml is not valid, an error message is presented to the user with details on what the error is and where it occurred. If the yaml is valid, the osquery options will be updated even if the options haven't change.
This commit is contained in:
noahtalerman
2020-11-04 18:00:51 -08:00
committed by GitHub
parent 222cdc7115
commit 8e37b8938c
25 changed files with 710 additions and 0 deletions
+71
View File
@@ -0,0 +1,71 @@
import React, { Component } from 'react';
import PropTypes from 'prop-types';
import AceEditor from 'react-ace';
import classnames from 'classnames';
import 'ace-builds/src-noconflict/mode-yaml';
const baseClass = 'yaml-ace';
class YamlAce extends Component {
static propTypes = {
error: PropTypes.string,
label: PropTypes.string,
name: PropTypes.string,
onChange: PropTypes.func.isRequired,
value: PropTypes.string,
wrapperClassName: PropTypes.string,
}
renderLabel = () => {
const { error, label } = this.props;
const labelClassName = classnames(
`${baseClass}__label`,
{ [`${baseClass}__label--error`]: error },
);
return (
<p className={labelClassName}>{error || label}</p>
);
}
render() {
const {
label,
name,
onChange,
value,
error,
wrapperClassName,
} = this.props;
const { renderLabel } = this;
const wrapperClass = classnames(wrapperClassName, {
[`${baseClass}__wrapper--error`]: error,
});
return (
<div className={wrapperClass}>
{renderLabel()}
<AceEditor
className={baseClass}
mode="yaml"
theme="kolide"
width="100%"
minLines={2}
maxLines={17}
editorProps={{ $blockScrolling: Infinity }}
value={value}
tabSize={2}
onChange={onChange}
name={name}
label={label}
/>
</div>
);
}
}
export default YamlAce;
+44
View File
@@ -0,0 +1,44 @@
.yaml-ace {
&__label {
font-size: 16px;
font-weight: $bold;
font-style: normal;
font-stretch: normal;
letter-spacing: -0.5px;
color: $text-dark;
display: block;
margin-bottom: 4px;
min-height: 25px;
&--error {
color: $alert;
}
}
&__wrapper {
&--error {
.ace-kolide {
border: 1px solid $alert;
}
}
}
// Added to remove the "popping" effect when the editor first loads.
min-height: 408px;
.ace_gutter-layer {
min-height: 408px;
}
.ace_line {
min-height: 24px;
}
.ace_gutter-cell {
min-height: 24px;
}
.ace_fold-widget {
min-height: 24px;
}
}
+1
View File
@@ -0,0 +1 @@
export default from './YamlAce';
@@ -0,0 +1,66 @@
import React, { Component } from 'react';
import PropTypes from 'prop-types';
import { size } from 'lodash';
import Button from 'components/buttons/Button';
import Form from 'components/forms/Form';
import formFieldInterface from 'interfaces/form_field';
import YamlAce from 'components/YamlAce';
import validateYaml from 'components/forms/validators/validate_yaml';
import constructErrorString from './helpers';
const baseClass = 'osquery-options-form';
const validate = (formData) => {
const errors = {};
const {
error: yamlError,
valid: yamlValid,
} = validateYaml(formData.osquery_options);
if (!yamlValid) {
errors.osquery_options = constructErrorString(yamlError);
}
const valid = !size(errors);
return { valid, errors };
};
class OsqueryOptionsForm extends Component {
static propTypes = {
formData: PropTypes.object, // eslint-disable-line react/forbid-prop-types
handleSubmit: PropTypes.func.isRequired,
fields: PropTypes.shape({
osquery_options: formFieldInterface.isRequired,
}).isRequired,
}
render () {
const { handleSubmit, fields } = this.props;
return (
<form onSubmit={handleSubmit} className={baseClass}>
<p className={`${baseClass}__header`}>The YAML code editor allows control over osquery configuration options.
Options specified in the code editor below will overwrite existing osquery options.
</p>
<YamlAce
{...fields.osquery_options}
error={fields.osquery_options.error}
wrapperClassName={`${baseClass}__text-editor-wrapper`}
label="YAML"
/>
<Button
type="submit"
variant="brand"
>
UPDATE OPTIONS
</Button>
</form>
);
}
}
export default Form(OsqueryOptionsForm, {
fields: ['osquery_options'],
validate,
});
@@ -0,0 +1,32 @@
.osquery-options-form {
display: flex;
flex-direction: column;
align-items: flex-end;
width: 60%;
padding-right: 40px;
&__header {
font-size: 16px;
margin: 15px 0;
display: inline-block;
color: $text-dark;
}
&__button-wrap {
text-align: right;
.query-form__run-query-btn,
.query-form__stop-query-btn {
margin-left: $pad-xsmall;
}
.kolide-timer {
display: block;
}
}
&__text-editor-wrapper {
margin: $base 0;
width: 100%;
}
}
@@ -0,0 +1,5 @@
const constructErrorString = (yamlError) => {
return `${yamlError.name}: ${yamlError.reason} at line ${yamlError.line}`;
};
export default constructErrorString;
@@ -0,0 +1 @@
export default from './OsqueryOptionsForm';
@@ -0,0 +1,31 @@
const yaml = require('js-yaml');
const invalidYamlResponse = (message) => {
return { valid: false, error: message };
};
const validYamlResponse = { valid: true, error: null };
export const validateYaml = (yamlText) => {
if (!yamlText) {
return invalidYamlResponse('YAML text must be present');
}
try {
yaml.safeLoad(yamlText);
return validYamlResponse;
} catch (error) {
if (error instanceof yaml.YAMLException) {
return invalidYamlResponse({
name: 'Syntax Error',
reason: error.reason,
line: error.mark.line,
});
}
return invalidYamlResponse(error.message);
}
};
export default validateYaml;
@@ -0,0 +1,42 @@
import expect from 'expect';
import validateYaml from './index';
// Valid indentations take up two spaces
const malformedYaml = [
'spec:\nconfig:\n options:\n logger_plugin: tls\n pack_delimiter: /\n logger_tls_period: 10\n distributed_plugin: tls\n disable_distributed: false\n logger_tls_endpoint: /api/v1/osquery/log\n distributed_interval: 8\n distributed_tls_max_attempts: 5\n decorators:\n load:\n - SELECT uuid AS host_uuid FROM system_info;\n - SELECT hostname FROM system_info;\n overrides: {}\n',
'spec:\nconfig:\n options:\n logger_plugin: tls\n pack_delimiter /\n logger_tls_period: 10\n distributed_plugin: tls\n disable_distributed: false\n logger_tls_endpoint: /api/v1/osquery/log\n distributed_interval: 8\n distributed_tls_max_attempts: 5\n decorators:\n load:\n - SELECT uuid AS host_uuid FROM system_info;\n - SELECT hostname FROM system_info;\n overrides: {}\n',
];
const validYaml = [
'spec:\n config:\n options:\n logger_plugin: tls\n pack_delimiter: /\n logger_tls_period: 10\n distributed_plugin: tls\n disable_distributed: false\n logger_tls_endpoint: /api/v1/osquery/log\n distributed_interval: 8\n distributed_tls_max_attempts: 5\n decorators:\n load:\n - SELECT uuid AS host_uuid FROM system_info;\n - SELECT hostname FROM system_info;\n overrides: {}\n',
];
describe('validateYaml', () => {
it('rejects malformed yaml', () => {
malformedYaml.forEach((yaml) => {
const { error, valid } = validateYaml(yaml);
expect(valid).toEqual(false);
expect(error.name).toEqual('Syntax Error');
expect(error.reason).toExist();
expect(error.line).toBeGreaterThan(0);
});
});
it('rejects blank entries', () => {
const { error, valid } = validateYaml();
expect(valid).toEqual(false);
expect(error).toEqual('YAML text must be present');
});
it('accepts valid yaml', () => {
validYaml.forEach((yaml) => {
const { error, valid } = validateYaml(yaml);
expect(valid).toEqual(true);
expect(error).toNotExist();
});
});
});
@@ -27,6 +27,16 @@ export default (admin) => {
pathname: PATHS.ADMIN_SETTINGS,
},
},
{
// No such icon now. SiteNavSidePanel does not display
// icons for subItems
icon: 'osquery',
name: 'Osquery Options',
location: {
regex: new RegExp(`^${PATHS.ADMIN_OSQUERY}`),
pathname: PATHS.ADMIN_OSQUERY,
},
},
],
},
];
+1
View File
@@ -4,6 +4,7 @@ export default {
CONFIRM_EMAIL_CHANGE: (token) => {
return `/v1/kolide/email/change/${token}`;
},
OSQUERY_OPTIONS: '/v1/kolide/spec/osquery_options',
ENABLE_USER: (id) => {
return `/v1/kolide/users/${id}/enable`;
},
@@ -0,0 +1,18 @@
import endpoints from 'kolide/endpoints';
const yaml = require('js-yaml');
export default (client) => {
return {
loadAll: () => {
const { OSQUERY_OPTIONS } = endpoints;
return client.authenticatedGet(client._endpoint(OSQUERY_OPTIONS));
},
update: (formData) => {
const { OSQUERY_OPTIONS } = endpoints;
const osqueryOptionsData = yaml.safeLoad(formData.osquery_options);
return client.authenticatedPost(client._endpoint(OSQUERY_OPTIONS), JSON.stringify(osqueryOptionsData));
},
};
};
+2
View File
@@ -2,6 +2,7 @@ import Base from 'kolide/base';
import Request from 'kolide/request';
import accountMethods from 'kolide/entities/account';
import configMethods from 'kolide/entities/config';
import osqueryOptionsMethods from 'kolide/entities/osquery_options';
import hostMethods from 'kolide/entities/hosts';
import inviteMethods from 'kolide/entities/invites';
import labelMethods from 'kolide/entities/labels';
@@ -23,6 +24,7 @@ class Kolide extends Base {
this.account = accountMethods(this);
this.config = configMethods(this);
this.osqueryOptions = osqueryOptionsMethods(this);
this.hosts = hostMethods(this);
this.invites = inviteMethods(this);
this.labels = labelMethods(this);
@@ -0,0 +1,111 @@
import React, { Component } from 'react';
import PropTypes from 'prop-types';
import { connect } from 'react-redux';
import { noop } from 'lodash';
import osqueryOptionsActions from 'redux/nodes/osquery/actions';
import validateYaml from 'components/forms/validators/validate_yaml';
import OsqueryOptionsForm from 'components/forms/admin/OsqueryOptionsForm';
import Icon from 'components/icons/Icon';
import { renderFlash } from 'redux/nodes/notifications/actions';
const yaml = require('js-yaml');
const baseClass = 'osquery-options';
export class OsqueryOptionsPage extends Component {
static propTypes = {
options: PropTypes.object, // eslint-disable-line react/forbid-prop-types
dispatch: PropTypes.func,
};
static defaultProps = {
dispatch: noop,
}
componentDidMount() {
const { dispatch } = this.props;
dispatch(osqueryOptionsActions.getOsqueryOptions())
.catch(() => false);
}
onSaveOsqueryOptionsFormSubmit = (formData) => {
const { dispatch } = this.props;
const { error } = validateYaml(formData.osquery_options);
if (error) {
dispatch(renderFlash('error', error));
return false;
}
dispatch(osqueryOptionsActions.updateOsqueryOptions(formData))
.then(() => {
dispatch(renderFlash('success', 'Osquery options updated!'));
return false;
})
.catch((errors) => {
if (errors.base) {
dispatch(renderFlash('error', errors.base));
}
return false;
});
return false;
}
render () {
const { options } = this.props;
const formData = {
osquery_options: yaml.safeDump(options),
};
const { onSaveOsqueryOptionsFormSubmit } = this;
return (
<div className={`${baseClass} body-wrap`}>
<h1>Osquery Options</h1>
<div className={`${baseClass}__form-wrapper`}>
<OsqueryOptionsForm
formData={formData}
handleSubmit={onSaveOsqueryOptionsFormSubmit}
/>
<div className={`${baseClass}__form-details`}>
<p>This file describes options returned to osqueryd when it checks for configuration.</p>
<p>See Fleet documentation for an example file that includes the overrides option.</p>
<a
href="https://github.com/fleetdm/fleet/blob/master/docs/cli/file-format.md#osquery-configuration-options"
target="_blank"
rel="noreferrer"
className="button button--muted"
>
GO TO FLEET DOCS
<Icon name="right-arrow" />
</a>
<p>See osquery documentation for all available options.</p>
<a
href="https://osquery.readthedocs.io/en/stable/deployment/configuration/#options"
target="_blank"
rel="noreferrer"
className="button button--muted"
>
GO TO OSQUERY DOCS
<Icon name="right-arrow" />
</a>
</div>
</div>
</div>
);
}
}
const mapStateToProps = (state) => {
const { osquery } = state;
const { options } = osquery;
return {
options,
};
};
export default connect(mapStateToProps)(OsqueryOptionsPage);
@@ -0,0 +1,89 @@
import React from 'react';
import expect, { restoreSpies, spyOn } from 'expect';
import { mount } from 'enzyme';
import { connectedComponent, reduxMockStore } from 'test/helpers';
import ConnectedOsqueryOptionsPage, { OsqueryOptionsPage } from 'pages/admin/OsqueryOptionsPage/OsqueryOptionsPage';
import osqueryOptionsActions from 'redux/nodes/osquery/actions';
const currentUser = {
admin: true,
email: 'hi@gnar.dog',
enabled: true,
name: 'Gnar Dog',
position: 'Head of Gnar',
username: 'gnardog',
};
const osqueryOptionsString =
'spec:\n config:\n options:\n logger_plugin: tls\n pack_delimiter: /\n logger_tls_period: 10\n distributed_plugin: tls\n disable_distributed: false\n logger_tls_endpoint: /api/v1/osquery/log\n distributed_interval: 8\n distributed_tls_max_attempts: 5\n decorators:\n load:\n - SELECT uuid AS host_uuid FROM system_info;\n - SELECT hostname FROM system_info;\n overrides: {}\n';
const store = {
app: {
config: {
configured: true,
},
},
auth: {
user: {
...currentUser,
},
},
osquery: {
erros: {},
loading: false,
options: {},
},
entities: {
users: {
loading: false,
data: {
1: {
...currentUser,
},
},
},
},
};
describe('Osquery Options Page - Component', () => {
beforeEach(() => {
spyOn(osqueryOptionsActions, 'getOsqueryOptions')
.andReturn(() => Promise.resolve([]));
spyOn(osqueryOptionsActions, 'updateOsqueryOptions')
.andReturn(() => Promise.resolve([]));
});
afterEach(restoreSpies);
it('renders', () => {
const mockStore = reduxMockStore(store);
const page = mount(connectedComponent(ConnectedOsqueryOptionsPage, { mockStore }));
expect(page.find('OsqueryOptionsPage').length).toEqual(1);
});
it('gets osquery options on mount', () => {
const mockStore = reduxMockStore(store);
mount(connectedComponent(ConnectedOsqueryOptionsPage, { mockStore }));
expect(osqueryOptionsActions.getOsqueryOptions).toHaveBeenCalled();
});
describe('updating osquery options', () => {
const dispatch = () => Promise.resolve();
const props = { dispatch, options: {} };
const pageNode = mount(<OsqueryOptionsPage {...props} />).instance();
const updatedOptions = { osquery_options: osqueryOptionsString };
it('updates the current osquery options with the new osquery options object', () => {
spyOn(osqueryOptionsActions, 'updateOsqueryOptions').andCallThrough();
pageNode.onSaveOsqueryOptionsFormSubmit(updatedOptions);
expect(osqueryOptionsActions.updateOsqueryOptions).toHaveBeenCalledWith(updatedOptions);
});
});
});
@@ -0,0 +1,32 @@
.osquery-options {
padding: 30px;
h1 {
margin: 0 0 22px;
}
a {
margin: 0 0 20px;
}
&__form-wrapper {
display: flex;
}
&__form-details {
margin-top: 133px;
width: 40%;
p {
font-size: 15px;
font-weight: $normal;
line-height: 1.6;
letter-spacing: 0.5px;
color: $text-dark;
}
}
i {
margin-left: 8px;
}
}
@@ -0,0 +1 @@
export default from './OsqueryOptionsPage';
+58
View File
@@ -0,0 +1,58 @@
import Kolide from 'kolide';
const yaml = require('js-yaml');
export const OSQUERY_OPTIONS_FAILURE = 'OSQUERY_OPTIONS_FAILURE';
export const OSQUERY_OPTIONS_START = 'OSQUERY_OPTIONS_START';
export const OSQUERY_OPTIONS_SUCCESS = 'OSQUERY_OPTIONS_SUCCESS';
export const loadOsqueryOptions = { type: OSQUERY_OPTIONS_START };
export const osqueryOptionsSuccess = (data) => {
return { type: OSQUERY_OPTIONS_SUCCESS, payload: { data } };
};
export const osqueryOptionsFailure = (errors) => {
return { type: OSQUERY_OPTIONS_FAILURE, payload: { errors } };
};
export const getOsqueryOptions = () => {
return (dispatch) => {
dispatch(loadOsqueryOptions);
return Kolide.osqueryOptions.loadAll()
.then((osqueryOptions) => {
dispatch(osqueryOptionsSuccess(osqueryOptions));
return osqueryOptions;
})
.catch((errors) => {
dispatch(osqueryOptionsFailure(errors));
throw errors;
});
};
};
export const updateOsqueryOptions = (osqueryOptionsData) => {
return (dispatch) => {
dispatch(loadOsqueryOptions);
return Kolide.osqueryOptions.update(osqueryOptionsData)
.then((osqueryOptions) => {
const yamlOptions = yaml.safeLoad(osqueryOptionsData.osquery_options);
dispatch(osqueryOptionsSuccess(yamlOptions));
return osqueryOptions;
})
.catch((errors) => {
dispatch(osqueryOptionsFailure(errors));
throw errors;
});
};
};
export default {
getOsqueryOptions,
updateOsqueryOptions,
};
+37
View File
@@ -0,0 +1,37 @@
import {
OSQUERY_OPTIONS_FAILURE,
OSQUERY_OPTIONS_START,
OSQUERY_OPTIONS_SUCCESS,
} from './actions';
export const initialState = {
options: {},
errors: {},
loading: false,
};
const reducer = (state = initialState, { type, payload }) => {
switch (type) {
case OSQUERY_OPTIONS_START:
return {
...state,
loading: true,
};
case OSQUERY_OPTIONS_SUCCESS:
return {
...state,
options: payload.data,
loading: false,
};
case OSQUERY_OPTIONS_FAILURE:
return {
...state,
errors: payload.errors,
loading: false,
};
default:
return state;
}
};
export default reducer;
@@ -0,0 +1,47 @@
import expect from 'expect';
import reducer, { initialState } from './reducer';
import {
loadOsqueryOptions,
osqueryOptionsFailure,
osqueryOptionsSuccess,
} from './actions';
describe('Osquery - reducer', () => {
it('sets the initial state', () => {
expect(reducer(undefined, { type: 'SOME_ACTION' })).toEqual(initialState);
});
it('sets the state to loading', () => {
expect(reducer(initialState, loadOsqueryOptions)).toEqual({
...initialState,
loading: true,
});
});
it('sets the osquery options in state', () => {
const osqueryOptions = { spec: {} };
const loadingOsqueryOptionsState = {
...initialState,
loading: true,
};
expect(reducer(loadingOsqueryOptionsState, osqueryOptionsSuccess(osqueryOptions))).toEqual({
loading: false,
errors: {},
options: osqueryOptions,
});
});
it('sets errors in state', () => {
const error = 'Unable to get osquery options';
const loadingOsqueryOptionsState = {
...initialState,
loading: true,
};
expect(reducer(loadingOsqueryOptionsState, osqueryOptionsFailure(error))).toEqual({
loading: false,
errors: error,
options: {},
});
});
});
+2
View File
@@ -8,6 +8,7 @@ import components from './nodes/components/reducer';
import entities from './nodes/entities/reducer';
import errors500 from './nodes/errors500/reducer';
import notifications from './nodes/notifications/reducer';
import osquery from './nodes/osquery/reducer';
import persistentFlash from './nodes/persistent_flash/reducer';
import redirectLocation from './nodes/redirectLocation/reducer';
@@ -19,6 +20,7 @@ export default combineReducers({
errors500,
loadingBar: loadingBarReducer,
notifications,
osquery,
persistentFlash,
redirectLocation,
routing: routerReducer,
+2
View File
@@ -5,6 +5,7 @@ import { syncHistoryWithStore } from 'react-router-redux';
import AdminAppSettingsPage from 'pages/admin/AppSettingsPage';
import AdminUserManagementPage from 'pages/admin/UserManagementPage';
import AdminOsqueryOptionsPage from 'pages/admin/OsqueryOptionsPage';
import AllPacksPage from 'pages/packs/AllPacksPage';
import App from 'components/App';
import AuthenticatedAdminRoutes from 'components/AuthenticatedAdminRoutes';
@@ -50,6 +51,7 @@ const routes = (
<Route path="admin" component={AuthenticatedAdminRoutes}>
<Route path="users" component={AdminUserManagementPage} />
<Route path="settings" component={AdminAppSettingsPage} />
<Route path="osquery" component={AdminOsqueryOptionsPage} />
</Route>
<Route path="hosts">
<Route path="manage" component={ManageHostsPage} />
+1
View File
@@ -3,6 +3,7 @@ import URL_PREFIX from 'router/url_prefix';
export default {
ADMIN_USERS: `${URL_PREFIX}/admin/users`,
ADMIN_SETTINGS: `${URL_PREFIX}/admin/settings`,
ADMIN_OSQUERY: `${URL_PREFIX}/admin/osquery`,
ALL_PACKS: `${URL_PREFIX}/packs/all`,
EDIT_PACK: (pack) => {
return `${URL_PREFIX}/packs/${pack.id}/edit`;
+1
View File
@@ -10,6 +10,7 @@
"test": "make test-js"
},
"dependencies": {
"ace-builds": "1.3.1",
"autoprefixer": "^9.4.3",
"bourbon": "^5.0.0",
"brace": "0.11.1",
+5
View File
@@ -219,6 +219,11 @@ accepts@~1.3.5:
mime-types "~2.1.24"
negotiator "0.6.2"
ace-builds@1.3.1:
version "1.3.1"
resolved "https://registry.yarnpkg.com/ace-builds/-/ace-builds-1.3.1.tgz#c7f9d7a657e7d9c630acd78f1dc2fa1e0e2a84f6"
integrity sha512-MJtPAqeGaiIpfgUCXi3/oowqcIw4wSkKTDGvtfUoQHrfZGfjNnH3frPdHzd1VfKF62JFeNJOl4q0TRDiHwoBFg==
acorn-globals@^3.1.0:
version "3.1.0"
resolved "https://registry.yarnpkg.com/acorn-globals/-/acorn-globals-3.1.0.tgz#fd8270f71fbb4996b004fa880ee5d46573a731bf"