Files
catalyst/ui/src/views/Playbook.vue
2021-12-13 00:39:15 +01:00

312 lines
8.3 KiB
Vue

<template>
<div v-if="playbook !== undefined" class="fill-height d-flex flex-column pa-8">
<v-alert v-if="readonly" type="info">You do not have write access to playbooks.</v-alert>
<h2 v-if="this.$route.params.id === 'new'">New Playbook</h2>
<h2 v-else>Edit Playbook: {{ playbook.name }}</h2>
<v-alert v-if="formaterrors.length" color="warning">
<div v-for="(formaterror, index) in formaterrors" :key="index">
{{ formaterror.instancePath }}{{ formaterror.instancePath ? ": " : "" }}{{ formaterror.message }}
</div>
</v-alert>
<v-alert v-else-if="error" color="warning">
{{ error }}
</v-alert>
<div v-else class="px-4 overflow-scroll">
<vue-pipeline
v-if="pipelineData"
ref="pipeline"
:x="50"
:y="55"
:data="pipelineData"
:showArrow="true"
:ystep="70"
:xstep="100"
lineStyle="default"
/>
</div>
<v-subheader class="pl-0 py-0" style="height: 20px; font-size: 12px">
Playbook
</v-subheader>
<div class="flex-grow-1 flex-shrink-1 overflow-scroll">
<Editor v-model="playbook.yaml" @input="updatePipeline" lang="yaml" :readonly="readonly"></Editor>
</div>
<v-row v-if="!readonly" class="px-3 my-6 flex-grow-0 flex-shrink-0">
<v-btn v-if="this.$route.params.id === 'new'" color="success" @click="save" outlined>
<v-icon>mdi-plus-thick</v-icon>
Create
</v-btn>
<v-btn v-else color="success" @click="save" outlined>
<v-icon>mdi-content-save</v-icon>
Save
</v-btn>
</v-row>
</div>
</template>
<script lang="ts">
import Vue from "vue";
import {Playbook, PlaybookTemplate, Task, TaskResponse} from "../client";
import { API } from "@/services/api";
import Editor from "../components/Editor.vue";
import {alg, Graph} from "graphlib";
import yaml from 'yaml';
import Ajv from "ajv";
const playbookSchema = {
type: "object",
required: ["name", "tasks"],
properties: {
name: { type: "string" },
tasks: {
type: "object",
additionalProperties: { $ref: "#/definitions/Task" }
}
},
// additionalProperties: false,
$id: "#/definitions/Playbook"
};
const taskSchema = {
type: "object",
required: ["name", "type"],
properties: {
automation: { type: "string" },
join: { type: "boolean" },
msg: { type: "object", additionalProperties: { type: "string" } },
name: { type: "string" },
next: {
type: "object",
additionalProperties: { type: ["string", "null"] }
},
schema: { type: "object" },
type: { type: "string", enum: ["task", "input", "automation"] }
},
// additionalProperties: false,
$id: "#/definitions/Task"
};
interface State {
playbook?: PlaybookTemplate;
g: Record<string, any>;
selected: any;
pipelineData: any;
error: string;
}
interface TaskWithID {
id: string;
task: Task;
}
const inityaml = "name: VirusTotal hash check\n" +
"tasks:\n" +
" input:\n" +
" name: Please enter a word\n" +
" type: input\n" +
" schema:\n" +
" title: Word\n" +
" type: object\n" +
" properties:\n" +
" word:\n" +
" type: string\n" +
" title: Enter a Word\n" +
" default: \"\"\n" +
" next:\n" +
" hash: \"word != ''\"\n" +
"\n" +
" hash:\n" +
" name: Hash the word\n" +
" type: automation\n" +
" automation: hash.sha1\n" +
" msg:\n" +
" payload: \"playbook.tasks['input'].data['word']\"\n" +
" next:\n" +
" end:\n" +
"\n" +
" end:\n" +
" name: Finish the incident\n" +
" type: task\n"
export default Vue.extend({
name: "Playbook",
components: { Editor },
data: (): State => ({
playbook: undefined,
g: {},
selected: undefined,
pipelineData: undefined,
error: "",
}),
watch: {
'$route': function () {
this.loadPlaybook();
}
},
computed: {
formaterrors: function (): Array<any> {
if (!this.playbook) {
return [];
}
try {
let playbook = yaml.parse(this.playbook.yaml);
const ajv = new Ajv({validateFormats: false});
ajv.addSchema(taskSchema, "#/definitions/Task")
const validate = ajv.compile(playbookSchema);
const valid = validate(playbook)
if (!valid && validate.errors) {
return validate.errors;
}
return [];
}
catch (e) {
return [e];
}
},
readonly: function (): boolean {
return !this.hasRole("engineer:playbook:write");
},
},
methods: {
gstatus: function(task: TaskResponse) {
if (task.active) {
return "open"
}
return "inactive"
},
tasks: function(g: any, playbook: Playbook): Array<TaskWithID> {
let taskKeys = alg.topsort(g);
let tasks = [] as Array<TaskWithID>;
for (const tasksKey in taskKeys) {
let taskWithID = {} as TaskWithID;
if (playbook.tasks[taskKeys[tasksKey]] === undefined) {
continue; // TODO
}
taskWithID.task = playbook.tasks[taskKeys[tasksKey]];
taskWithID.id = taskKeys[tasksKey];
tasks.push(taskWithID);
}
return tasks;
},
updatePipeline: function () {
if (this.playbook) {
this.pipeline(this.playbook.yaml);
}
},
pipeline: function(playbookYAML: string) {
try {
let playbook = yaml.parse(playbookYAML);
this.error = "";
let g = new Graph();
for (const stepKey in playbook.tasks) {
g.setNode(stepKey);
}
this.lodash.forEach(playbook.tasks, (task: Task, stepKey: string) => {
if ("next" in task) {
this.lodash.forEach(task.next, (condition, nextKey) => {
g.setEdge(stepKey, nextKey);
});
}
});
let tasks = this.tasks(g, playbook);
let elements = [] as Array<any>;
this.lodash.forEach(tasks, task => {
elements.push({
id: task.id,
name: task.task.name,
next: [],
status: "unknown"
});
});
this.lodash.forEach(tasks, (task: TaskWithID) => {
if ("next" in task.task) {
this.lodash.forEach(task.task.next, (condition, nextKey) => {
let nextID = this.lodash.findIndex(elements, ["id", nextKey]);
let stepID = this.lodash.findIndex(elements, ["id", task.id]);
if (nextID !== -1) {
// TODO: invalid schema
elements[stepID].next.push({index: nextID});
}
});
}
});
this.pipelineData = undefined;
this.$nextTick(() => {
this.pipelineData = this.lodash.values(elements);
})
}
catch (e: unknown) {
console.log(e);
this.error = this.lodash.toString(e);
}
},
save() {
if (this.playbook === undefined) {
return;
}
if (this.$route.params.id == 'new') {
let playbook = this.playbook;
// playbook.id = kebabCase(playbook.name);
API.createPlaybook(playbook).then(() => {
this.$store.dispatch("alertSuccess", { name: "Playbook created" });
});
} else {
API.updatePlaybook(this.$route.params.id, this.playbook).then(() => {
this.$store.dispatch("alertSuccess", { name: "Playbook saved" });
});
}
},
loadPlaybook() {
if (!this.$route.params.id) {
return
}
if (this.$route.params.id == 'new') {
this.playbook = { name: "MyPlaybook", yaml: inityaml }
} else {
API.getPlaybook(this.$route.params.id).then((response) => {
this.playbook = response.data;
});
}
},
hasRole: function (s: string): boolean {
if (this.$store.state.user.roles) {
return this.lodash.includes(this.$store.state.user.roles, s);
}
return false;
}
},
mounted() {
this.loadPlaybook();
},
});
</script>
<style>
.my-code {
background: #2d2d2d;
color: #ccc;
width: inherit;
height: inherit;
font-family: Fira code, Fira Mono, Consolas, Menlo, Courier, monospace;
font-size: 14px;
line-height: 1.5;
padding: 15px;
}
.pipeline-node-label {
fill: #333 !important;
}
</style>