Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
49 changes: 29 additions & 20 deletions package-lock.json

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

3 changes: 2 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -34,7 +34,8 @@
"vue-eslint-parser": "^10.2.0",
"vue-prism-editor": "^2.0.0-alpha.2",
"vue-router": "^4.0.12",
"vue-scrollto": "^2.20.0"
"vue-scrollto": "^2.20.0",
"yaml": "^2.8.3"
},
"devDependencies": {
"@babel/core": "^7.17.12",
Expand Down
137 changes: 137 additions & 0 deletions src/components/abilities/ImportAbilityModal.vue
Original file line number Diff line number Diff line change
@@ -0,0 +1,137 @@
<script setup>
import { ref, inject } from "vue";
import { parseAllDocuments } from "yaml";
import { toast } from "bulma-toast";
import { useAbilityStore } from "../../stores/abilityStore";
import { useCoreDisplayStore } from "../../stores/coreDisplayStore";
import { storeToRefs } from "pinia";

const $api = inject("$api");

const abilityStore = useAbilityStore();
const coreDisplayStore = useCoreDisplayStore();
const { modals } = storeToRefs(coreDisplayStore);

const fileUploadPlaceholder = "No file selected.";
const fileName = ref(fileUploadPlaceholder);
const isFileSelected = ref(false);
const isImporting = ref(false);
const input = ref(null);

function updateFileName($event) {
if ($event.target.files.length > 0) {
fileName.value = $event.target.files[0].name;
isFileSelected.value = true;
} else {
isFileSelected.value = false;
}
}

function readFileText(file) {
return new Promise((resolve, reject) => {
const reader = new FileReader();
reader.onload = (event) => resolve(event.target.result);
reader.onerror = () => reject(new Error("Unable to read ability file."));
reader.readAsText(file);
});
}

function parseAbilityYaml(fileText) {
const documents = parseAllDocuments(fileText);
const errors = documents.flatMap((document) => document.errors);
if (errors.length) {
throw new Error(errors[0].message);
}

const parsedDocuments = documents
.map((document) => document.toJSON())
.filter((document) => document !== null && document !== undefined);
const entries = parsedDocuments.flatMap((document) => Array.isArray(document) ? document : [document]);
const abilities = entries.filter((entry) => entry && typeof entry === "object" && !Array.isArray(entry));
if (!abilities.length || abilities.length !== entries.length) {
throw new Error("Ability YAML must contain an ability object or a list of ability objects.");
}
return abilities;
}

function getErrorMessage(error) {
const responseData = error.response?.data;
if (responseData?.error) return responseData.error;
if (responseData?.details) {
return typeof responseData.details === "string" ? responseData.details : JSON.stringify(responseData.details);
}
return error.message || "Unable to import ability.";
}

function showToast(message, type) {
toast({
message,
position: "bottom-right",
type,
dismissible: true,
pauseOnHover: true,
duration: 3000,
});
}

async function submitFile() {
const file = input.value.files[0];
isImporting.value = true;
try {
const fileText = await readFileText(file);
const abilities = parseAbilityYaml(fileText);
for (const ability of abilities) {
await abilityStore.importAbility($api, ability);
}
showToast(`Imported ${abilities.length} ${abilities.length === 1 ? "ability" : "abilities"}.`, "is-success");
closeModal();
} catch(error) {
console.error("Error importing ability.", error);
showToast(getErrorMessage(error), "is-danger");
} finally {
isImporting.value = false;
}
}

function resetFileInput() {
fileName.value = fileUploadPlaceholder;
isFileSelected.value = false;
if (input.value) {
input.value.value = "";
}
}

function closeModal() {
resetFileInput();
modals.value.abilities.showImport = false;
}
</script>

<template lang="pug">
.modal(:class="{ 'is-active': modals.abilities.showImport }")
.modal-background(@click="closeModal")
.modal-card
header.modal-card-head
p.modal-card-title Import Ability YAML
.modal-card-body
.file.has-name.is-fullwidth
label.file-label
input.file-input(type="file", ref="input", accept=".yml,.yaml", @change="updateFileName")
span.file-cta
span.file-icon
font-awesome-icon(icon="fas fa-upload")
span.file-label Choose a file...
span.file-name {{ fileName }}
footer.modal-card-foot.is-flex.is-justify-content-flex-end
button.button(@click="closeModal") Close
button.button.is-primary(:class="{ 'is-loading': isImporting }" :disabled="!isFileSelected || isImporting", @click="submitFile")
span.icon
font-awesome-icon(icon="fas fa-save")
span Import
</template>

<style scoped>
.modal-card{
width: 70%;
}
</style>
10 changes: 10 additions & 0 deletions src/stores/abilityStore.js
Original file line number Diff line number Diff line change
Expand Up @@ -68,6 +68,16 @@ export const useAbilityStore = defineStore("abilityStore", {
console.error("Error fetching abilities", error);
}
},
async importAbility($api, ability) {
try {
const response = await $api.post("/api/v2/abilities", ability);
await this.getAbilities($api);
return response.data;
} catch(error) {
console.error("Error importing ability.", error);
throw error;
}
},
async getPayloads($api, sort=false, excludePlugins=false, addPath=false) {
try {
const response = await $api.get("/api/v2/payloads", {params: {sort: sort, exclude_plugins: excludePlugins, add_path: addPath}});
Expand Down
3 changes: 3 additions & 0 deletions src/stores/coreDisplayStore.js
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,9 @@ export const useCoreDisplayStore = defineStore("coreDisplayStore", {
payloads: {
showUpload: false,
},
abilities: {
showImport: false,
},
adversaries: {
showFactBreakdown: false,
showImport: false,
Expand Down
8 changes: 8 additions & 0 deletions src/views/AbilitiesView.vue
Original file line number Diff line number Diff line change
Expand Up @@ -2,13 +2,16 @@
import { storeToRefs } from "pinia";
import { reactive, ref, inject, onMounted, computed } from "vue";
import { useRoute } from "vue-router";
import { useCoreDisplayStore } from "@/stores/coreDisplayStore";
import { useAbilityStore } from "@/stores/abilityStore";
import { getAbilityPlatforms } from "@/utils/abilityUtil.js";
import CreateEditAbility from "@/components/abilities/CreateEditAbility.vue";
import ImportAbilityModal from "@/components/abilities/ImportAbilityModal.vue";

const $api = inject("$api");
const route = useRoute();

const coreDisplayStore = useCoreDisplayStore();
const abilityStore = useAbilityStore();
const { abilities, tactics, techniques, plugins, platforms } = storeToRefs(abilityStore);

Expand Down Expand Up @@ -64,6 +67,10 @@ hr
span.icon
font-awesome-icon(icon="fas fa-plus")
span Create an Ability
button.button.is-fullwidth.mb-4(@click="coreDisplayStore.modals.abilities.showImport = true")
span.icon
font-awesome-icon(icon="fas fa-file-import")
span Import
form
.field
.control.has-icons-left
Expand Down Expand Up @@ -113,6 +120,7 @@ hr

//- Modals
CreateEditAbility(:ability="selectedAbility" :active="showAbilityModal" :creating="isCreatingAbility" @close="showAbilityModal = false")
ImportAbilityModal
</template>

<style scoped>
Expand Down
Loading