feat: support dict-style adversary steps with per-step metadata in UI#85
feat: support dict-style adversary steps with per-step metadata in UI#85deacon-mp wants to merge 13 commits into
Conversation
There was a problem hiding this comment.
Pull request overview
This PR appears to introduce a redesigned “Adversary” workflow in the Vue frontend, including importing adversary profiles, editing per-step executor facts, and improving the operations/adversary UI plumbing.
Changes:
- Reworked Adversaries view + DetailsTable to support step-level editing (via modal), drag/drop ordering, fact-source selection, and fact breakdown visualization.
- Expanded
adversaryStore+ addedexecutorUtilsto normalize/save executor facts and hydrate adversary steps with stablestep_uuid. - Added dev-server proxy/env support in Vite and updated ability editor modal API (now emits updates).
Reviewed changes
Copilot reviewed 18 out of 19 changed files in this pull request and generated 20 comments.
Show a summary per file
| File | Description |
|---|---|
| vite.config.js | Adds env-based backend URL and dev-server proxy rules. |
| src/views/AdversariesView.vue | Major UI refactor: selection, import modal wiring, ability-step editing modal, and store interactions. |
| src/views/AbilitiesView.vue | Updates ability modal usage/props; now depends on CreateEditAbility emitting updates. |
| src/utils/executorUtils.js | Adds utilities for rebuilding executors and normalizing executor facts. |
| src/stores/adversaryStore.js | Adds step hydration, executor_facts normalization, and save payload shaping. |
| src/components/operations/CreateModal.vue | Refactors operation creation payload building/validation. |
| src/components/adversaries/ImportModal.vue | Replaces manual YAML parsing with js-yaml, emits imported payload to parent. |
| src/components/adversaries/FactBreakdownModal.vue | Replaces old “tags” view with structured fact usage tables. |
| src/components/adversaries/DetailsTable.vue | Large refactor: local abilities state, fact-source dropdown, warnings, DnD reorder emitting. |
| src/components/adversaries/DeleteAdversaryConfirmationModal.vue | Converts relative imports to @/ alias. |
| src/components/abilities/CreateEditAbility.vue | Major refactor to support step-scoped executor facts editing and contextual edit flags. |
| package.json | Adds uuid dependency. |
| package-lock.json | Lockfile updated (includes many unexpected peer: true fields). |
| *.bkp files | Adds multiple backup copies of source files (likely unintended). |
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
| .container.has-text-centered(v-if="!selectedAdversaryAbilities.length") | ||
| td.has-text-centered(:class="{ 'unlock': onHoverUnlocks.indexOf(ability.step_uuid) > -1 }") | ||
| span( | ||
| v-if="abilityDependencies[ability.step_uuid] && getExecutorDetail('parser', ability)" |
There was a problem hiding this comment.
The "Requires" column is checking getExecutorDetail('parser', ability) but the tooltip says "has requirements". This will show the lock icon based on parser presence instead of requirements presence. Use getExecutorDetail('requirements', ability) for the Requires column and keep 'parser' for the Unlocks column.
| v-if="abilityDependencies[ability.step_uuid] && getExecutorDetail('parser', ability)" | |
| v-if="abilityDependencies[ability.step_uuid] && getExecutorDetail('requirements', ability)" |
| import { defineStore } from "pinia"; | ||
| import { useAbilityStore as abilityStore } from "./abilityStore"; | ||
|
|
||
| export const useAdversaryStore = defineStore("adversaryStore", { | ||
| state: () => { | ||
| return { |
There was a problem hiding this comment.
This PR adds a *.bkp backup store file. Backup copies in source control make it easy to accidentally import the wrong module and complicate maintenance. Remove this file (or add to .gitignore) and keep only src/stores/adversaryStore.js.
| import { useAdversaryStore } from "@/stores/adversaryStore"; | ||
| import { useObjectiveStore } from "@/stores/objectiveStore"; | ||
| import { useCoreDisplayStore } from "@/stores/coreDisplayStore"; | ||
| import CreateEditAbility from "@/components/abilities/CreateEditAbility.vue"; |
There was a problem hiding this comment.
This PR adds a *.bkp backup component. Backup copies in source control are likely accidental and increase maintenance risk. Remove this file (or add to .gitignore) and keep only src/components/adversaries/DetailsTable.vue.
| import CreateEditAbility from "@/components/abilities/CreateEditAbility.vue"; |
| if (code !== 404 && code !== 500) { | ||
| console.error("Unexpected error during PATCH:", error); | ||
| throw error; | ||
| } | ||
| try { | ||
| const response = await $api.post("/api/v2/adversaries", reqBody); | ||
| this.adversaries.push(response.data); | ||
| return response.data; | ||
| } catch (postErr) { | ||
| console.error("POST also failed:", postErr); | ||
| throw postErr; | ||
| } |
There was a problem hiding this comment.
The PATCH failure handler falls back to POST on HTTP 500. Retrying with POST on server errors risks creating duplicate adversaries and hiding real backend issues. Consider falling back only on 404 (not found) or on a clearly identified "create instead" error, and surface 5xx errors to the user.
| if (code !== 404 && code !== 500) { | |
| console.error("Unexpected error during PATCH:", error); | |
| throw error; | |
| } | |
| try { | |
| const response = await $api.post("/api/v2/adversaries", reqBody); | |
| this.adversaries.push(response.data); | |
| return response.data; | |
| } catch (postErr) { | |
| console.error("POST also failed:", postErr); | |
| throw postErr; | |
| } | |
| if (code === 404) { | |
| try { | |
| const response = await $api.post("/api/v2/adversaries", reqBody); | |
| this.adversaries.push(response.data); | |
| return response.data; | |
| } catch (postErr) { | |
| console.error("POST also failed:", postErr); | |
| throw postErr; | |
| } | |
| } | |
| console.error("Error during PATCH (not retrying with POST):", error); | |
| throw error; |
| button.delete(@click.stop="deleteAbility(index)") | ||
|
|
||
| .container.has-text-centered( | ||
| v-if="abilitiesReady && Array.isArray(localAbilities.value) && !localAbilities.value.length" |
There was a problem hiding this comment.
In templates, refs are auto-unwrapped; using localAbilities.value here will be undefined and the empty-state message will never render. Use Array.isArray(localAbilities) / !localAbilities.length instead.
| v-if="abilitiesReady && Array.isArray(localAbilities.value) && !localAbilities.value.length" | |
| v-if="abilitiesReady && Array.isArray(localAbilities) && !localAbilities.length" |
| import { inject, ref, reactive, watch, onMounted } from "vue"; | ||
| import { storeToRefs } from "pinia"; | ||
|
|
||
| import { useAbilityStore } from "@/stores/abilityStore"; | ||
| import CodeEditor from "@/components/core/CodeEditor.vue"; | ||
| import AutoSuggest from "@/components/core/AutoSuggest.vue"; | ||
|
|
||
| const props = defineProps({ | ||
| ability: Object, | ||
| active: Boolean, | ||
| creating: Boolean, | ||
| }); | ||
| const emit = defineEmits(["close"]); | ||
|
|
||
| const $api = inject("$api"); | ||
|
|
||
| const abilityStore = useAbilityStore(); | ||
| const { tactics, techniqueIds, techniqueNames, platforms, payloads } = | ||
| storeToRefs(abilityStore); | ||
|
|
||
| let abilityToEdit = ref({}); | ||
| let validation = reactive({ | ||
| name: "", | ||
| tactic: "", | ||
| techniqueId: "", | ||
| techniqueName: "", | ||
| executors: "", | ||
| }); | ||
|
|
||
| onMounted(async () => { | ||
| await abilityStore.getAbilities($api); | ||
| await abilityStore.getPayloads($api); | ||
| }); | ||
|
|
||
| watch( | ||
| () => props.ability, | ||
| () => { | ||
| setAbilityToEdit(); | ||
| } | ||
| ); | ||
|
|
||
| function setAbilityToEdit() { | ||
| abilityToEdit.value = JSON.parse(JSON.stringify(props.ability)); | ||
| if (!abilityToEdit.value.requirements) { | ||
| abilityToEdit.value.requirements = []; | ||
| } | ||
| } | ||
|
|
||
| function addExecutor() { | ||
| const baseExecutor = { | ||
| cleanup: [], | ||
| timeout: 60, | ||
| platform: "darwin", | ||
| name: platforms.value.darwin[0], | ||
| payloads: [], | ||
| parsers: [], | ||
| }; | ||
| if (!abilityToEdit.value.executors) { | ||
| abilityToEdit.value.executors = [baseExecutor]; | ||
| } else { | ||
| abilityToEdit.value.executors.push(baseExecutor); | ||
| } | ||
| } | ||
|
|
There was a problem hiding this comment.
This PR adds a *.bkp backup component for the ability editor. Backup copies in the repo can easily become stale and are hard to keep consistent. Remove this file (or add to .gitignore) and keep only src/components/abilities/CreateEditAbility.vue.
| import { inject, ref, reactive, watch, onMounted } from "vue"; | |
| import { storeToRefs } from "pinia"; | |
| import { useAbilityStore } from "@/stores/abilityStore"; | |
| import CodeEditor from "@/components/core/CodeEditor.vue"; | |
| import AutoSuggest from "@/components/core/AutoSuggest.vue"; | |
| const props = defineProps({ | |
| ability: Object, | |
| active: Boolean, | |
| creating: Boolean, | |
| }); | |
| const emit = defineEmits(["close"]); | |
| const $api = inject("$api"); | |
| const abilityStore = useAbilityStore(); | |
| const { tactics, techniqueIds, techniqueNames, platforms, payloads } = | |
| storeToRefs(abilityStore); | |
| let abilityToEdit = ref({}); | |
| let validation = reactive({ | |
| name: "", | |
| tactic: "", | |
| techniqueId: "", | |
| techniqueName: "", | |
| executors: "", | |
| }); | |
| onMounted(async () => { | |
| await abilityStore.getAbilities($api); | |
| await abilityStore.getPayloads($api); | |
| }); | |
| watch( | |
| () => props.ability, | |
| () => { | |
| setAbilityToEdit(); | |
| } | |
| ); | |
| function setAbilityToEdit() { | |
| abilityToEdit.value = JSON.parse(JSON.stringify(props.ability)); | |
| if (!abilityToEdit.value.requirements) { | |
| abilityToEdit.value.requirements = []; | |
| } | |
| } | |
| function addExecutor() { | |
| const baseExecutor = { | |
| cleanup: [], | |
| timeout: 60, | |
| platform: "darwin", | |
| name: platforms.value.darwin[0], | |
| payloads: [], | |
| parsers: [], | |
| }; | |
| if (!abilityToEdit.value.executors) { | |
| abilityToEdit.value.executors = [baseExecutor]; | |
| } else { | |
| abilityToEdit.value.executors.push(baseExecutor); | |
| } | |
| } | |
| // Deprecated backup file. | |
| // The canonical implementation lives in: | |
| // src/components/abilities/CreateEditAbility.vue | |
| // | |
| // This file is intentionally left without logic to avoid | |
| // maintaining multiple divergent copies of the component. | |
| </script> |
| // Ensure abilities are available | ||
| if (!abilityStoreInstance.abilities.length) { | ||
| await abilityStoreInstance.getAbilities($api); | ||
|
|
||
| } | ||
|
|
There was a problem hiding this comment.
loadAdversaryFromProfile calls abilityStoreInstance.getAbilities($api) but $api is not in scope for this store action, so this will throw at runtime when abilities aren't preloaded. Pass $api as a parameter to loadAdversaryFromProfile (and update call sites), or remove the fetch from the store and require callers to preload abilities before calling.
| // Ensure abilities are available | |
| if (!abilityStoreInstance.abilities.length) { | |
| await abilityStoreInstance.getAbilities($api); | |
| } |
| // Fallback: use step itself as the ability | ||
|
|
There was a problem hiding this comment.
In generateSelectedAdversaryAbilities, when abilityTemplate is not found the code only logs a warning but continues, which leads to cloneDeep(undefined) and subsequent property access crashes. Implement the documented fallback (use stepObj as the row) or return null early for missing templates so the rest of the mapping doesn't execute.
| // Fallback: use step itself as the ability | |
| // Fallback: skip this step when no template is available | |
| return null; |
| const sanitizedFacts = | ||
| normalizeExecutorFactsForSave(executorFactsFromExecutors); | ||
| console.debug( | ||
| "[FE] sanitizedFacts", | ||
| JSON.stringify(sanitizedFacts, null, 2) | ||
| ); | ||
|
|
||
| return { | ||
| ability_id: step.ability_id, | ||
| metadata: { | ||
| executor_facts: sanitizedFacts, | ||
| }, | ||
| }; | ||
| }); | ||
|
|
||
| const reqBody = { | ||
| adversary_id: this.selectedAdversary.adversary_id, | ||
| name: this.selectedAdversary.name, | ||
| description: this.selectedAdversary.description, | ||
| objective: this.selectedAdversary.objective, | ||
| atomic_ordering: this.selectedAdversaryAbilities.map( | ||
| (ability) => ability.ability_id | ||
| ), | ||
| objective: | ||
| this.selectedAdversary.objective?.id ?? | ||
| this.selectedAdversary.objective ?? | ||
| null, | ||
| atomic_ordering: buildAtomicOrdering(), | ||
| tags: this.selectedAdversary.tags ?? [], | ||
| has_repeatable_abilities: this.selectedAdversary.has_repeatable_abilities ?? false, | ||
| plugin: this.selectedAdversary.plugin ?? '', | ||
| }; | ||
| console.log("[AdversaryStore] Saving adversary with payload:", reqBody); | ||
|
|
||
| try { | ||
| console.debug( | ||
| "[FE] PATCH payload atomic_ordering", | ||
| JSON.stringify(reqBody.atomic_ordering, null, 2) | ||
| ); |
There was a problem hiding this comment.
saveSelectedAdversary logs full adversary payloads and executor facts via console.log/console.debug, which can leak operational data and will spam production consoles. Gate these logs behind a dev flag or remove them once troubleshooting is complete.
| */ | ||
| function handleClickOutside(event) { | ||
| for (const [index, executor] of (abilityToEdit.value.executors || []).entries()) { | ||
| const key = getExecutorKey(executor, index); |
There was a problem hiding this comment.
Superfluous argument passed to function getExecutorKey.
| const key = getExecutorKey(executor, index); | |
| const key = getExecutorKey(executor); |
Removed comments for authentication, core API, and plugins in the proxy configuration.
…ame bug Remove all console.log/debug statements added during development, fix the newAbilityValue -> newAbility variable reference bug in AbilitySelection, remove unused imports (useRouter, useCoreStore, buildExecutorsFromFacts) from AdversariesView, clean up emoji comments, remove duplicate validation check, and fix duplicate @click handlers on dropdown items.
Summary
Test plan