diff --git a/.github/workflows/pre-commit.yaml b/.github/workflows/pre-commit.yaml index 10eb33a1..8b6e2027 100644 --- a/.github/workflows/pre-commit.yaml +++ b/.github/workflows/pre-commit.yaml @@ -25,8 +25,6 @@ concurrency: env: GO_VERSION: "1.26.1" PYTHON_VERSION: "3.14.3" - TASK_X_REMOTE_TASKFILES: "1" - TASK_VERSION: 3.49.1 jobs: pre-commit: @@ -82,11 +80,6 @@ jobs: # Add Go bin directory to PATH echo "$(go env GOPATH)/bin" >> "$GITHUB_PATH" - - name: Setup go-task - run: | - sh -c "$(curl --location https://taskfile.dev/install.sh)" -- -d -b /usr/local/bin v${{ env.TASK_VERSION }} - task --version - - name: Run pre-commit run: | pre-commit run --show-diff-on-failure --color=always --all-files diff --git a/.hooks/gen-arch-diagram.py b/.hooks/gen-arch-diagram.py index 334bcf32..a3c5806b 100755 --- a/.hooks/gen-arch-diagram.py +++ b/.hooks/gen-arch-diagram.py @@ -271,6 +271,13 @@ def render_svg(mmd_path, svg_path): finally: config_path.unlink(missing_ok=True) puppeteer_config_path.unlink(missing_ok=True) + + # Ensure trailing newline so end-of-file-fixer has nothing to fix + svg = Path(svg_path) + content = svg.read_bytes() + if content and not content.endswith(b"\n"): + svg.write_bytes(content + b"\n") + return True diff --git a/README.md b/README.md index 3e9316a0..4a7dfbed 100644 --- a/README.md +++ b/README.md @@ -1,10 +1,9 @@ # DreadGOAD -Ansible collection for deploying and configuring vulnerable Active Directory -lab environments for penetration testing and security research. - -Based on [GOAD (Game of Active Directory)](https://github.com/Orange-Cyberdefense/GOAD) -by Orange Cyberdefense. +Heavily modified fork of [GOAD (Game of Active Directory)](https://github.com/Orange-Cyberdefense/GOAD) +by Orange Cyberdefense. Deploys vulnerable Active Directory lab environments +for penetration testing and security research, with a Go CLI (`dreadgoad`), +Ansible collection, Packer/Vagrant provisioning, and Docker support. --- @@ -218,11 +217,29 @@ The GOAD lab provides: - role: dreadnode.goad.domain_controller ``` -For full orchestration, use the playbooks in the `ansible/playbooks/` directory with -the Taskfile: +For full orchestration, use the `dreadgoad` CLI: ```bash -task provision ENV=dev +# Build the CLI +cd cli && go build -o dreadgoad . + +# Provision the lab +dreadgoad provision --env staging + +# Health check all instances +dreadgoad health-check --env staging + +# Verify domain trusts +dreadgoad verify-trusts --env staging + +# Quick vulnerability validation +dreadgoad validate --quick --env staging + +# Full vulnerability validation +dreadgoad validate --env staging + +# See all commands +dreadgoad --help ``` --- diff --git a/Taskfile.yaml b/Taskfile.yaml deleted file mode 100644 index 3173fbcd..00000000 --- a/Taskfile.yaml +++ /dev/null @@ -1,743 +0,0 @@ ---- -# yaml-language-server: $schema=https://taskfile.dev/schema.json -version: '3' - -includes: - aws: - taskfile: https://raw.githubusercontent.com/CowDogMoo/taskfile-templates/main/aws/Taskfile.yaml - -vars: - ENV: '{{.ENV | default "dev"}}' - # List of all available playbooks in order of execution - GOAD_PLAYBOOKS: 'build.yml ad-servers.yml ad-parent_domain.yml ad-child_domain.yml ad-members.yml ad-trusts.yml ad-data.yml ad-gmsa.yml laps.yml ad-relations.yml adcs.yml ad-acl.yml servers.yml security.yml vulnerabilities.yml' - INVENTORY_FILE: './{{.ENV}}-inventory' - LOG_DATE: '{{now | date "20060102_150405"}}' - LOG_DIR: '$HOME/.ansible/logs/goad' - LOG_FILE: '$HOME/.ansible/logs/goad/{{.ENV}}-dreadgoad-{{.LOG_DATE}}.log' - MAX_RETRIES: '{{.MAX_RETRIES | default "3"}}' - RETRY_DELAY: '{{.RETRY_DELAY | default "30"}}' - # Playbooks that may trigger reboots (tracked for logging purposes only) - REBOOT_PLAYBOOKS: 'ad-parent_domain.yml ad-child_domain.yml ad-members.yml ad-trusts.yml' - DEBUG: '{{.DEBUG | default "false"}}' - VERBOSE_FLAG: '{{if eq .DEBUG "true"}}-vvv{{else}}{{end}}' - -env: - ANSIBLE_CONFIG: './ansible/ansible.cfg' - ANSIBLE_CACHE_PLUGIN_CONNECTION: '$HOME/.ansible/cache/{{.ENV}}_dreadgoad_facts' - ANSIBLE_HOST_KEY_CHECKING: 'False' - ANSIBLE_RETRY_FILES_ENABLED: 'True' - ANSIBLE_GATHER_TIMEOUT: '60' - -set: - - pipefail - -tasks: - default: - desc: Show available tasks and help information - cmds: - - task --list - silent: true - - list-plays: - desc: Show all available playbooks that can be executed - cmds: - - | - echo "Available playbooks:" - for playbook in $(echo '{{.GOAD_PLAYBOOKS}}' | tr '\n' ' '); do - echo " - $playbook" - done - echo "" - echo "Usage examples:" - echo " Run all playbooks: task provision" - echo " Run specific playbooks: task provision PLAYS=\"build.yml ad-servers.yml\"" - echo " Run a single playbook: task provision PLAYS=ad-data.yml" - silent: true - - check-ansible-version: - desc: Verify ansible-core version is compatible with AWS SSM Windows connections - internal: true - silent: true - cmds: - - | - ANSIBLE_VERSION=$(ansible --version 2>/dev/null | head -1 | grep -oE '[0-9]+\.[0-9]+\.[0-9]+') - MAJOR=$(echo "$ANSIBLE_VERSION" | cut -d. -f1) - MINOR=$(echo "$ANSIBLE_VERSION" | cut -d. -f2) - if [ "$MAJOR" -gt 2 ] || { [ "$MAJOR" -eq 2 ] && [ "$MINOR" -ge 19 ]; }; then - echo "ERROR: ansible-core $ANSIBLE_VERSION detected. Versions >=2.19 break Windows" - echo " module execution over AWS SSM (pipelining bug in SSM plugin)." - echo "" - echo " Fix: pip install 'ansible-core>=2.17.0,<2.18.0'" - echo "" - echo " See requirements.txt for details." - exit 1 - fi - if [ -z "$ANSIBLE_VERSION" ]; then - echo "ERROR: ansible-core not found. Install it: pip install -r requirements.txt" - exit 1 - fi - - provision: - desc: Run the DreadGOAD provisioning process with error handling and retries - summary: | - Runs the complete AD provisioning process with DreadGOAD Ansible playbooks - using AWS Systems Manager (SSM). - - Options: - ENV= Environment to target (default: dev) - DEBUG=true Enable verbose Ansible output (-vvv) - MAX_RETRIES= Maximum retry attempts (default: 3) - RETRY_DELAY= Delay between retries in seconds (default: 30) - PLAYS="play1.yml play2.yml" Specific playbooks to run (default: all playbooks) - LIMIT= Limit execution to specific hosts (e.g., srv03, dc01) - - Examples: - task provision ENV=prod DEBUG=true - task provision ENV=staging PLAYS="build.yml ad-servers.yml" - task provision ENV=dev PLAYS=ad-data.yml - task provision PLAYS=laps.yml LIMIT=srv03 - task provision LIMIT="dc01,srv03" PLAYS="ad-members.yml laps.yml" - - To see all available playbooks: - task list-plays - vars: - PLAYS: '{{.PLAYS | default .GOAD_PLAYBOOKS}}' - LIMIT: '{{.LIMIT | default ""}}' - # Generate log file name once for this provision run - PROVISION_LOG_FILE: '$HOME/.ansible/logs/goad/{{.ENV}}-dreadgoad-{{now | date "20060102_150405"}}.log' - deps: - - check-ansible-version - - ssm:cleanup - - ensure-log-dir - - generate-mapping - - prepare-adcs-zips - cmds: - - task: log-provision-header - vars: - PLAYS: '{{.PLAYS}}' - LIMIT: '{{.LIMIT}}' - LOG_FILE: '{{.PROVISION_LOG_FILE}}' - - for: {var: PLAYS, split: ' '} - task: run-playbook-with-retry - vars: - PLAYBOOK: '{{.ITEM}}' - LIMIT: '{{.LIMIT}}' - LOG_FILE: '{{.PROVISION_LOG_FILE}}' - - task: log-provision-success - vars: - LOG_FILE: '{{.PROVISION_LOG_FILE}}' - - ad-users: - desc: Ensure Active Directory users exist using the ad-data.yml playbook - summary: | - Runs the ad-data.yml Ansible playbook to create users in Active Directory. - - Options: - ENV= Environment to target (default: dev) - DEBUG=true Enable verbose Ansible output (-vvv) - MAX_RETRIES= Maximum retry attempts (default: 3) - RETRY_DELAY= Delay between retries in seconds (default: 30) - LIMIT= Limit execution to specific hosts (e.g., dc01, dc02) - - Examples: - task ad-users - task ad-users ENV=prod - task ad-users DEBUG=true - task ad-users LIMIT=dc01 - cmds: - - task: provision - vars: - PLAYS: 'ad-data.yml' - - ensure-log-dir: - internal: true - cmds: - - mkdir -p {{.LOG_DIR}} - status: - - test -d {{.LOG_DIR}} - - prepare-adcs-zips: - desc: Create ADCSTemplate.zip files for ADCS roles - internal: true - cmds: - - cd ansible/roles/adcs_templates/files && zip -r ADCSTemplate.zip ADCSTemplate/ - - cd ansible/roles/vulns_adcs_templates/files && zip -r ADCSTemplate.zip ADCSTemplate/ - status: - - test -f ansible/roles/adcs_templates/files/ADCSTemplate.zip - - test -f ansible/roles/vulns_adcs_templates/files/ADCSTemplate.zip - - log-provision-header: - internal: true - requires: - vars: [PLAYS] - cmds: - - | - { - echo "===============================================" - echo "DreadGOAD provisioning process started at $(date)" - echo "Environment: {{.ENV}}" - echo "Max Retries: {{.MAX_RETRIES}}" - {{if .LIMIT}}echo "Limited to hosts: {{.LIMIT}}"{{end}} - echo "===============================================" - echo "" - echo "Playbooks to be executed:" - for playbook in $(echo '{{.PLAYS}}' | tr '\n' ' '); do - echo " - ansible/playbooks/$playbook" - done - echo "-----------------------------------------------" - } | tee "{{.LOG_FILE}}" - - log-provision-success: - internal: true - cmds: - - | - { - echo "===============================================" - echo "All selected playbooks completed successfully at $(date)" - echo "Full log available at: {{.LOG_FILE}}" - echo "===============================================" - } | tee -a "{{.LOG_FILE}}" - - run-playbook-with-retry: - internal: true - requires: - vars: [PLAYBOOK] - cmds: - - PLAYBOOK={{.PLAYBOOK}} ENV={{.ENV}} LOG_FILE={{.LOG_FILE}} MAX_RETRIES={{.MAX_RETRIES}} RETRY_DELAY={{.RETRY_DELAY}} VERBOSE_FLAG={{.VERBOSE_FLAG}} LIMIT={{.LIMIT}} ./scripts/run-playbook-with-retry.sh - - get-files: - desc: Display content of files related to specific playbooks - summary: | - Displays the content of files related to a specific Ansible playbook - - Options: - ENV= Environment to target (default: dev) - PLAYBOOK= Playbook to get files for - - Example: task get-files PLAYBOOK=security - cmds: - - PLAYBOOK={{.PLAYBOOK}} ENV={{.ENV}} ./scripts/get-playbook-files.sh - - generate-mapping: - desc: Generate AWS instance-to-IP mapping file for Ansible optimization - summary: | - Queries AWS EC2 for instance private IPs and creates a mapping file - that Ansible uses to skip network detection, speeding up playbook execution. - - Options: - ENV= Environment to target (default: dev) - INVENTORY= Path to inventory file (default: ./-inventory) - OUTPUT= Output file path (default: /tmp/aws_instance_mapping_.json) - - Examples: - task generate-mapping # Generate mapping for dev environment - task generate-mapping ENV=prod # Generate mapping for prod environment - task generate-mapping OUTPUT=/tmp/my-mapping.json # Use custom output path - deps: - - update-inventory - vars: - INVENTORY: '{{.INVENTORY | default (print "./" .ENV "-inventory")}}' - OUTPUT: '{{.OUTPUT | default (print "/tmp/aws_instance_mapping_" .ENV ".json")}}' - run: always - cmds: - - ./scripts/generate-instance-mapping.sh "{{.INVENTORY}}" "{{.OUTPUT}}" - - update-inventory: - desc: Synchronize Ansible inventory with AWS instance IDs of running EC2 instances and update environment value - summary: | - Retrieves instance IDs from AWS and updates the environment-specific - Ansible inventory file (default: dev) with the latest instance mappings. - Also updates the "env=" field in the inventory to match the specified ENV parameter. - - Options: - ENV= Environment to target (default: dev) - INVENTORY= Path to inventory file (default: ./-inventory) - OUTPUT= Output file path (default: overwrite inventory file) - BACKUP= Create backup before modifying (default: false) - JSON= Path to JSON file with instances data - - Examples: - task update-inventory # Update dev inventory with current AWS instances - task update-inventory ENV=prod # Update prod inventory and set env=prod in the file - task update-inventory BACKUP=true # Create backup before updating - task update-inventory OUTPUT=new-inventory # Write to new file instead of updating original - vars: - INVENTORY: '{{.INVENTORY | default (print "./" .ENV "-inventory")}}' - OUTPUT: '{{.OUTPUT | default ""}}' - BACKUP: '{{.BACKUP | default "false"}}' - JSON: '{{.JSON | default ""}}' - BACKUP_FILE: '{{.INVENTORY}}.bak.{{now | date "20060102150405"}}' - run: always - cmds: - - ENV={{.ENV}} INVENTORY={{.INVENTORY}} OUTPUT={{.OUTPUT}} BACKUP={{.BACKUP}} BACKUP_FILE={{.BACKUP_FILE}} JSON={{.JSON}} ./scripts/update-inventory.sh - - validate-vulns: - desc: Validate GOAD vulnerability configurations against documented specifications - summary: | - Validates that all GOAD vulnerabilities documented in docs/GOAD-vulnerabilities-comprehensive.md - are properly configured in the AWS deployment. This includes checking: - - - Credential discovery vulnerabilities (passwords in descriptions, etc.) - - Kerberos attack vectors (AS-REP roasting, Kerberoasting) - - Network misconfigurations (SMB signing, LLMNR/NBT-NS) - - Delegation configurations (unconstrained, constrained, RBCD) - - MSSQL configurations and impersonation - - ADCS misconfigurations (ESC1-15) - - ACL permission chains - - Domain trust relationships - - Service configurations (IIS, Print Spooler, etc.) - - Options: - ENV= Environment to target (default: dev) - VERBOSE=true Enable verbose output with debug information - OUTPUT= Custom output file path for JSON results - REGION= AWS region (default: us-west-1) - FAIL_ON_ERROR=false Don't exit with error on failed checks (useful for initial validation) - - Examples: - task validate-vulns # Validate dev environment - task validate-vulns ENV=staging # Validate staging environment - task validate-vulns VERBOSE=true # Enable verbose output - task validate-vulns OUTPUT=/tmp/results.json # Custom output path - task validate-vulns ENV=staging FAIL_ON_ERROR=false # Initial validation, don't fail on errors - task validate-vulns ENV=prod VERBOSE=true # Validate prod with verbose output - - Output: - - Console output with color-coded results (✓/✗/⚠) - - JSON report file with detailed findings - - Exit code 0 if all checks pass, 1 if any fail - - For details on expected vulnerabilities, see: - docs/GOAD-vulnerabilities-comprehensive.md - vars: - VERBOSE: '{{.VERBOSE | default "false"}}' - OUTPUT: '{{.OUTPUT | default ""}}' - REGION: '{{.REGION | default "us-west-1"}}' - FAIL_ON_ERROR: '{{.FAIL_ON_ERROR | default "true"}}' - env: - AWS_REGION: '{{.REGION}}' - AWS_DEFAULT_REGION: '{{.REGION}}' - run: always - cmds: - - | - export ENV={{.ENV}} - export REGION={{.REGION}} - export INVENTORY_FILE={{.INVENTORY_FILE}} - export VERBOSE={{.VERBOSE}} - export FAIL_ON_ERROR={{.FAIL_ON_ERROR}} - {{if .OUTPUT}}export OUTPUT_FILE={{.OUTPUT}}{{end}} - ./scripts/validate-goad-vulns.sh - - ssm:cleanup: - desc: Clean up stale SSM sessions to prevent connection saturation - summary: | - Terminates stale SSM sessions that may be blocking new connections. - AWS SSM has concurrent session limits per instance, and orphaned sessions - from hung/killed playbook runs can cause new connections to hang. - - Options: - ENV= Environment to target (default: dev) - MAX_AGE= Sessions older than this are considered stale (default: 30) - DRY_RUN=true Show what would be terminated without doing it - - Examples: - task ssm:cleanup # Clean stale sessions for dev - task ssm:cleanup ENV=prod # Clean stale sessions for prod - task ssm:cleanup MAX_AGE=15 # More aggressive cleanup (15 min) - task ssm:cleanup DRY_RUN=true # Preview what would be cleaned - vars: - MAX_AGE: '{{.MAX_AGE | default "30"}}' - DRY_RUN: '{{.DRY_RUN | default "false"}}' - REGION: '{{.REGION | default "us-west-2"}}' - run: always - silent: true - cmds: - - | - set -e - INVENTORY="./{{.ENV}}-inventory" - - # Extract instance IDs from inventory - INSTANCE_IDS=$(grep -E 'ansible_host=i-[a-z0-9]+' "$INVENTORY" | sed -E 's/.*ansible_host=(i-[a-z0-9]+).*/\1/' | sort -u) - - if [ -z "$INSTANCE_IDS" ]; then - echo "No instance IDs found in $INVENTORY" - exit 0 - fi - - echo "Checking for stale SSM sessions (older than {{.MAX_AGE}} minutes)..." - echo "Instances: $(echo $INSTANCE_IDS | tr '\n' ' ')" - echo "" - - TOTAL_TERMINATED=0 - CUTOFF_TIME=$(date -u -v-{{.MAX_AGE}}M +%Y-%m-%dT%H:%M:%S 2>/dev/null || date -u -d "{{.MAX_AGE}} minutes ago" +%Y-%m-%dT%H:%M:%S) - - for INSTANCE_ID in $INSTANCE_IDS; do - # Get active sessions for this instance - SESSIONS=$(aws ssm describe-sessions \ - --state Active \ - --region {{.REGION}} \ - --filters "key=Target,value=$INSTANCE_ID" \ - --query "Sessions[?StartDate<\`$CUTOFF_TIME\`].[SessionId,StartDate]" \ - --output text 2>/dev/null || true) - - if [ -n "$SESSIONS" ]; then - echo "=== $INSTANCE_ID ===" - while IFS=$'\t' read -r SESSION_ID START_DATE; do - if [ -n "$SESSION_ID" ]; then - if [ "{{.DRY_RUN}}" = "true" ]; then - echo " [DRY RUN] Would terminate: $SESSION_ID (started: $START_DATE)" - else - aws ssm terminate-session --session-id "$SESSION_ID" --region {{.REGION}} > /dev/null 2>&1 && \ - echo " Terminated: $SESSION_ID (started: $START_DATE)" && \ - TOTAL_TERMINATED=$((TOTAL_TERMINATED + 1)) - fi - fi - done <<< "$SESSIONS" - fi - done - - echo "" - if [ "{{.DRY_RUN}}" = "true" ]; then - echo "Dry run complete. Use DRY_RUN=false to actually terminate sessions." - else - echo "Cleanup complete. Terminated $TOTAL_TERMINATED stale session(s)." - fi - - ssm:status: - desc: Show active SSM sessions for environment instances - summary: | - Displays all active SSM sessions for instances in the current environment. - Useful for debugging connection issues or checking session state. - - Options: - ENV= Environment to target (default: dev) - - Example: - task ssm:status - task ssm:status ENV=prod - vars: - REGION: '{{.REGION | default "us-west-2"}}' - run: always - silent: true - cmds: - - | - set -e - INVENTORY="./{{.ENV}}-inventory" - - # Extract instance IDs from inventory - INSTANCE_IDS=$(grep -E 'ansible_host=i-[a-z0-9]+' "$INVENTORY" | sed -E 's/.*ansible_host=(i-[a-z0-9]+).*/\1/' | sort -u) - - echo "Active SSM sessions for {{.ENV}} environment:" - echo "" - - for INSTANCE_ID in $INSTANCE_IDS; do - HOST=$(grep "$INSTANCE_ID" "$INVENTORY" | awk '{print $1}' | head -1) - SESSIONS=$(aws ssm describe-sessions \ - --state Active \ - --region {{.REGION}} \ - --filters "key=Target,value=$INSTANCE_ID" \ - --query 'Sessions[*].[SessionId,StartDate,Status]' \ - --output text 2>/dev/null || true) - - SESSION_COUNT=$(echo "$SESSIONS" | grep -c . 2>/dev/null || echo "0") - if [ "$SESSION_COUNT" = "0" ] || [ -z "$SESSIONS" ]; then - echo "[$HOST] $INSTANCE_ID: No active sessions" - else - echo "[$HOST] $INSTANCE_ID: $SESSION_COUNT active session(s)" - echo "$SESSIONS" | while IFS=$'\t' read -r SID SDATE STATUS; do - [ -n "$SID" ] && echo " - $SID ($STATUS, started: $SDATE)" - done - fi - done - - health-check: - desc: Verify all GOAD instances are healthy (AD replication, trusts, DNS, services) - summary: | - Runs health checks across all GOAD instances via SSM to verify: - - Domain controllers are responding - - AD replication is working with no failures - - Domain trusts are established - - DNS resolution across domains - - Member servers can reach their DCs - - Critical services (IIS, MSSQL) are running - - Options: - ENV= Environment to target (default: dev) - REGION= AWS region (default: us-west-1) - - Examples: - task health-check - task health-check ENV=staging - task health-check ENV=prod REGION=us-west-1 - vars: - REGION: '{{.REGION | default "us-west-1"}}' - env: - AWS_REGION: '{{.REGION}}' - AWS_DEFAULT_REGION: '{{.REGION}}' - run: always - cmds: - - | - set -e - echo "=== GOAD Health Check ({{.ENV}}) ===" - echo "" - - # Get instance IDs with names - INSTANCES=$(aws ec2 describe-instances \ - --filters "Name=tag:Name,Values=*{{.ENV}}*dreadgoad*" "Name=instance-state-name,Values=running" \ - --query 'Reservations[*].Instances[*].[InstanceId,Tags[?Key==`Name`].Value|[0]]' \ - --output text --region {{.REGION}}) - - if [ -z "$INSTANCES" ]; then - echo "ERROR: No running GOAD instances found for ENV={{.ENV}}" - exit 1 - fi - - echo "Found instances:" - echo "$INSTANCES" | while read -r ID NAME; do - echo " - $NAME ($ID)" - done - echo "" - - # Get DC instance IDs - DC01_ID=$(echo "$INSTANCES" | grep -i dc01 | awk '{print $1}') - DC02_ID=$(echo "$INSTANCES" | grep -i dc02 | awk '{print $1}') - DC03_ID=$(echo "$INSTANCES" | grep -i dc03 | awk '{print $1}') - SRV02_ID=$(echo "$INSTANCES" | grep -i srv02 | awk '{print $1}') - SRV03_ID=$(echo "$INSTANCES" | grep -i srv03 | awk '{print $1}') - - # Check DC01 - Primary DC, replication, trusts - echo "--- DC01 (kingslanding - sevenkingdoms.local) ---" - CMD_ID=$(aws ssm send-command \ - --instance-ids "$DC01_ID" \ - --document-name "AWS-RunPowerShellScript" \ - --parameters 'commands=["Get-ADDomainController -Filter * | Format-Table Name, Domain, IPv4Address, IsGlobalCatalog -AutoSize","Write-Host \"Trusts:\"; Get-ADTrust -Filter * | Format-Table Name, Direction, TrustType -AutoSize","Write-Host \"Replication:\"; repadmin /replsummary"]' \ - --region {{.REGION}} --query 'Command.CommandId' --output text) - sleep 6 - aws ssm get-command-invocation --command-id "$CMD_ID" --instance-id "$DC01_ID" --region {{.REGION}} \ - --query 'StandardOutputContent' --output text - echo "" - - # Check DC02 - Child domain, DNS - echo "--- DC02 (winterfell - north.sevenkingdoms.local) ---" - CMD_ID=$(aws ssm send-command \ - --instance-ids "$DC02_ID" \ - --document-name "AWS-RunPowerShellScript" \ - --parameters 'commands=["Get-ADDomainController -Filter * | Format-Table Name, Domain, IPv4Address -AutoSize","Write-Host \"DNS Test:\"; Resolve-DnsName kingslanding.sevenkingdoms.local -ErrorAction SilentlyContinue | Select Name, IPAddress","Resolve-DnsName meereen.essos.local -ErrorAction SilentlyContinue | Select Name, IPAddress"]' \ - --region {{.REGION}} --query 'Command.CommandId' --output text) - sleep 6 - aws ssm get-command-invocation --command-id "$CMD_ID" --instance-id "$DC02_ID" --region {{.REGION}} \ - --query 'StandardOutputContent' --output text - echo "" - - # Check DC03 - Separate forest, forest trust - echo "--- DC03 (meereen - essos.local) ---" - CMD_ID=$(aws ssm send-command \ - --instance-ids "$DC03_ID" \ - --document-name "AWS-RunPowerShellScript" \ - --parameters 'commands=["Get-ADDomainController -Filter * | Format-Table Name, Domain, IPv4Address -AutoSize","Write-Host \"Forest Trust:\"; Get-ADTrust -Filter * | Format-Table Name, Direction, TrustType, ForestTransitive -AutoSize"]' \ - --region {{.REGION}} --query 'Command.CommandId' --output text) - sleep 6 - aws ssm get-command-invocation --command-id "$CMD_ID" --instance-id "$DC03_ID" --region {{.REGION}} \ - --query 'StandardOutputContent' --output text - echo "" - - # Check member servers - echo "--- Member Servers (SRV02, SRV03) ---" - CMD_ID=$(aws ssm send-command \ - --instance-ids "$SRV02_ID" "$SRV03_ID" \ - --document-name "AWS-RunPowerShellScript" \ - --parameters 'commands=["Write-Host \"=== $env:COMPUTERNAME ===\"","Write-Host \"Domain: $(Get-WmiObject Win32_ComputerSystem | Select -Expand Domain)\"","nltest /dsgetdc: 2>&1 | Select-String -Pattern \"DC:|Address:\"","Get-Service -Name W3SVC,MSSQLSERVER -ErrorAction SilentlyContinue | Format-Table Name, Status -AutoSize"]' \ - --region {{.REGION}} --query 'Command.CommandId' --output text) - sleep 8 - for SRV_ID in $SRV02_ID $SRV03_ID; do - aws ssm get-command-invocation --command-id "$CMD_ID" --instance-id "$SRV_ID" --region {{.REGION}} \ - --query 'StandardOutputContent' --output text 2>/dev/null || echo "Waiting for $SRV_ID..." - done - echo "" - echo "=== Health Check Complete ===" - - ssm:run: - desc: Run arbitrary PowerShell commands across GOAD instances via SSM - summary: | - Execute PowerShell commands on one or more GOAD instances using AWS SSM. - - Options: - ENV= Environment to target (default: dev) - REGION= AWS region (default: us-west-1) - HOSTS= Comma-separated host names (dc01,dc02,srv02) or 'all' (default: all) - CMD= PowerShell command to execute (required) - - Examples: - task ssm:run CMD="Get-ADUser -Filter *" - task ssm:run HOSTS=dc01 CMD="Get-ADDomainController -Filter *" - task ssm:run HOSTS=dc01,dc02 CMD="Get-Service ADWS" - task ssm:run HOSTS=srv02,srv03 CMD="Get-Service MSSQLSERVER" - task ssm:run ENV=staging HOSTS=all CMD="whoami" - vars: - REGION: '{{.REGION | default "us-west-1"}}' - HOSTS: '{{.HOSTS | default "all"}}' - env: - AWS_REGION: '{{.REGION}}' - AWS_DEFAULT_REGION: '{{.REGION}}' - requires: - vars: [CMD] - run: always - cmds: - - | - set -e - - # Get all GOAD instances - INSTANCES=$(aws ec2 describe-instances \ - --filters "Name=tag:Name,Values=*{{.ENV}}*dreadgoad*" "Name=instance-state-name,Values=running" \ - --query 'Reservations[*].Instances[*].[InstanceId,Tags[?Key==`Name`].Value|[0]]' \ - --output text --region {{.REGION}}) - - if [ -z "$INSTANCES" ]; then - echo "ERROR: No running GOAD instances found for ENV={{.ENV}}" - exit 1 - fi - - # Filter instances based on HOSTS parameter - if [ "{{.HOSTS}}" = "all" ]; then - TARGET_IDS=$(echo "$INSTANCES" | awk '{print $1}' | tr '\n' ' ') - else - TARGET_IDS="" - for HOST in $(echo "{{.HOSTS}}" | tr ',' ' '); do - ID=$(echo "$INSTANCES" | grep -i "$HOST" | awk '{print $1}') - if [ -n "$ID" ]; then - TARGET_IDS="$TARGET_IDS $ID" - else - echo "WARNING: Host '$HOST' not found" - fi - done - fi - - if [ -z "$TARGET_IDS" ]; then - echo "ERROR: No matching instances found" - exit 1 - fi - - echo "Running command on: {{.HOSTS}}" - echo "Command: {{.CMD}}" - echo "" - - # Send command to all target instances - CMD_ID=$(aws ssm send-command \ - --instance-ids $TARGET_IDS \ - --document-name "AWS-RunPowerShellScript" \ - --parameters "commands=[\"{{.CMD}}\"]" \ - --region {{.REGION}} --query 'Command.CommandId' --output text) - - echo "Command ID: $CMD_ID" - echo "Waiting for results..." - sleep 6 - - # Get results from each instance - for ID in $TARGET_IDS; do - NAME=$(echo "$INSTANCES" | grep "$ID" | awk '{print $2}') - echo "" - echo "=== $NAME ($ID) ===" - aws ssm get-command-invocation --command-id "$CMD_ID" --instance-id "$ID" --region {{.REGION}} \ - --query '[Status, StandardOutputContent, StandardErrorContent]' --output text 2>/dev/null || echo "Pending..." - done - - verify-trusts: - desc: Verify domain trust relationships between all GOAD domains - summary: | - Validates that all domain trusts are properly configured: - - sevenkingdoms.local <-> north.sevenkingdoms.local (parent-child) - - sevenkingdoms.local <-> essos.local (forest trust) - - Also tests cross-domain authentication by querying users across trusts. - - Options: - ENV= Environment to target (default: dev) - REGION= AWS region (default: us-west-1) - - Examples: - task verify-trusts - task verify-trusts ENV=staging - vars: - REGION: '{{.REGION | default "us-west-1"}}' - env: - AWS_REGION: '{{.REGION}}' - AWS_DEFAULT_REGION: '{{.REGION}}' - run: always - cmds: - - | - set -e - echo "=== GOAD Trust Verification ({{.ENV}}) ===" - echo "" - - # Get DC01 instance ID - DC01_ID=$(aws ec2 describe-instances \ - --filters "Name=tag:Name,Values=*{{.ENV}}*dreadgoad*DC01*" "Name=instance-state-name,Values=running" \ - --query 'Reservations[*].Instances[*].InstanceId' \ - --output text --region {{.REGION}}) - - if [ -z "$DC01_ID" ]; then - echo "ERROR: DC01 not found for ENV={{.ENV}}" - exit 1 - fi - - echo "Using DC01 ($DC01_ID) as trust verification source..." - echo "" - - # Run comprehensive trust check from DC01 - CMD_ID=$(aws ssm send-command \ - --instance-ids "$DC01_ID" \ - --document-name "AWS-RunPowerShellScript" \ - --parameters 'commands=["Write-Host \"=== Domain Trusts from sevenkingdoms.local ===\"","Get-ADTrust -Filter * | Format-Table Name, Direction, TrustType, ForestTransitive, TrustAttributes -AutoSize","Write-Host \"\"","Write-Host \"=== Trust Validation ===\"","nltest /domain_trusts /all_trusts","Write-Host \"\"","Write-Host \"=== Cross-Domain Query Test ===\"","Write-Host \"Querying north.sevenkingdoms.local:\"","Get-ADUser -Filter * -Server winterfell.north.sevenkingdoms.local | Select -First 3 Name | Format-Table -AutoSize","Write-Host \"Querying essos.local:\"","Get-ADUser -Filter * -Server meereen.essos.local | Select -First 3 Name | Format-Table -AutoSize","Write-Host \"\"","Write-Host \"=== Trust Status ===\"","$trusts = Get-ADTrust -Filter *; foreach ($t in $trusts) { Write-Host \"$($t.Name): $(if (Test-ComputerSecureChannel -Server $t.Name -ErrorAction SilentlyContinue) { \"HEALTHY\" } else { \"Check manually\" })\" }"]' \ - --region {{.REGION}} --query 'Command.CommandId' --output text) - - echo "Waiting for trust verification..." - sleep 10 - - RESULT=$(aws ssm get-command-invocation --command-id "$CMD_ID" --instance-id "$DC01_ID" --region {{.REGION}} \ - --query '[Status, StandardOutputContent, StandardErrorContent]' --output text) - - STATUS=$(echo "$RESULT" | head -1) - echo "Status: $STATUS" - echo "" - echo "$RESULT" | tail -n +2 - - if [ "$STATUS" = "Success" ]; then - echo "" - echo "=== Trust Verification Complete ===" - else - echo "" - echo "WARNING: Trust verification may have issues. Check output above." - exit 1 - fi - - validate-vulns-quick: - desc: Quick validation of critical GOAD vulnerabilities (subset of full validation) - summary: | - Performs a quick validation of the most critical GOAD vulnerabilities: - - User accounts exist in all domains - - Key services are running (MSSQL, IIS, ADCS) - - SMB signing configuration - - Domain trusts - - This is a faster alternative to 'validate-vulns' for quick sanity checks. - - Options: - ENV= Environment to target (default: dev) - VERBOSE=true Enable verbose output - - Example: - task validate-vulns-quick - task validate-vulns-quick ENV=staging - vars: - VERBOSE: '{{.VERBOSE | default "false"}}' - REGION: '{{.REGION | default "us-west-1"}}' - env: - AWS_REGION: '{{.REGION}}' - AWS_DEFAULT_REGION: '{{.REGION}}' - run: always - cmds: - - | - export ENV={{.ENV}} - export INVENTORY_FILE={{.INVENTORY_FILE}} - export VERBOSE={{.VERBOSE}} - export QUICK_MODE=true - ./scripts/validate-goad-vulns.sh diff --git a/cli/cmd/config_cmd.go b/cli/cmd/config_cmd.go index 87364f71..324a435d 100644 --- a/cli/cmd/config_cmd.go +++ b/cli/cmd/config_cmd.go @@ -33,6 +33,25 @@ var configShowCmd = &cobra.Command{ fmt.Printf("Ansible Config: %s\n", cfg.AnsibleCfgPath()) fmt.Printf("Playbooks: %s\n", strings.Join(cfg.Playbooks, ", ")) + fmt.Println("\nEnvironments:") + if len(cfg.Environments) == 0 { + fmt.Println(" (none configured, using defaults)") + } else { + for name, ec := range cfg.Environments { + marker := "" + if name == cfg.Env { + marker = " (active)" + } + fmt.Printf(" %s%s:\n", name, marker) + fmt.Printf(" variant: %v\n", ec.Variant) + if ec.Variant { + fmt.Printf(" variant_source: %s\n", valueOrDefault(ec.VariantSource, "ad/GOAD")) + fmt.Printf(" variant_target: %s\n", valueOrDefault(ec.VariantTarget, "ad/GOAD-variant-1")) + fmt.Printf(" variant_name: %s\n", valueOrDefault(ec.VariantName, "variant-1")) + } + } + } + if cfgFile := viper.ConfigFileUsed(); cfgFile != "" { fmt.Printf("\nConfig file: %s\n", cfgFile) } else { @@ -56,7 +75,7 @@ var configInitCmd = &cobra.Command{ } content := `# DreadGOAD CLI Configuration -env: dev +env: staging # region: us-west-2 # Override AWS region (default: from inventory) debug: false max_retries: 3 @@ -64,6 +83,16 @@ retry_delay: 30 idle_timeout: 1200 # log_dir: ~/.ansible/logs/goad # project_root: /path/to/DreadGOAD # Auto-detected if omitted + +# Per-environment settings +environments: + dev: + variant: true + variant_source: ad/GOAD + variant_target: ad/GOAD-variant-1 + variant_name: variant-1 + staging: + variant: false ` if err := os.WriteFile(cfgPath, []byte(content), 0o644); err != nil { return err diff --git a/cli/cmd/health_check.go b/cli/cmd/health_check.go new file mode 100644 index 00000000..33f9e0db --- /dev/null +++ b/cli/cmd/health_check.go @@ -0,0 +1,277 @@ +package cmd + +import ( + "context" + "fmt" + "strings" + "time" + + "github.com/fatih/color" + "github.com/spf13/cobra" +) + +var healthCheckCmd = &cobra.Command{ + Use: "health-check", + Short: "Verify all GOAD instances are healthy", + Long: `Runs health checks across all GOAD instances via SSM to verify: + - Domain controllers are responding + - AD replication is working with no failures + - Domain trusts are established + - DNS resolution across domains + - Member servers can reach their DCs + - Critical services (IIS, MSSQL) are running`, + Example: ` dreadgoad health-check + dreadgoad health-check --env staging`, + RunE: runHealthCheck, +} + +func init() { + rootCmd.AddCommand(healthCheckCmd) +} + +// healthCheck defines a single check: a name, the host to run on, a PS command, and a function to evaluate the output. +type healthCheck struct { + name string + host string + command string + eval func(stdout string) (ok bool, detail string) +} + +func runHealthCheck(cmd *cobra.Command, args []string) error { + ctx := context.Background() + + title := " GOAD Health Check " + pad := 90 - len(title) + left := pad / 2 + right := pad - left + fmt.Printf("%s%s%s\n", strings.Repeat("=", left), title, strings.Repeat("=", right)) + + infra, err := requireInfra(ctx) + if err != nil { + return err + } + + fmt.Printf("%-40s %-10s %s\n", "CHECK", "STATUS", "DETAIL") + fmt.Println(strings.Repeat("-", 90)) + + checks := buildChecks() + + passed := 0 + failed := 0 + + for _, check := range checks { + instanceID, ok := infra.HostMap[check.host] + if !ok { + color.Red("%-40s %-10s %s", check.name, "SKIP", "instance not found") + failed++ + continue + } + + result, err := infra.Client.RunPowerShellCommand(ctx, instanceID, check.command, 90*time.Second) + if err != nil { + color.Red("%-40s %-10s %s", check.name, "FAIL", err.Error()) + failed++ + continue + } + if result.Status != "Success" { + color.Red("%-40s %-10s %s", check.name, "FAIL", "command status: "+result.Status) + failed++ + continue + } + + ok, detail := check.eval(result.Stdout) + if ok { + color.Green("%-40s %-10s %s", check.name, "OK", detail) + passed++ + } else { + color.Red("%-40s %-10s %s", check.name, "FAIL", detail) + failed++ + } + } + + fmt.Println(strings.Repeat("-", 90)) + fmt.Printf("Results: %d passed, %d failed\n", passed, failed) + + if failed > 0 { + return fmt.Errorf("%d health check(s) failed", failed) + } + return nil +} + +func buildChecks() []healthCheck { + return []healthCheck{ + // DC01 - AD responding + { + name: "DC01 AD Domain Controller", + host: "DC01", + command: `(Get-ADDomainController -Filter *).Name -join ','`, + eval: nonEmptyEval("no domain controllers returned"), + }, + // DC01 - Replication + { + name: "DC01 AD Replication", + host: "DC01", + command: `$r = repadmin /replsummary 2>&1 | Out-String; if ($r -match 'fails/total.*[1-9]\d*/') { Write-Output "REPL_ERRORS:$r" } else { Write-Output "REPL_OK" }`, + eval: replEval, + }, + // DC01 - Trusts + { + name: "DC01 Domain Trusts", + host: "DC01", + command: `Get-ADTrust -Filter * | ForEach-Object { "$($_.Name)|$($_.Direction)|$($_.TrustType)" }`, + eval: dc01TrustsEval, + }, + // DC02 - AD responding + { + name: "DC02 AD Domain Controller", + host: "DC02", + command: `(Get-ADDomainController -Filter *).Name -join ','`, + eval: nonEmptyEval("no domain controllers returned"), + }, + // DC02 - DNS cross-domain + { + name: "DC02 DNS (sevenkingdoms.local)", + host: "DC02", + command: `(Resolve-DnsName kingslanding.sevenkingdoms.local -ErrorAction Stop).IPAddress`, + eval: nonEmptyEval("DNS resolution failed"), + }, + { + name: "DC02 DNS (essos.local)", + host: "DC02", + command: `(Resolve-DnsName meereen.essos.local -ErrorAction Stop).IPAddress`, + eval: nonEmptyEval("DNS resolution failed"), + }, + // DC03 - AD responding + { + name: "DC03 AD Domain Controller", + host: "DC03", + command: `(Get-ADDomainController -Filter *).Name -join ','`, + eval: nonEmptyEval("no domain controllers returned"), + }, + // DC03 - Forest trust + { + name: "DC03 Forest Trust", + host: "DC03", + command: `Get-ADTrust -Filter * | ForEach-Object { "$($_.Name)|$($_.ForestTransitive)" }`, + eval: forestTrustEval, + }, + // SRV02 - Domain membership + { + name: "SRV02 Domain Membership", + host: "SRV02", + command: `(Get-WmiObject Win32_ComputerSystem).Domain`, + eval: nonEmptyEval("not domain-joined"), + }, + // SRV02 - DC reachable + { + name: "SRV02 DC Locator", + host: "SRV02", + command: `$r = nltest /dsgetdc: 2>&1 | Out-String; if ($r -match 'DC: \\\\(\S+)') { Write-Output $Matches[1] } else { Write-Output "FAIL" }`, + eval: dcLocatorEval, + }, + // SRV02 - IIS + { + name: "SRV02 IIS (W3SVC)", + host: "SRV02", + command: `(Get-Service W3SVC -ErrorAction SilentlyContinue).Status`, + eval: serviceRunningEval, + }, + // SRV02 - MSSQL + { + name: "SRV02 MSSQL", + host: "SRV02", + command: `(Get-Service 'MSSQL$SQLEXPRESS' -ErrorAction SilentlyContinue).Status`, + eval: serviceRunningEval, + }, + // SRV03 - Domain membership + { + name: "SRV03 Domain Membership", + host: "SRV03", + command: `(Get-WmiObject Win32_ComputerSystem).Domain`, + eval: nonEmptyEval("not domain-joined"), + }, + // SRV03 - DC reachable + { + name: "SRV03 DC Locator", + host: "SRV03", + command: `$r = nltest /dsgetdc: 2>&1 | Out-String; if ($r -match 'DC: \\\\(\S+)') { Write-Output $Matches[1] } else { Write-Output "FAIL" }`, + eval: dcLocatorEval, + }, + // SRV03 - IIS + { + name: "SRV03 IIS (W3SVC)", + host: "SRV03", + command: `(Get-Service W3SVC -ErrorAction SilentlyContinue).Status`, + eval: serviceRunningEval, + }, + // SRV03 - MSSQL + { + name: "SRV03 MSSQL", + host: "SRV03", + command: `(Get-Service 'MSSQL$SQLEXPRESS' -ErrorAction SilentlyContinue).Status`, + eval: serviceRunningEval, + }, + } +} + +func serviceRunningEval(stdout string) (bool, string) { + val := strings.TrimSpace(strings.ToLower(stdout)) + if val == "running" { + return true, "running" + } + if val == "" { + return false, "service not found" + } + return false, val +} + +// nonEmptyEval returns an eval func that passes when trimmed stdout is non-empty. +func nonEmptyEval(failMsg string) func(string) (bool, string) { + return func(stdout string) (bool, string) { + val := strings.TrimSpace(stdout) + if val == "" { + return false, failMsg + } + return true, val + } +} + +func replEval(stdout string) (bool, string) { + if strings.Contains(stdout, "REPL_OK") { + return true, "no replication failures" + } + return false, "replication errors detected" +} + +func dc01TrustsEval(stdout string) (bool, string) { + lower := strings.ToLower(stdout) + hasNorth := strings.Contains(lower, "north.sevenkingdoms.local") + hasEssos := strings.Contains(lower, "essos.local") + if hasNorth && hasEssos { + return true, "north.sevenkingdoms.local + essos.local" + } + var missing []string + if !hasNorth { + missing = append(missing, "north.sevenkingdoms.local") + } + if !hasEssos { + missing = append(missing, "essos.local") + } + return false, "missing: " + strings.Join(missing, ", ") +} + +func forestTrustEval(stdout string) (bool, string) { + lower := strings.ToLower(stdout) + if strings.Contains(lower, "sevenkingdoms.local") && strings.Contains(lower, "true") { + return true, "sevenkingdoms.local (forest transitive)" + } + return false, "forest trust to sevenkingdoms.local not found" +} + +func dcLocatorEval(stdout string) (bool, string) { + val := strings.TrimSpace(stdout) + if val == "FAIL" || val == "" { + return false, "cannot locate domain controller" + } + return true, val +} diff --git a/cli/cmd/infra.go b/cli/cmd/infra.go new file mode 100644 index 00000000..6bc8bc22 --- /dev/null +++ b/cli/cmd/infra.go @@ -0,0 +1,128 @@ +package cmd + +import ( + "context" + "fmt" + "strings" + + daws "github.com/dreadnode/dreadgoad/internal/aws" + "github.com/dreadnode/dreadgoad/internal/config" + "github.com/fatih/color" +) + +// goadHosts is the set of expected GOAD hostnames. +var goadHosts = []string{"DC01", "DC02", "DC03", "SRV02", "SRV03"} + +// infraContext holds the validated infrastructure state needed by commands. +type infraContext struct { + Client *daws.Client + HostMap map[string]string // hostname -> instance ID + Env string + Region string +} + +// requireInfra validates that AWS credentials work, GOAD instances are discoverable, +// and SSM agents are online. Returns the ready-to-use infrastructure context. +func requireInfra(ctx context.Context) (*infraContext, error) { + cfg := config.Get() + + region := cfg.Region + if region == "" { + region = "us-west-1" + } + + client, err := daws.NewClient(ctx, region) + if err != nil { + return nil, fmt.Errorf("create AWS client: %w", err) + } + + identity, err := client.VerifyCredentials(ctx) + if err != nil { + return nil, err + } + color.Green(" AWS credentials OK (account %s)", identity.Account) + + hostMap, err := discoverHostMap(ctx, client, cfg.Env) + if err != nil { + return nil, err + } + + if err := checkSSMOnline(ctx, client, hostMap); err != nil { + return nil, err + } + fmt.Println() + + return &infraContext{ + Client: client, + HostMap: hostMap, + Env: cfg.Env, + Region: region, + }, nil +} + +// discoverHostMap finds running GOAD instances and maps hostnames to instance IDs. +func discoverHostMap(ctx context.Context, client *daws.Client, env string) (map[string]string, error) { + instances, err := client.DiscoverInstances(ctx, env) + if err != nil { + return nil, fmt.Errorf("discover instances: %w", err) + } + if len(instances) == 0 { + return nil, fmt.Errorf("no running GOAD instances found for env=%s", env) + } + + hostMap := make(map[string]string) + for _, inst := range instances { + name := strings.ToUpper(inst.Name) + for _, h := range goadHosts { + if strings.Contains(name, h) { + hostMap[h] = inst.InstanceID + } + } + } + + var found, missing []string + for _, h := range goadHosts { + if _, ok := hostMap[h]; ok { + found = append(found, h) + } else { + missing = append(missing, h) + } + } + color.Green(" Instances discovered: %s", strings.Join(found, ", ")) + if len(missing) > 0 { + color.Yellow(" Instances not found: %s", strings.Join(missing, ", ")) + } + + return hostMap, nil +} + +// checkSSMOnline verifies that SSM agents are online for all discovered instances. +func checkSSMOnline(ctx context.Context, client *daws.Client, hostMap map[string]string) error { + var instanceIDs []string + for _, id := range hostMap { + instanceIDs = append(instanceIDs, id) + } + + statuses, err := client.CheckSSMStatus(ctx, instanceIDs) + if err != nil { + return fmt.Errorf("check SSM status: %w", err) + } + + idToHost := make(map[string]string, len(hostMap)) + for h, id := range hostMap { + idToHost[id] = h + } + + var offline []string + for _, s := range statuses { + if s.PingStatus != "Online" { + offline = append(offline, fmt.Sprintf("%s (%s)", idToHost[s.InstanceID], s.PingStatus)) + } + } + + if len(offline) > 0 { + return fmt.Errorf("SSM agent not online: %s", strings.Join(offline, ", ")) + } + color.Green(" SSM agents online: %d/%d instances", len(statuses), len(statuses)) + return nil +} diff --git a/cli/cmd/provision.go b/cli/cmd/provision.go index db0dfc8d..8c80e5c3 100644 --- a/cli/cmd/provision.go +++ b/cli/cmd/provision.go @@ -12,6 +12,7 @@ import ( "github.com/dreadnode/dreadgoad/internal/ansible" "github.com/dreadnode/dreadgoad/internal/config" "github.com/dreadnode/dreadgoad/internal/doctor" + "github.com/dreadnode/dreadgoad/internal/variant" "github.com/spf13/cobra" ) @@ -90,6 +91,26 @@ func runProvision(cmd *cobra.Command, args []string) error { slog.Warn("ADCS zip preparation failed", "error", err) } + // Pre-flight: auto-generate variant if environment requires it + envCfg := cfg.ActiveEnvironment() + if envCfg.Variant { + source, target := cfg.ResolvedVariantPaths() + variantName := envCfg.VariantName + if variantName == "" { + variantName = "variant-1" + } + if _, err := os.Stat(target); os.IsNotExist(err) { + fmt.Printf("Environment %q has variant=true, generating variant...\n", cfg.Env) + gen := variant.NewGenerator(source, target, variantName) + if err := gen.Run(); err != nil { + return fmt.Errorf("auto variant generation failed: %w", err) + } + fmt.Printf("Variant generated: %s\n", target) + } else { + slog.Info("Variant directory already exists, skipping generation", "target", target) + } + } + // Log header fmt.Println("===============================================") fmt.Printf("DreadGOAD provisioning started at %s\n", time.Now().Format(time.RFC3339)) diff --git a/cli/cmd/root.go b/cli/cmd/root.go index 8f9af0f7..ae221f9d 100644 --- a/cli/cmd/root.go +++ b/cli/cmd/root.go @@ -39,7 +39,7 @@ func Execute() error { func init() { cobra.OnInitialize(config.Init) - rootCmd.PersistentFlags().StringP("env", "e", "dev", "Target environment (dev, staging, prod)") + rootCmd.PersistentFlags().StringP("env", "e", "staging", "Target environment (dev, staging, prod)") rootCmd.PersistentFlags().String("region", "", "AWS region (default: from inventory)") rootCmd.PersistentFlags().Bool("debug", false, "Enable debug/verbose output") rootCmd.PersistentFlags().String("config", "", "Config file path") diff --git a/cli/cmd/validate.go b/cli/cmd/validate.go index 05580501..a944813b 100644 --- a/cli/cmd/validate.go +++ b/cli/cmd/validate.go @@ -6,8 +6,6 @@ import ( "log/slog" "time" - daws "github.com/dreadnode/dreadgoad/internal/aws" - "github.com/dreadnode/dreadgoad/internal/config" "github.com/dreadnode/dreadgoad/internal/validate" "github.com/fatih/color" "github.com/spf13/cobra" @@ -23,7 +21,8 @@ Checks credentials, Kerberos, SMB, delegation, MSSQL, ADCS, ACLs, trusts, and se Example: ` dreadgoad validate dreadgoad validate --env staging --verbose dreadgoad validate --format json --output /tmp/results.json - dreadgoad validate --no-fail`, + dreadgoad validate --no-fail + dreadgoad validate --quick`, RunE: runValidate, } @@ -34,40 +33,40 @@ func init() { validateCmd.Flags().String("output", "", "JSON report output path") validateCmd.Flags().Bool("verbose", false, "Enable verbose output") validateCmd.Flags().Bool("no-fail", false, "Don't exit with error on failed checks") + validateCmd.Flags().Bool("quick", false, "Quick validation of critical vulnerabilities only") } func runValidate(cmd *cobra.Command, args []string) error { - cfg := config.Get() ctx := context.Background() verbose, _ := cmd.Flags().GetBool("verbose") outputPath, _ := cmd.Flags().GetString("output") noFail, _ := cmd.Flags().GetBool("no-fail") + quick, _ := cmd.Flags().GetBool("quick") - // Determine region - region := cfg.Region - if region == "" { - region = "us-west-1" // validate default matches Taskfile - } + fmt.Println("==========================================") + fmt.Println("GOAD Vulnerability Validation") + fmt.Println("==========================================") - client, err := daws.NewClient(ctx, region) + infra, err := requireInfra(ctx) if err != nil { - return fmt.Errorf("create AWS client: %w", err) + return err } - fmt.Println("==========================================") - fmt.Println("GOAD Vulnerability Validation") - fmt.Println("==========================================") - fmt.Printf("Environment: %s\n", cfg.Env) - fmt.Printf("Region: %s\n", region) + fmt.Printf("Environment: %s\n", infra.Env) + fmt.Printf("Region: %s\n", infra.Region) - v := validate.NewValidator(client, cfg.Env, verbose, slog.Default()) + v := validate.NewValidator(infra.Client, infra.Env, verbose, slog.Default()) if err := v.DiscoverHosts(ctx); err != nil { return fmt.Errorf("discover hosts: %w", err) } - v.RunAllChecks(ctx) + if quick { + v.RunQuickChecks(ctx) + } else { + v.RunAllChecks(ctx) + } report := v.GetReport() diff --git a/cli/cmd/variant.go b/cli/cmd/variant.go index 5ea51a05..cd3f5397 100644 --- a/cli/cmd/variant.go +++ b/cli/cmd/variant.go @@ -39,11 +39,23 @@ func init() { func runVariantGenerate(cmd *cobra.Command, args []string) error { cfg := config.Get() + envCfg := cfg.ActiveEnvironment() source, _ := cmd.Flags().GetString("source") target, _ := cmd.Flags().GetString("target") name, _ := cmd.Flags().GetString("name") + // Use environment config as defaults when flags weren't explicitly set + if !cmd.Flags().Changed("source") && envCfg.VariantSource != "" { + source = envCfg.VariantSource + } + if !cmd.Flags().Changed("target") && envCfg.VariantTarget != "" { + target = envCfg.VariantTarget + } + if !cmd.Flags().Changed("name") && envCfg.VariantName != "" { + name = envCfg.VariantName + } + // Resolve paths relative to project root if !filepath.IsAbs(source) { source = filepath.Join(cfg.ProjectRoot, source) diff --git a/cli/cmd/verify_trusts.go b/cli/cmd/verify_trusts.go new file mode 100644 index 00000000..11a737e9 --- /dev/null +++ b/cli/cmd/verify_trusts.go @@ -0,0 +1,114 @@ +package cmd + +import ( + "context" + "fmt" + "strings" + "time" + + "github.com/fatih/color" + "github.com/spf13/cobra" +) + +var verifyTrustsCmd = &cobra.Command{ + Use: "verify-trusts", + Short: "Verify domain trust relationships between all GOAD domains", + Long: `Validates that all domain trusts are properly configured: + - sevenkingdoms.local <-> north.sevenkingdoms.local (parent-child) + - sevenkingdoms.local <-> essos.local (forest trust) + +Also tests cross-domain authentication by querying users across trusts.`, + Example: ` dreadgoad verify-trusts + dreadgoad verify-trusts --env staging`, + RunE: runVerifyTrusts, +} + +func init() { + rootCmd.AddCommand(verifyTrustsCmd) +} + +func runVerifyTrusts(cmd *cobra.Command, args []string) error { + ctx := context.Background() + + title := " GOAD Trust Verification " + pad := 90 - len(title) + left := pad / 2 + right := pad - left + fmt.Printf("%s%s%s\n", strings.Repeat("=", left), title, strings.Repeat("=", right)) + + infra, err := requireInfra(ctx) + if err != nil { + return err + } + + dc01ID, ok := infra.HostMap["DC01"] + if !ok { + return fmt.Errorf("DC01 not found in discovered instances") + } + + fmt.Printf("Using DC01 (%s) as trust verification source...\n\n", dc01ID) + + trustScript := `Write-Host "=== Domain Trusts from sevenkingdoms.local ===" +Get-ADTrust -Filter * | Format-Table Name, Direction, TrustType, ForestTransitive, TrustAttributes -AutoSize + +Write-Host "" +Write-Host "=== Trust Validation ===" +nltest /domain_trusts /all_trusts + +Write-Host "" +Write-Host "=== Cross-Domain Query Test ===" +Write-Host "Querying north.sevenkingdoms.local:" +Get-ADUser -Filter * -Server winterfell.north.sevenkingdoms.local | Select -First 3 Name | Format-Table -AutoSize +Write-Host "Querying essos.local:" +Get-ADUser -Filter * -Server meereen.essos.local | Select -First 3 Name | Format-Table -AutoSize + +Write-Host "" +Write-Host "=== Trust Status ===" +$trusts = Get-ADTrust -Filter * +foreach ($t in $trusts) { + Write-Host "$($t.Name): $(if (Test-ComputerSecureChannel -Server $t.Name -ErrorAction SilentlyContinue) { 'HEALTHY' } else { 'Check manually' })" +}` + + result, err := infra.Client.RunPowerShellCommand(ctx, dc01ID, trustScript, 2*time.Minute) + if err != nil { + return fmt.Errorf("run trust verification: %w", err) + } + + fmt.Printf("Status: %s\n\n", result.Status) + + if result.Stdout != "" { + fmt.Println(result.Stdout) + } + if result.Stderr != "" { + color.Yellow("STDERR: %s", result.Stderr) + } + + if result.Status == "Success" { + // Verify expected trusts are present in output + output := strings.ToLower(result.Stdout) + allGood := true + + if strings.Contains(output, "north.sevenkingdoms.local") { + color.Green(" ✓ Parent-child trust: north.sevenkingdoms.local") + } else { + color.Red(" ✗ Parent-child trust: north.sevenkingdoms.local NOT found") + allGood = false + } + + if strings.Contains(output, "essos.local") { + color.Green(" ✓ Forest trust: essos.local") + } else { + color.Red(" ✗ Forest trust: essos.local NOT found") + allGood = false + } + + fmt.Println("\n=== Trust Verification Complete ===") + + if !allGood { + return fmt.Errorf("one or more trust verifications failed") + } + return nil + } + + return fmt.Errorf("trust verification returned status: %s", result.Status) +} diff --git a/cli/go.mod b/cli/go.mod index 2a79391b..edaea3ab 100644 --- a/cli/go.mod +++ b/cli/go.mod @@ -7,6 +7,7 @@ require ( github.com/aws/aws-sdk-go-v2/config v1.32.13 github.com/aws/aws-sdk-go-v2/service/ec2 v1.296.2 github.com/aws/aws-sdk-go-v2/service/ssm v1.68.4 + github.com/aws/aws-sdk-go-v2/service/sts v1.41.10 github.com/fatih/color v1.19.0 github.com/spf13/cobra v1.10.2 github.com/spf13/viper v1.21.0 @@ -23,7 +24,6 @@ require ( github.com/aws/aws-sdk-go-v2/service/signin v1.0.9 // indirect github.com/aws/aws-sdk-go-v2/service/sso v1.30.14 // indirect github.com/aws/aws-sdk-go-v2/service/ssooidc v1.35.18 // indirect - github.com/aws/aws-sdk-go-v2/service/sts v1.41.10 // indirect github.com/aws/smithy-go v1.24.2 // indirect github.com/fsnotify/fsnotify v1.9.0 // indirect github.com/go-viper/mapstructure/v2 v2.4.0 // indirect diff --git a/cli/internal/aws/client.go b/cli/internal/aws/client.go index 35c981ef..e48d2732 100644 --- a/cli/internal/aws/client.go +++ b/cli/internal/aws/client.go @@ -9,12 +9,14 @@ import ( awsconfig "github.com/aws/aws-sdk-go-v2/config" "github.com/aws/aws-sdk-go-v2/service/ec2" "github.com/aws/aws-sdk-go-v2/service/ssm" + "github.com/aws/aws-sdk-go-v2/service/sts" ) -// Client wraps AWS SDK clients for EC2 and SSM. +// Client wraps AWS SDK clients for EC2, SSM, and STS. type Client struct { EC2 *ec2.Client SSM *ssm.Client + STS *sts.Client Region string } @@ -40,6 +42,7 @@ func NewClient(ctx context.Context, region string) (*Client, error) { c := &Client{ EC2: ec2.NewFromConfig(cfg), SSM: ssm.NewFromConfig(cfg), + STS: sts.NewFromConfig(cfg), Region: region, } clients[region] = c diff --git a/cli/internal/aws/preflight.go b/cli/internal/aws/preflight.go new file mode 100644 index 00000000..1f410593 --- /dev/null +++ b/cli/internal/aws/preflight.go @@ -0,0 +1,65 @@ +package aws + +import ( + "context" + "fmt" + + "github.com/aws/aws-sdk-go-v2/service/ssm" + ssmtypes "github.com/aws/aws-sdk-go-v2/service/ssm/types" +) + +// Identity holds the result of an STS GetCallerIdentity call. +type Identity struct { + Account string + ARN string +} + +// VerifyCredentials validates that AWS credentials are configured and working. +func (c *Client) VerifyCredentials(ctx context.Context) (*Identity, error) { + out, err := c.STS.GetCallerIdentity(ctx, nil) + if err != nil { + return nil, fmt.Errorf("AWS credentials invalid or not configured: %w", err) + } + return &Identity{ + Account: deref(out.Account), + ARN: deref(out.Arn), + }, nil +} + +// SSMStatus holds the SSM agent ping status for an instance. +type SSMStatus struct { + InstanceID string + PingStatus string // "Online", "ConnectionLost", etc. +} + +// CheckSSMStatus queries SSM DescribeInstanceInformation for the given instance IDs +// and returns their ping status. Instances not managed by SSM are reported as "NotManaged". +func (c *Client) CheckSSMStatus(ctx context.Context, instanceIDs []string) ([]SSMStatus, error) { + if len(instanceIDs) == 0 { + return nil, nil + } + + out, err := c.SSM.DescribeInstanceInformation(ctx, &ssm.DescribeInstanceInformationInput{ + Filters: []ssmtypes.InstanceInformationStringFilter{ + {Key: Ptr("InstanceIds"), Values: instanceIDs}, + }, + }) + if err != nil { + return nil, fmt.Errorf("describe instance information: %w", err) + } + + managed := make(map[string]string, len(out.InstanceInformationList)) + for _, info := range out.InstanceInformationList { + managed[deref(info.InstanceId)] = string(info.PingStatus) + } + + statuses := make([]SSMStatus, 0, len(instanceIDs)) + for _, id := range instanceIDs { + status := "NotManaged" + if s, ok := managed[id]; ok { + status = s + } + statuses = append(statuses, SSMStatus{InstanceID: id, PingStatus: status}) + } + return statuses, nil +} diff --git a/cli/internal/config/config.go b/cli/internal/config/config.go index 16ec99d1..42da3e04 100644 --- a/cli/internal/config/config.go +++ b/cli/internal/config/config.go @@ -8,17 +8,26 @@ import ( "github.com/spf13/viper" ) +// EnvironmentConfig holds per-environment settings. +type EnvironmentConfig struct { + Variant bool `mapstructure:"variant"` + VariantSource string `mapstructure:"variant_source"` + VariantTarget string `mapstructure:"variant_target"` + VariantName string `mapstructure:"variant_name"` +} + // Config holds all CLI configuration. type Config struct { - Env string `mapstructure:"env"` - Region string `mapstructure:"region"` - Debug bool `mapstructure:"debug"` - MaxRetries int `mapstructure:"max_retries"` - RetryDelay int `mapstructure:"retry_delay"` - IdleTimeout int `mapstructure:"idle_timeout"` - LogDir string `mapstructure:"log_dir"` - Playbooks []string `mapstructure:"playbooks"` - ProjectRoot string `mapstructure:"project_root"` + Env string `mapstructure:"env"` + Region string `mapstructure:"region"` + Debug bool `mapstructure:"debug"` + MaxRetries int `mapstructure:"max_retries"` + RetryDelay int `mapstructure:"retry_delay"` + IdleTimeout int `mapstructure:"idle_timeout"` + LogDir string `mapstructure:"log_dir"` + Playbooks []string `mapstructure:"playbooks"` + ProjectRoot string `mapstructure:"project_root"` + Environments map[string]EnvironmentConfig `mapstructure:"environments"` } var ( @@ -95,6 +104,39 @@ func (c *Config) AnsibleEnv() map[string]string { } } +// ActiveEnvironment returns the EnvironmentConfig for the currently selected env. +// Returns a zero-value EnvironmentConfig if not defined (variant: false). +func (c *Config) ActiveEnvironment() EnvironmentConfig { + if c.Environments == nil { + return EnvironmentConfig{} + } + return c.Environments[c.Env] +} + +// ResolvedVariantPaths returns absolute source/target paths for the active +// environment's variant config. Returns empty strings if variant is false. +func (c *Config) ResolvedVariantPaths() (source, target string) { + ec := c.ActiveEnvironment() + if !ec.Variant { + return "", "" + } + src := ec.VariantSource + if src == "" { + src = "ad/GOAD" + } + tgt := ec.VariantTarget + if tgt == "" { + tgt = "ad/GOAD-variant-1" + } + if !filepath.IsAbs(src) { + src = filepath.Join(c.ProjectRoot, src) + } + if !filepath.IsAbs(tgt) { + tgt = filepath.Join(c.ProjectRoot, tgt) + } + return src, tgt +} + func findProjectRoot() string { // Walk up from cwd looking for ansible/ directory dir, _ := os.Getwd() diff --git a/cli/internal/config/defaults.go b/cli/internal/config/defaults.go index 75a843f6..4361ff02 100644 --- a/cli/internal/config/defaults.go +++ b/cli/internal/config/defaults.go @@ -30,7 +30,7 @@ var RebootPlaybooks = []string{ } func setDefaults() { - viper.SetDefault("env", "dev") + viper.SetDefault("env", "staging") viper.SetDefault("region", "") viper.SetDefault("debug", false) viper.SetDefault("max_retries", 3) @@ -38,4 +38,15 @@ func setDefaults() { viper.SetDefault("idle_timeout", 1200) viper.SetDefault("log_dir", "") viper.SetDefault("playbooks", DefaultPlaybooks) + viper.SetDefault("environments", map[string]interface{}{ + "dev": map[string]interface{}{ + "variant": true, + "variant_source": "ad/GOAD", + "variant_target": "ad/GOAD-variant-1", + "variant_name": "variant-1", + }, + "staging": map[string]interface{}{ + "variant": false, + }, + }) } diff --git a/cli/internal/validate/validator.go b/cli/internal/validate/validator.go index 5f024556..fd1ef4b0 100644 --- a/cli/internal/validate/validator.go +++ b/cli/internal/validate/validator.go @@ -87,6 +87,16 @@ func (v *Validator) DiscoverHosts(ctx context.Context) error { return nil } +// RunQuickChecks runs a subset of critical checks: credentials, services, SMB signing, and trusts. +func (v *Validator) RunQuickChecks(ctx context.Context) { + v.checkCredentialDiscovery(ctx) + v.checkNetworkMisconfigs(ctx) + v.checkMSSQL(ctx) + v.checkADCS(ctx) + v.checkDomainTrusts(ctx) + v.checkServices(ctx) +} + // RunAllChecks executes all vulnerability validation checks. func (v *Validator) RunAllChecks(ctx context.Context) { v.checkCredentialDiscovery(ctx) diff --git a/docs/architecture.svg b/docs/architecture.svg index cb6a2736..bcb3ed2f 100644 --- a/docs/architecture.svg +++ b/docs/architecture.svg @@ -1 +1 @@ -

dreadnode.goad
Ansible Collection

LAPS
4 roles

SCCM
14 roles

Vulnerabilities
20 roles

Security
8 roles

Settings
13 roles

Active Directory
21 roles

Server Roles
15 roles

dc • permissions • server
verify

config_accounts • config_boundary • config_client_install
config_client_push • config_discovery • config_naa
config_pxe • config_users • install_adk
install_iis • install_mecm • install_prerequisites
install_wsus • pxe

acls • adcs_templates • administrator_folder
anonymous_enum • autologon • credentials
directory • disable_firewall • enable_credssp_client
enable_credssp_server • enable_llmnr • enable_nbt_ns
files • mssql • ntlmdowngrade
openshares • permissions • schedule
shares • smbv1

dc_audit_sacl • ldap_diagnostic_logging • account_is_sensitive
asr • audit_policy • enable_run_as_ppl
ensure_kb_not_installed • powershell_restrict

adjust_rights • admin_password • copy_files
disable_nat_adapter • enable_nat_adapter • gpmc
gpo_remove • hostname • keyboard
no_updates • updates • user_rights
windows_defender

acl • ad • adcs
adcs_templates • child_domain • dc_dns_conditional_forwarder
disable_user • dns_conditional_forwarder • domain_controller
domain_controller_slave • enable_user • gmsa
gmsa_hosts • groups_domains • member_server
move_to_ou • onlyusers • parent_child_dns
password_policy • sync_domains • trusts

common • commonwkstn • dhcp
elk • fix_dns • iis
localusers • logs_windows • mssql
audit • link • reporting
ssms • ps • webdav

Playbooks
35 playbooks

ad • ad-acl • ad-child_domain
ad-data • ad-gmsa • ad-members
ad-parent_domain • ad-relations • ad-servers
ad-trusts • adcs • build
data • dhcp • diagnose-dc01
disable_vagrant • elk • enable_vagrant
fix_dns • fix_trust • interfaces
laps • localusers • main
onlyusers • reboot • sccm-client
sccm-config • sccm-install • sccm-pxe
security • security_logging • servers
vulnerabilities • wait5m

\ No newline at end of file +

dreadnode.goad
Ansible Collection

LAPS
4 roles

SCCM
14 roles

Vulnerabilities
20 roles

Security
8 roles

Settings
13 roles

Active Directory
21 roles

Server Roles
15 roles

dc • permissions • server
verify

config_accounts • config_boundary • config_client_install
config_client_push • config_discovery • config_naa
config_pxe • config_users • install_adk
install_iis • install_mecm • install_prerequisites
install_wsus • pxe

acls • adcs_templates • administrator_folder
anonymous_enum • autologon • credentials
directory • disable_firewall • enable_credssp_client
enable_credssp_server • enable_llmnr • enable_nbt_ns
files • mssql • ntlmdowngrade
openshares • permissions • schedule
shares • smbv1

dc_audit_sacl • ldap_diagnostic_logging • account_is_sensitive
asr • audit_policy • enable_run_as_ppl
ensure_kb_not_installed • powershell_restrict

adjust_rights • admin_password • copy_files
disable_nat_adapter • enable_nat_adapter • gpmc
gpo_remove • hostname • keyboard
no_updates • updates • user_rights
windows_defender

acl • ad • adcs
adcs_templates • child_domain • dc_dns_conditional_forwarder
disable_user • dns_conditional_forwarder • domain_controller
domain_controller_slave • enable_user • gmsa
gmsa_hosts • groups_domains • member_server
move_to_ou • onlyusers • parent_child_dns
password_policy • sync_domains • trusts

common • commonwkstn • dhcp
elk • fix_dns • iis
localusers • logs_windows • mssql
audit • link • reporting
ssms • ps • webdav

Playbooks
35 playbooks

ad • ad-acl • ad-child_domain
ad-data • ad-gmsa • ad-members
ad-parent_domain • ad-relations • ad-servers
ad-trusts • adcs • build
data • dhcp • diagnose-dc01
disable_vagrant • elk • enable_vagrant
fix_dns • fix_trust • interfaces
laps • localusers • main
onlyusers • reboot • sccm-client
sccm-config • sccm-install • sccm-pxe
security • security_logging • servers
vulnerabilities • wait5m

diff --git a/docs/cli.md b/docs/cli.md new file mode 100644 index 00000000..89630895 --- /dev/null +++ b/docs/cli.md @@ -0,0 +1,117 @@ +# DreadGOAD CLI Configuration + +The `dreadgoad` CLI uses [Viper](https://github.com/spf13/viper) for +configuration, with values resolved in this priority order: + +1. CLI flags (`--env`, `--region`, `--debug`) +2. Environment variables (`DREADGOAD_ENV`, `DREADGOAD_REGION`, etc.) +3. Config file (YAML) +4. Built-in defaults + +## Config File + +The config file is **optional**. When present it is loaded from: + +1. Path given via `--config` flag +2. `~/.config/dreadgoad/dreadgoad.yaml` +3. `./dreadgoad.yaml` (current directory) + +### Creating a Config File + +```bash +dreadgoad config init +``` + +This writes a default config to `~/.config/dreadgoad/dreadgoad.yaml`. + +### Viewing Effective Config + +```bash +dreadgoad config show +``` + +### Setting a Value + +```bash +dreadgoad config set env staging +dreadgoad config set environments.dev.variant true +``` + +## Reference + +```yaml +# Active environment (selects into the environments map below) +env: staging + +# AWS region override (default: resolved from inventory) +# region: us-west-2 + +debug: false +max_retries: 3 # Ansible playbook retry attempts +retry_delay: 30 # Seconds between retries +idle_timeout: 1200 # Seconds before killing idle ansible-playbook + +# Auto-detected by walking up from cwd looking for ansible/ directory +# project_root: /path/to/DreadGOAD + +# Log directory (default: ~/.ansible/logs/goad) +# log_dir: ~/.ansible/logs/goad + +# Per-environment settings +environments: + dev: + variant: true + variant_source: ad/GOAD # Source directory to clone from + variant_target: ad/GOAD-variant-1 # Output directory for generated variant + variant_name: variant-1 # Variant identifier + staging: + variant: false +``` + +## Per-Environment Settings + +The `environments` map lets you configure behavior per environment. The +active environment is selected by the top-level `env` key. + +### Variant Support + +When `variant: true`, the environment uses a randomized GOAD variant +instead of the stock lab. Variants are graph-isomorphic copies with +randomized entity names (domains, users, hosts, groups, OUs, passwords) +that preserve all structural relationships and vulnerabilities. + +| Key | Description | Default | +|------------------|--------------------------------------|----------------------| +| `variant` | Enable randomized variant | `false` | +| `variant_source` | Source GOAD directory to clone from | `ad/GOAD` | +| `variant_target` | Output directory for the variant | `ad/GOAD-variant-1` | +| `variant_name` | Variant identifier | `variant-1` | + +### How It Works + +- **`dreadgoad provision`**: When the active environment has `variant: true`, + provisioning automatically generates the variant if the target directory + doesn't exist yet. Subsequent runs skip generation. + +- **`dreadgoad variant generate`**: Reads defaults from the active + environment's config. Explicit flags (`--source`, `--target`, `--name`) + override the config values. + +- **Regenerating**: Delete the variant target directory and re-run + `dreadgoad provision` or `dreadgoad variant generate` to get fresh + randomized names. + +## Environment Variables + +All config keys can be set via environment variables with the +`DREADGOAD_` prefix: + +| Variable | Config Key | +|-----------------------|----------------| +| `DREADGOAD_ENV` | `env` | +| `DREADGOAD_REGION` | `region` | +| `DREADGOAD_DEBUG` | `debug` | +| `DREADGOAD_MAX_RETRIES` | `max_retries` | +| `DREADGOAD_RETRY_DELAY` | `retry_delay` | +| `DREADGOAD_IDLE_TIMEOUT` | `idle_timeout` | +| `DREADGOAD_LOG_DIR` | `log_dir` | diff --git a/docs/taskfile.md b/docs/taskfile.md deleted file mode 100644 index f0f1adb4..00000000 --- a/docs/taskfile.md +++ /dev/null @@ -1,111 +0,0 @@ -# 🛠️ Taskfile for DreadGOAD - -This repository contains a Taskfile for managing DreadGOAD provisioning logic -using Ansible and AWS Systems Manager (SSM). It provides a set of automated -tasks for deploying, configuring, and managing AD environments efficiently. - -## 📋 Prerequisites - -Before using these tasks, ensure that the following dependencies are installed -and properly configured: - -- [AWS CLI](https://aws.amazon.com/cli/) installed and configured -- [jq](https://stedolan.github.io/jq/) installed (for JSON processing) -- [Task](https://taskfile.dev) installed (`brew install go-task/tap/go-task` or equivalent) - -## 🎯 Available Tasks - -### `default` - -Displays a list of available tasks. - -```bash -task -``` - -### `list-plays` - -Lists all available Ansible playbooks that can be executed. - -```bash -task list-plays -``` - -### `provision` - -Runs the complete AD provisioning process using Ansible playbooks. - -```bash -task provision ENV=prod DEBUG=true -``` - -Optional variables: - -- `ENV`: Environment to target (default: `dev`) -- `DEBUG`: Enables verbose Ansible output with `-vvv` flag (`true` or `false`, default: `false`) -- `MAX_RETRIES`: Maximum retry attempts for failed playbooks (default: `3`) -- `RETRY_DELAY`: Delay in seconds between retries (default: `30`) -- `PLAYS`: List of specific playbooks to execute (default: all playbooks) - -Example usage: - -```bash -task provision PLAYS="build.yml ad-servers.yml" ENV=prod -``` - -### `get-files` - -Displays content of files related to a specific playbook. - -```bash -task get-files PLAYBOOK=security -``` - -Required variable: - -- `PLAYBOOK`: Name of the playbook whose files should be displayed. - -### `update-inventory` - -Synchronizes the Ansible inventory with AWS instance IDs. - -```bash -task update-inventory ENV=prod BACKUP=true --force -``` - -Optional variables: - -- `ENV`: Target environment (default: `dev`) -- `INVENTORY`: Path to inventory file (default: `./-inventory`) -- `OUTPUT`: Output file path (if different from inventory file) -- `BACKUP`: Create a backup before modifying the inventory (default: `false`) -- `JSON`: Path to a JSON file with AWS instance data -- `--force`: Force update inventory without confirmation - -## 🔧 Extending Tasks - -You can extend these tasks by importing this Taskfile into your own Taskfile -and adding custom logic: - -```yaml -version: "3" - -includes: - ad: - taskfile: ./Taskfile.yaml - optional: true - -tasks: - provision-extended: - deps: [ad:provision] - cmds: - - echo "Additional post-provisioning steps..." -``` - -## 🔍 Important Notes - -- Ensure that AWS CLI is properly configured (`aws configure`). -- Ansible inventory should be kept up to date with the `update-inventory` task. -- The `provision` task implements error handling and retries for robustness. -- Be cautious with deletion tasks, as they can remove AWS resources permanently. -- Use the `AWS_PROFILE` environment variable if working with multiple AWS accounts. diff --git a/docs/validation.md b/docs/validation.md index d1ef298a..d6dd368b 100644 --- a/docs/validation.md +++ b/docs/validation.md @@ -11,24 +11,17 @@ The GOAD validation system checks that all 50+ vulnerabilities documented in [`G ### Run Full Validation ```bash -cd /Users/l/dreadnode/DreadOps/apps/DreadGOAD +# Validate staging environment (default) +dreadgoad validate -# Validate dev environment (default) -task validate-vulns - -# Validate staging environment -task validate-vulns ENV=staging +# Validate a specific environment +dreadgoad validate --env dev # Enable verbose output -task validate-vulns VERBOSE=true +dreadgoad validate --verbose # Initial validation without failing on errors -task validate-vulns ENV=staging FAIL_ON_ERROR=false - -# Run script directly (useful for debugging) -ENV=staging REGION=us-west-1 INVENTORY_FILE=./staging-inventory \ - VERBOSE=true FAIL_ON_ERROR=false \ - ./scripts/validate-goad-vulns.sh +dreadgoad validate --env staging --no-fail ``` ### Run Quick Validation @@ -36,8 +29,8 @@ ENV=staging REGION=us-west-1 INVENTORY_FILE=./staging-inventory \ For a faster sanity check of critical vulnerabilities: ```bash -task validate-vulns-quick -task validate-vulns-quick ENV=staging +dreadgoad validate --quick +dreadgoad validate --quick --env dev ``` ## What Gets Validated @@ -245,17 +238,10 @@ Use this checklist to track validation progress: ```bash # Check instance status -task -y aws:list-running-instances +dreadgoad lab status # Verify SSM agent is running aws ssm describe-instance-information --filters "Key=tag:Name,Values=*dreadgoad*" - -# Test instance discovery manually -aws ec2 describe-instances \ - --filters "Name=tag:Name,Values=*dreadgoad*" "Name=instance-state-name,Values=running" \ - --region us-west-1 \ - --query 'Reservations[*].Instances[*].[InstanceId,Tags[?Key==`Name`].Value|[0]]' \ - --output table ``` #### 2. "Permission denied" errors @@ -279,21 +265,11 @@ aws sts get-caller-identity **Solution**: ```bash -# Option 1: Run with FAIL_ON_ERROR=false to see progress -task validate-vulns ENV=staging FAIL_ON_ERROR=false +# Option 1: Run with --no-fail to see progress +dreadgoad validate --env staging --no-fail --verbose -# Option 2: Run script directly with all parameters -ENV=staging REGION=us-west-1 INVENTORY_FILE=./staging-inventory \ - VERBOSE=true FAIL_ON_ERROR=false \ - ./scripts/validate-goad-vulns.sh - -# Option 3: Test AWS CLI connectivity first +# Option 2: Test AWS CLI connectivity first time aws ec2 describe-instances --region us-west-1 --max-results 5 - -# If AWS CLI is slow, check: -# - Network connectivity -# - AWS credentials are valid -# - Region is correct ``` **Note**: The script may take 1-2 minutes to complete due to multiple AWS API calls. This is normal. @@ -316,10 +292,10 @@ time aws ec2 describe-instances --region us-west-1 --max-results 5 ```bash # Re-run vulnerability provisioning -task provision PLAYS=vulnerabilities.yml +dreadgoad provision --plays vulnerabilities.yml # Or provision specific vulnerability roles -task provision PLAYS=vulnerabilities.yml LIMIT=dc02 +dreadgoad provision --plays vulnerabilities.yml --limit dc02 ``` ## Advanced Usage @@ -336,7 +312,7 @@ vim scripts/validate-goad-vulns.sh ### Custom Output Location ```bash -task validate-vulns OUTPUT=/path/to/custom-report.json +dreadgoad validate --output /path/to/custom-report.json ``` ### Integrate with CI/CD @@ -348,7 +324,7 @@ Use the validation script in your CI/CD pipeline: - name: Validate GOAD Deployment run: | cd apps/DreadGOAD - task validate-vulns ENV=staging + dreadgoad validate --env staging continue-on-error: false ``` @@ -356,7 +332,7 @@ Use the validation script in your CI/CD pipeline: ```bash # Run validation and generate HTML report -task validate-vulns OUTPUT=/tmp/report.json +dreadgoad validate --output /tmp/report.json python3 scripts/generate-html-report.py /tmp/report.json > /tmp/report.html ``` @@ -406,7 +382,7 @@ After validation: ## Related Documentation - [`GOAD-vulnerabilities-comprehensive.md`](./GOAD-vulnerabilities-comprehensive.md) - Complete vulnerability catalog -- [`taskfile.md`](./taskfile.md) - Task usage documentation +- [`cli.md`](./cli.md) - CLI usage and configuration reference - [GOAD Official Docs](https://github.com/Orange-Cyberdefense/GOAD) - Upstream documentation - [Mayfly's Walkthrough Series](https://mayfly277.github.io/categories/goad/) - Attack technique guides