| 1 | <template> |
| 2 | <div class="simulator-windows-attack-wizard flex min-h-120 flex-col gap-4 overflow-hidden"> |
| 3 | <div> |
| 4 | <n-scrollbar x-scrollable trigger="none"> |
| 5 | <div class="px-7 pt-4 pb-2"> |
| 6 | <n-steps :current size="small" :status="currentStatus"> |
| 7 | <n-step title="Attack" /> |
| 8 | <n-step title="Endpoint agent" /> |
| 9 | <n-step title="Simulate" /> |
| 10 | </n-steps> |
| 11 | </div> |
| 12 | </n-scrollbar> |
| 13 | </div> |
| 14 | |
| 15 | <div class="flex grow flex-col overflow-hidden"> |
| 16 | <Transition :name="`slide-form-${slideFormDirection}`"> |
| 17 | <div v-if="current === 1" class="grow overflow-hidden"> |
| 18 | <n-scrollbar ref="parametersScroll" style="max-height: 350px" trigger="none"> |
| 19 | <ParametersList |
| 20 | v-model:selected="selectedAttack" |
| 21 | :technique-id |
| 22 | class="px-7" |
| 23 | :os-list |
| 24 | :parameters-list |
| 25 | @loaded="parametersList = $event" |
| 26 | /> |
| 27 | </n-scrollbar> |
| 28 | </div> |
| 29 | <div v-else-if="current === 2" class="grow overflow-hidden"> |
| 30 | <n-scrollbar ref="agentsScroll" style="max-height: 350px" trigger="none"> |
| 31 | <AgentsList |
| 32 | v-model:selected="selectedAgent" |
| 33 | class="px-7" |
| 34 | :agents-list |
| 35 | :filter="agentsListFilter" |
| 36 | @loaded="agentsList = $event" |
| 37 | /> |
| 38 | </n-scrollbar> |
| 39 | </div> |
| 40 | <div v-else class="flex flex-col gap-6 px-7"> |
| 41 | <CardEntity size="small" embedded> |
| 42 | <template #header>selected</template> |
| 43 | <template #footer> |
| 44 | <div class="flex flex-col gap-2"> |
| 45 | <CardEntity v-if="selectedAttack" embedded size="small"> |
| 46 | <template #header> |
| 47 | {{ selectedAttack.name }} |
| 48 | </template> |
| 49 | <template #default> |
| 50 | {{ selectedAttack.description }} |
| 51 | </template> |
| 52 | </CardEntity> |
| 53 | <CardEntity v-if="selectedAgent" embedded size="small"> |
| 54 | <template #headerMain> |
| 55 | {{ selectedAgent.hostname }} |
| 56 | </template> |
| 57 | <template #headerExtra> |
| 58 | <code |
| 59 | class="text-primary cursor-pointer" |
| 60 | @click.stop="routeAgent(selectedAgent.agent_id).navigate()" |
| 61 | > |
| 62 | {{ selectedAgent.agent_id }} |
| 63 | <Icon :name="LinkIcon" :size="13" class="relative top-0.5" /> |
| 64 | </code> |
| 65 | </template> |
| 66 | <template #default> |
| 67 | {{ selectedAgent.ip_address }} |
| 68 | <code>{{ selectedAgent.label }}</code> |
| 69 | </template> |
| 70 | <template #footer> |
| 71 | <div class="flex flex-wrap items-center gap-2"> |
| 72 | <Icon :name="iconFromOs(selectedAgent.os)" :size="14" /> |
| 73 | <span> |
| 74 | {{ selectedAgent.os }} |
| 75 | </span> |
| 76 | </div> |
| 77 | </template> |
| 78 | </CardEntity> |
| 79 | </div> |
| 80 | </template> |
| 81 | </CardEntity> |
| 82 | |
| 83 | <div v-if="collectResponse" class="flex flex-col gap-2"> |
| 84 | <div v-if="collectTime" class="text-xs"> |
| 85 | <span class="text-sm">last simulation:</span> |
| 86 | <code>{{ formatDate(collectTime, dFormats.datetimesec) }}</code> |
| 87 | </div> |
| 88 | <CardEntity v-for="report of collectResponse" :key="`${report.GUID}`" size="small"> |
| 89 | <template #default> |
| 90 | <table> |
| 91 | <tbody class="text-xs"> |
| 92 | <tr v-for="(value, key) in report" :key="`${key}`"> |
| 93 | <td class="text-secondary p-1 pr-4 whitespace-nowrap">{{ key }}</td> |
| 94 | <td class="p-1">{{ value }}</td> |
| 95 | </tr> |
| 96 | </tbody> |
| 97 | </table> |
| 98 | </template> |
| 99 | </CardEntity> |
| 100 | </div> |
| 101 | <template v-else> |
| 102 | <div v-if="!loading" class="p-6 text-center"> |
| 103 | Run “ |
| 104 | <strong>Simulate attack</strong> |
| 105 | ” to view the report here. |
| 106 | </div> |
| 107 | <div v-else class="flex flex-col gap-2"> |
| 108 | <n-skeleton height="20px" width="50%" :sharp="false" /> |
| 109 | <n-skeleton height="100px" width="100%" :sharp="false" /> |
| 110 | </div> |
| 111 | </template> |
| 112 | </div> |
| 113 | </Transition> |
| 114 | </div> |
| 115 | |
| 116 | <div class="flex justify-between gap-4 px-7 pb-4"> |
| 117 | <div class="flex items-center gap-4"> |
| 118 | <n-button v-if="isPrevStepEnabled" :disabled="loading" @click="prev()"> |
| 119 | <template #icon> |
| 120 | <Icon :name="ArrowLeftIcon" /> |
| 121 | </template> |
| 122 | Prev |
| 123 | </n-button> |
| 124 | <n-button v-if="isNextStepEnabled" icon-placement="right" :disabled="loading" @click="next()"> |
| 125 | <template #icon> |
| 126 | <Icon :name="ArrowRightIcon" /> |
| 127 | </template> |
| 128 | Next |
| 129 | </n-button> |
| 130 | </div> |
| 131 | <n-button v-if="isSubmitEnabled" type="primary" :disabled="!isSubmitValid" :loading @click="submit()"> |
| 132 | <template #icon> |
| 133 | <Icon :name="AttackIcon" /> |
| 134 | </template> |
| 135 | Simulate attack |
| 136 | </n-button> |
| 137 | </div> |
| 138 | </div> |
| 139 | </template> |
| 140 | |
| 141 | <script setup lang="ts"> |
| 142 | import type { ScrollbarInst, StepsProps } from "naive-ui" |
| 143 | import type { CollectRequest } from "@/api/endpoints/artifacts" |
| 144 | import type { Agent } from "@/types/agents.d" |
| 145 | import type { MatchingParameter } from "@/types/artifacts.d" |
| 146 | import { NButton, NScrollbar, NSkeleton, NStep, NSteps, useMessage } from "naive-ui" |
| 147 | import { computed, onMounted, ref, watch } from "vue" |
| 148 | import Api from "@/api" |
| 149 | import CardEntity from "@/components/common/cards/CardEntity.vue" |
| 150 | import Icon from "@/components/common/Icon.vue" |
| 151 | import { useNavigation } from "@/composables/useNavigation" |
| 152 | import { useSettingsStore } from "@/stores/settings" |
| 153 | import { getOS, iconFromOs } from "@/utils" |
| 154 | import { formatDate } from "@/utils/format" |
| 155 | import AgentsList from "./AgentsList.vue" |
| 156 | import ParametersList from "./ParametersList.vue" |
| 157 | |
| 158 | export interface Report { |
| 159 | "Execution Time (UTC)": Date |
| 160 | "Execution Time (Local)": Date |
| 161 | Technique: string |
| 162 | "Test Number": number |
| 163 | "Test Name": string |
| 164 | Hostname: string |
| 165 | Username: string |
| 166 | GUID: string |
| 167 | } |
| 168 | |
| 169 | const { techniqueId, osList } = defineProps<{ |
| 170 | techniqueId: string |
| 171 | osList: string[] |
| 172 | }>() |
| 173 | |
| 174 | const emit = defineEmits<{ |
| 175 | (e: "update:loading", value: boolean): void |
| 176 | (e: "close"): void |
| 177 | (e: "submitted"): void |
| 178 | (e: "mounted", value: { reset: () => void }): void |
| 179 | }>() |
| 180 | |
| 181 | const ArrowRightIcon = "carbon:arrow-right" |
| 182 | const ArrowLeftIcon = "carbon:arrow-left" |
| 183 | const AttackIcon = "mdi:target" |
| 184 | const LinkIcon = "carbon:launch" |
| 185 | |
| 186 | const { routeAgent } = useNavigation() |
| 187 | const dFormats = useSettingsStore().dateFormat |
| 188 | const message = useMessage() |
| 189 | const current = ref<number>(1) |
| 190 | const currentStatus = ref<StepsProps["status"]>("process") |
| 191 | const slideFormDirection = ref<"right" | "left">("right") |
| 192 | |
| 193 | const parametersList = ref<MatchingParameter[] | null>(null) |
| 194 | const agentsList = ref<Agent[] | null>(null) |
| 195 | const selectedAttack = ref<MatchingParameter | null>(null) |
| 196 | const selectedAgent = ref<Agent | null>(null) |
| 197 | const loading = ref(false) |
| 198 | const collectResponse = ref<Report[] | null>(null) |
| 199 | const collectTime = ref<Date | null>(null) |
| 200 | const parametersScroll = ref<ScrollbarInst | null>(null) |
| 201 | const agentsScroll = ref<ScrollbarInst | null>(null) |
| 202 | |
| 203 | const isNextStepEnabled = computed( |
| 204 | () => (current.value === 1 && selectedAttack.value) || (current.value === 2 && selectedAgent.value) |
| 205 | ) |
| 206 | const isPrevStepEnabled = computed(() => current.value > 1) |
| 207 | const isSubmitEnabled = computed(() => current.value === 3) |
| 208 | const isSubmitValid = computed(() => { |
| 209 | if (!selectedAttack.value) { |
| 210 | return false |
| 211 | } |
| 212 | |
| 213 | if (!selectedAgent.value) { |
| 214 | return false |
| 215 | } |
| 216 | |
| 217 | return true |
| 218 | }) |
| 219 | |
| 220 | function submit() { |
| 221 | if (selectedAttack.value && selectedAgent.value) { |
| 222 | if (getOS(selectedAgent.value.os) === "MacOS") { |
| 223 | return |
| 224 | } |
| 225 | |
| 226 | currentStatus.value = "finish" |
| 227 | loading.value = true |
| 228 | |
| 229 | let artifact_name = "" |
| 230 | |
| 231 | if (getOS(selectedAgent.value.os) === "Linux") { |
| 232 | artifact_name = "Linux.AttackSimulation.AtomicRedTeam" |
| 233 | } |
| 234 | |
| 235 | if (getOS(selectedAgent.value.os) === "Windows") { |
| 236 | artifact_name = "Windows.AttackSimulation.AtomicRedTeam" |
| 237 | } |
| 238 | |
| 239 | const payload: CollectRequest = { |
| 240 | hostname: selectedAgent.value.hostname, |
| 241 | artifact_name, |
| 242 | parameters: { |
| 243 | env: [ |
| 244 | { |
| 245 | key: "InstallART", |
| 246 | value: "N" |
| 247 | }, |
| 248 | { |
| 249 | key: selectedAttack.value.name, |
| 250 | value: "Y" |
| 251 | } |
| 252 | ] |
| 253 | } |
| 254 | } |
| 255 | |
| 256 | Api.artifacts |
| 257 | .collect(payload) |
| 258 | .then(res => { |
| 259 | if (res.data.success) { |
| 260 | emit("submitted") |
| 261 | collectResponse.value = (res.data.results as unknown as Report[]) || [] |
| 262 | collectTime.value = new Date() |
| 263 | message.success(res.data?.message || "Successfully executed query.") |
| 264 | } else { |
| 265 | message.warning(res.data?.message || "An error occurred. Please try again later.") |
| 266 | } |
| 267 | }) |
| 268 | .catch(err => { |
| 269 | // MOCK |
| 270 | /* |
| 271 | const res = { |
| 272 | message: "Successfully executed query", |
| 273 | success: true, |
| 274 | results: [ |
| 275 | { |
| 276 | "Execution Time (UTC)": "2025-05-31T20:43:02Z", |
| 277 | "Execution Time (Local)": "2025-05-31T13:43:02Z", |
| 278 | Technique: |
| 279 | "[T1003.001](https://github.com/redcanaryco/atomic-red-team/blob/master/atomics/T1003.001/T1003.001.md)", |
| 280 | "Test Number": 10, |
| 281 | "Test Name": "Powershell Mimikatz", |
| 282 | Hostname: "WIN-HFOU106TD7K", |
| 283 | Username: "nt authority\\system", |
| 284 | GUID: "66fb0bc1-3c3f-47e9-a298-550ecfefacbc" |
| 285 | } |
| 286 | ] |
| 287 | } |
| 288 | |
| 289 | emit("submitted") |
| 290 | collectResponse.value = (res.results as unknown as Report[]) || [] |
| 291 | collectTime.value = new Date() |
| 292 | message.success(res?.message || "Successfully executed query.") |
| 293 | |
| 294 | throw err |
| 295 | */ |
| 296 | message.error(err.response?.data?.message || "An error occurred. Please try again later.") |
| 297 | }) |
| 298 | .finally(() => { |
| 299 | loading.value = false |
| 300 | }) |
| 301 | } |
| 302 | } |
| 303 | |
| 304 | function reset() { |
| 305 | currentStatus.value = "process" |
| 306 | slideFormDirection.value = "right" |
| 307 | current.value = 1 |
| 308 | |
| 309 | selectedAttack.value = null |
| 310 | selectedAgent.value = null |
| 311 | collectResponse.value = null |
| 312 | collectTime.value = null |
| 313 | } |
| 314 | |
| 315 | function next() { |
| 316 | currentStatus.value = "process" |
| 317 | slideFormDirection.value = "right" |
| 318 | current.value++ |
| 319 | } |
| 320 | |
| 321 | function prev() { |
| 322 | currentStatus.value = "process" |
| 323 | slideFormDirection.value = "left" |
| 324 | current.value-- |
| 325 | } |
| 326 | |
| 327 | function agentsListFilter(agent: Agent) { |
| 328 | for (const os of osList) { |
| 329 | if (getOS(agent.os) === getOS(os)) { |
| 330 | return true |
| 331 | } |
| 332 | } |
| 333 | return false |
| 334 | } |
| 335 | |
| 336 | function scrollInView(scrollContainer: ScrollbarInst) { |
| 337 | // @ts-expect-error $el property not mapped |
| 338 | const wrap = (scrollContainer.$el.nextSibling || scrollContainer.$el.nextElementSibling) as HTMLElement |
| 339 | |
| 340 | const element = wrap.querySelector(".highlighted") as HTMLElement |
| 341 | |
| 342 | if (element) { |
| 343 | const middle = element.offsetTop - wrap.offsetHeight / 2 + element.offsetHeight / 2 |
| 344 | |
| 345 | scrollContainer.scrollTo({ top: middle, behavior: "smooth" }) |
| 346 | } |
| 347 | } |
| 348 | |
| 349 | watch(selectedAttack, val => { |
| 350 | if (val) { |
| 351 | next() |
| 352 | } |
| 353 | }) |
| 354 | |
| 355 | watch(selectedAgent, val => { |
| 356 | if (val) { |
| 357 | next() |
| 358 | } |
| 359 | }) |
| 360 | |
| 361 | watch([current, parametersList, agentsList], () => { |
| 362 | if (current.value === 1 && parametersList.value?.length && selectedAttack.value) { |
| 363 | setTimeout(() => { |
| 364 | if (parametersScroll.value) { |
| 365 | scrollInView(parametersScroll.value) |
| 366 | } |
| 367 | }, 200) |
| 368 | } |
| 369 | if (current.value === 2 && agentsList.value?.length && selectedAgent.value) { |
| 370 | setTimeout(() => { |
| 371 | if (agentsScroll.value) { |
| 372 | scrollInView(agentsScroll.value) |
| 373 | } |
| 374 | }, 200) |
| 375 | } |
| 376 | }) |
| 377 | |
| 378 | onMounted(() => { |
| 379 | emit("mounted", { |
| 380 | reset |
| 381 | }) |
| 382 | }) |
| 383 | </script> |
| 384 | |
| 385 | <style lang="scss" scoped> |
| 386 | .simulator-windows-attack-wizard { |
| 387 | .slide-form-right-enter-active, |
| 388 | .slide-form-right-leave-active, |
| 389 | .slide-form-left-enter-active, |
| 390 | .slide-form-left-leave-active { |
| 391 | transition: all 0.2s ease-out; |
| 392 | position: absolute; |
| 393 | width: 100%; |
| 394 | } |
| 395 | |
| 396 | .slide-form-left-enter-from { |
| 397 | transform: translateX(-100%); |
| 398 | } |
| 399 | |
| 400 | .slide-form-left-leave-to { |
| 401 | transform: translateX(100%); |
| 402 | } |
| 403 | |
| 404 | .slide-form-right-enter-from { |
| 405 | transform: translateX(100%); |
| 406 | } |
| 407 | |
| 408 | .slide-form-right-leave-to { |
| 409 | transform: translateX(-100%); |
| 410 | } |
| 411 | } |
| 412 | </style> |