Skip to content

feat: support dict-style adversary steps with per-step metadata in UI#85

Open
deacon-mp wants to merge 13 commits into
masterfrom
newAdversary
Open

feat: support dict-style adversary steps with per-step metadata in UI#85
deacon-mp wants to merge 13 commits into
masterfrom
newAdversary

Conversation

@deacon-mp
Copy link
Copy Markdown
Contributor

@deacon-mp deacon-mp commented Feb 9, 2026

Summary

  • Updates adversary UI to support the new dict-style step format
  • Each step can have per-platform executor facts metadata
  • Backward compatible with legacy string ability ID format
  • Companion to caldera core PR #3338

Test plan

  • Create adversary with dict-style steps via UI
  • Edit existing adversary — verify metadata preserved
  • Verify legacy string-format adversaries still display correctly

Copy link
Copy Markdown
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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 + added executorUtils to normalize/save executor facts and hydrate adversary steps with stable step_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)"
Copy link

Copilot AI Feb 9, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Suggested change
v-if="abilityDependencies[ability.step_uuid] && getExecutorDetail('parser', ability)"
v-if="abilityDependencies[ability.step_uuid] && getExecutorDetail('requirements', ability)"

Copilot uses AI. Check for mistakes.
Comment thread src/stores/adversaryStore.js.bkp Outdated
Comment on lines +1 to +6
import { defineStore } from "pinia";
import { useAbilityStore as abilityStore } from "./abilityStore";

export const useAdversaryStore = defineStore("adversaryStore", {
state: () => {
return {
Copy link

Copilot AI Feb 9, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copilot uses AI. Check for mistakes.
import { useAdversaryStore } from "@/stores/adversaryStore";
import { useObjectiveStore } from "@/stores/objectiveStore";
import { useCoreDisplayStore } from "@/stores/coreDisplayStore";
import CreateEditAbility from "@/components/abilities/CreateEditAbility.vue";
Copy link

Copilot AI Feb 9, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Suggested change
import CreateEditAbility from "@/components/abilities/CreateEditAbility.vue";

Copilot uses AI. Check for mistakes.
Comment on lines +135 to +146
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;
}
Copy link

Copilot AI Feb 9, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Suggested change
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;

Copilot uses AI. Check for mistakes.
button.delete(@click.stop="deleteAbility(index)")

.container.has-text-centered(
v-if="abilitiesReady && Array.isArray(localAbilities.value) && !localAbilities.value.length"
Copy link

Copilot AI Feb 9, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Suggested change
v-if="abilitiesReady && Array.isArray(localAbilities.value) && !localAbilities.value.length"
v-if="abilitiesReady && Array.isArray(localAbilities) && !localAbilities.length"

Copilot uses AI. Check for mistakes.
Comment on lines +2 to +65
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);
}
}

Copy link

Copilot AI Feb 9, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Suggested change
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>

Copilot uses AI. Check for mistakes.
Comment on lines +277 to +282
// Ensure abilities are available
if (!abilityStoreInstance.abilities.length) {
await abilityStoreInstance.getAbilities($api);

}

Copy link

Copilot AI Feb 9, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Suggested change
// Ensure abilities are available
if (!abilityStoreInstance.abilities.length) {
await abilityStoreInstance.getAbilities($api);
}

Copilot uses AI. Check for mistakes.
Comment thread src/stores/adversaryStore.js Outdated
Comment on lines +209 to +210
// Fallback: use step itself as the ability

Copy link

Copilot AI Feb 9, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Suggested change
// Fallback: use step itself as the ability
// Fallback: skip this step when no template is available
return null;

Copilot uses AI. Check for mistakes.
Comment thread src/stores/adversaryStore.js Outdated
Comment on lines +88 to +122
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)
);
Copy link

Copilot AI Feb 9, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copilot uses AI. Check for mistakes.
*/
function handleClickOutside(event) {
for (const [index, executor] of (abilityToEdit.value.executors || []).entries()) {
const key = getExecutorKey(executor, index);
Copy link

Copilot AI Feb 9, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Superfluous argument passed to function getExecutorKey.

Suggested change
const key = getExecutorKey(executor, index);
const key = getExecutorKey(executor);

Copilot uses AI. Check for mistakes.
deacon-mp and others added 13 commits March 16, 2026 10:55
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.
@deacon-mp deacon-mp changed the title New adversary feat: support dict-style adversary steps with per-step metadata in UI Mar 16, 2026
@deacon-mp deacon-mp requested a review from Copilot March 16, 2026 14:56
Copy link
Copy Markdown
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Copilot encountered an error and was unable to review this pull request. You can try again by re-requesting a review.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants