Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
24 commits
Select commit Hold shift + click to select a range
5f1fe10
feat(score): add live verification and standalone score command
mkultraWasHere Jul 12, 2026
82d8db6
fix(score): harden live verification checks
mkultraWasHere Jul 13, 2026
64c7fa6
feat(score): add Azure Bastion SSH support for live verification
mkultraWasHere Jul 13, 2026
10c48ec
fix(score): harden Azure BastionShellRunner
mkultraWasHere Jul 13, 2026
327577f
feat(score): auto-discover Azure Bastion, Kali VM, and SSH key
mkultraWasHere Jul 13, 2026
7f17b57
docs: add scoring.md and update scoreboard.md references
mkultraWasHere Jul 13, 2026
0913287
fix(score): use NetBIOS names for DCSync, remove technique scoring, a…
mkultraWasHere Jul 13, 2026
7c27deb
fix: address Copilot PR review feedback
mkultraWasHere Jul 13, 2026
369c0a8
fix(score): stderr summary, failed check reporting, filter krbtgt can…
mkultraWasHere Jul 13, 2026
80e7c2a
fix: align remaining /tmp/report.jsonl references to ./report.jsonl
mkultraWasHere Jul 13, 2026
3fb3be8
fix(score): emit failed_check for missing dc_ip on live_auth, validat…
mkultraWasHere Jul 13, 2026
921b0aa
fix(score): improve error when Azure resource ID used without -p azure
mkultraWasHere Jul 13, 2026
364c88e
fix: guard parseNXCOutput against empty username and fix FailedCheck …
mkultraWasHere Jul 13, 2026
0752581
fix(score): guard empty username, deduplicate cred matching, align SS…
mkultraWasHere Jul 13, 2026
a4dfc50
fix(variant): re-encrypt PowerShell SecureStrings with mapped passwords
mkultraWasHere Jul 13, 2026
a2796fb
fix(score): local auth fallback for host admin checks and nxc flag co…
mkultraWasHere Jul 13, 2026
6a662ae
fix(variant): randomize share names to prevent GOAD fingerprinting
mkultraWasHere Jul 14, 2026
0b79484
fix(score): fallback to admin_users list when findings lack hostname tag
mkultraWasHere Jul 14, 2026
9cbb680
feat(score): add reset subcommand for between-run range cleanup
mkultraWasHere Jul 15, 2026
3e76c3c
fix(score): add rogue account detection, group membership diff, and e…
mkultraWasHere Jul 15, 2026
5eb115f
fix(score): replace extension-based /tmp cleanup with catch-all approach
mkultraWasHere Jul 16, 2026
313a7bb
chore(scoreboard): add second agent prompt variant
mkultraWasHere Jul 16, 2026
7d9426b
Merge branch 'main' into feat/live-verification
mkultraWasHere Jul 16, 2026
4711421
fix(validate): distinguish transport errors from empty results in Win…
mkultraWasHere Jul 16, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
22 changes: 1 addition & 21 deletions cli/cmd/bastion.go
Original file line number Diff line number Diff line change
Expand Up @@ -244,7 +244,7 @@ func resolveRoleDefaults(client *azure.Client, ctx context.Context, env, hostnam
return &roleDefaults{
authType: "ssh-key",
user: "kali",
sshKey: kaliKeyPath(env, inst.Name),
sshKey: azure.KaliKeyPath(env, inst.Name),
}
default:
return nil
Expand Down Expand Up @@ -272,26 +272,6 @@ func controllerKeyPath(env, vmName string) string {
return path
}

// kaliKeyPath derives the conventional ephemeral private-key path the
// terraform-azure-kali module writes. VM names follow
// "{env}-{deployment}-kali-vm"; the module writes to
// "~/.dreadgoad/keys/azure-{env}-{deployment}-kali".
func kaliKeyPath(env, vmName string) string {
deployment := strings.TrimSuffix(strings.TrimPrefix(vmName, env+"-"), "-kali-vm")
if deployment == "" || deployment == vmName {
return ""
}
home, err := os.UserHomeDir()
if err != nil {
return ""
}
path := filepath.Join(home, ".dreadgoad", "keys", fmt.Sprintf("azure-%s-%s-kali", env, deployment))
if _, err := os.Stat(path); err != nil {
return ""
}
return path
}

func runBastionRDP(cmd *cobra.Command, args []string) error {
ctx := context.Background()
client, host, cfg, err := bastionContext(ctx)
Expand Down
273 changes: 273 additions & 0 deletions cli/cmd/score.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,273 @@
package cmd

import (
"context"
"encoding/json"
"fmt"
"os"
"path/filepath"
"sort"
"strings"

"github.com/dreadnode/dreadgoad/internal/azure"
"github.com/dreadnode/dreadgoad/internal/config"
"github.com/dreadnode/dreadgoad/internal/scoreboard"
"github.com/spf13/cobra"
)

var scoreCmd = &cobra.Command{
Use: "score",
Short: "Score an agent's report against the answer key",
Long: `Scores an agent's JSONL report against the answer key and outputs
a JSON result. Supports live verification via --live-verify to test
credentials against the running GOAD lab.

Use 'score generate-key' to build the answer key from a lab config.`,
RunE: runScore,
}

var scoreGenerateKeyCmd = &cobra.Command{
Use: "generate-key",
Short: "Generate the answer key from a GOAD config.json",
RunE: runScoreGenerateKey,
}

func init() {
rootCmd.AddCommand(scoreCmd)
scoreCmd.AddCommand(scoreGenerateKeyCmd)

scoreCmd.Flags().String("report", "", "Path to the agent's JSONL report file (required)")
scoreCmd.Flags().String("answer-key", "", "Path to answer_key.json (default: scoreboard/answer_key.json)")
scoreCmd.Flags().String("output", "", "Write JSON result to file instead of stdout")
scoreCmd.Flags().Bool("live-verify", false, "Enable live verification via the attack box")
scoreCmd.Flags().String("attack-box", "", "Instance ID (AWS) or resource ID (Azure) of the Kali attack box")
scoreCmd.Flags().String("region", "", "AWS region for SSM")
scoreCmd.Flags().String("profile", "", "AWS named profile")

// Azure-specific flags for live verification (optional overrides;
// all are auto-discovered from the environment when omitted).
scoreCmd.Flags().String("ssh-key", "", "Path to SSH private key for the Kali VM (Azure; auto-discovered if omitted)")
scoreCmd.Flags().String("ssh-user", "kali", "SSH username for the Kali VM (Azure)")

scoreGenerateKeyCmd.Flags().String("config", "", "Path to GOAD config.json (default: ad/GOAD/data/config.json)")
scoreGenerateKeyCmd.Flags().String("output", "", "Output path for answer_key.json (default: scoreboard/answer_key.json)")
}

func runScore(cmd *cobra.Command, _ []string) error {
cfg, err := config.Get()
if err != nil {
return err
}

reportPath, _ := cmd.Flags().GetString("report")
if reportPath == "" {
return fmt.Errorf("--report is required")
}

answerKeyPath, _ := cmd.Flags().GetString("answer-key")
if answerKeyPath == "" {
answerKeyPath = filepath.Join(cfg.ProjectRoot, "scoreboard", "answer_key.json")
}

ak, err := scoreboard.LoadAnswerKey(answerKeyPath)
if err != nil {
return fmt.Errorf("%w (run 'dreadgoad score generate-key' first)", err)
}

raw, err := os.ReadFile(reportPath)
if err != nil {
return fmt.Errorf("read report: %w", err)
}
report := scoreboard.ParseReport(string(raw))

ctx := cmd.Context()
var lv *scoreboard.LiveVerifier
if live, _ := cmd.Flags().GetBool("live-verify"); live {
runner, err := buildShellRunner(ctx, cmd, cfg)
if err != nil {
return fmt.Errorf("live verification setup: %w", err)
}
lv = scoreboard.NewLiveVerifier(runner)
}

result := scoreboard.ScoreReport(ctx, report, ak, lv)

data, err := json.MarshalIndent(result, "", " ")
if err != nil {
return fmt.Errorf("marshal result: %w", err)
}

out := cmd.OutOrStdout()

// Human-readable summary on stderr so stdout stays valid JSON.
fmt.Fprintf(os.Stderr, "\n Score: %s (%s)\n\n", result.AgentID, result.Mode)
total, achieved := 0, 0
for _, g := range []string{"credentials", "hosts", "domains"} {
s := result.Summary[g]
if s == nil {
continue
}
fmt.Fprintf(os.Stderr, " %-14s %d / %d\n", g, s.Achieved, s.Total)
total += s.Total
achieved += s.Achieved
}
fmt.Fprintf(os.Stderr, " %-14s %d / %d\n", "TOTAL", achieved, total)
Comment thread
mkultraWasHere marked this conversation as resolved.
if len(result.FailedChecks) > 0 {
fmt.Fprintf(os.Stderr, "\n %d failed check(s) — see JSON output for details\n", len(result.FailedChecks))
}
fmt.Fprintln(os.Stderr)

outputPath, _ := cmd.Flags().GetString("output")
if outputPath != "" {
if err := os.WriteFile(outputPath, data, 0o644); err != nil {
return fmt.Errorf("write output: %w", err)
}
_, err = fmt.Fprintf(os.Stderr, "JSON result written to %s\n", outputPath)
return err
}

_, err = fmt.Fprintln(out, string(data))
return err
}

func buildShellRunner(ctx context.Context, cmd *cobra.Command, cfg *config.Config) (scoreboard.ShellRunner, error) {
attackBox, _ := cmd.Flags().GetString("attack-box")

// Explicit Azure resource ID — skip auto-discovery.
if strings.HasPrefix(attackBox, "/subscriptions/") {
return buildAzureRunner(ctx, cmd, cfg, attackBox)
}

// Explicit AWS instance ID.
if attackBox != "" {
if !strings.HasPrefix(attackBox, "i-") {
return nil, fmt.Errorf("unrecognized --attack-box format %q (expected AWS instance ID like i-0abc123 or Azure resource ID starting with /subscriptions/)", attackBox)
}
return buildAWSRunner(ctx, cmd, cfg, attackBox)
}

// No --attack-box: auto-detect provider.
if cfg.Provider == "azure" {
return buildAzureRunner(ctx, cmd, cfg, "")
}
return nil, fmt.Errorf("--attack-box is required with --live-verify (or set -p azure for auto-discovery)")
}

func buildAWSRunner(ctx context.Context, cmd *cobra.Command, cfg *config.Config, instanceID string) (scoreboard.ShellRunner, error) {
region, _ := cmd.Flags().GetString("region")
if region == "" {
region = cfg.Region
}
profile, _ := cmd.Flags().GetString("profile")
return scoreboard.NewSSMShellRunner(ctx, instanceID, region, profile)
}

func buildAzureRunner(ctx context.Context, cmd *cobra.Command, cfg *config.Config, vmResourceID string) (scoreboard.ShellRunner, error) {
prov, err := cfg.NewProvider(ctx)
if err != nil {
return nil, fmt.Errorf("create azure provider: %w", err)
}
ap, ok := prov.(*azure.AzureProvider)
if !ok {
return nil, fmt.Errorf("expected azure provider, got %s — pass -p azure when using an Azure resource ID for --attack-box", prov.Name())
}
client := ap.Client()
Comment thread
mkultraWasHere marked this conversation as resolved.

if _, err := client.VerifyCredentials(ctx); err != nil {
return nil, fmt.Errorf("azure credentials: %w", err)
}

// Discover Bastion.
bastion, err := client.DiscoverBastion(ctx, cfg.Env)
if err != nil {
return nil, fmt.Errorf("discover bastion: %w", err)
}
if bastion == nil {
return nil, fmt.Errorf("no Azure Bastion found for env=%s", cfg.Env)
}

// Discover Kali VM if not explicitly provided.
if vmResourceID == "" {
kali, err := client.DiscoverKali(ctx, cfg.Env)
if err != nil {
return nil, fmt.Errorf("discover kali: %w", err)
}
if kali == nil {
return nil, fmt.Errorf("no Kali attack box (Role=AttackBox) found for env=%s", cfg.Env)
}
vmResourceID = kali.ID

// Auto-discover SSH key from Kali VM name.
sshKey, _ := cmd.Flags().GetString("ssh-key")
if sshKey == "" {
sshKey = azure.KaliKeyPath(cfg.Env, kali.Name)
if sshKey == "" {
return nil, fmt.Errorf("could not find SSH key for Kali VM %s; use --ssh-key", kali.Name)
}
}
sshUser, _ := cmd.Flags().GetString("ssh-user")
return &scoreboard.BastionShellRunner{
BastionName: bastion.Name,
ResourceGroup: bastion.ResourceGroup,
VMResourceID: vmResourceID,
SSHKeyPath: sshKey,
Username: sshUser,
}, nil
}

// Explicit VM resource ID — still need SSH key.
sshKey, _ := cmd.Flags().GetString("ssh-key")
if sshKey == "" {
return nil, fmt.Errorf("--ssh-key is required when using explicit --attack-box with Azure")
}
sshUser, _ := cmd.Flags().GetString("ssh-user")
return &scoreboard.BastionShellRunner{
BastionName: bastion.Name,
ResourceGroup: bastion.ResourceGroup,
VMResourceID: vmResourceID,
SSHKeyPath: sshKey,
Username: sshUser,
}, nil
}

func runScoreGenerateKey(cmd *cobra.Command, _ []string) error {
cfg, err := config.Get()
if err != nil {
return err
}
configPath, _ := cmd.Flags().GetString("config")
if configPath == "" {
configPath = filepath.Join(cfg.ProjectRoot, "ad", "GOAD", "data", "config.json")
}
outputPath, _ := cmd.Flags().GetString("output")
if outputPath == "" {
outputPath = filepath.Join(cfg.ProjectRoot, "scoreboard", "answer_key.json")
}
if err := os.MkdirAll(filepath.Dir(outputPath), 0o755); err != nil {
return fmt.Errorf("mkdir %s: %w", filepath.Dir(outputPath), err)
}

ak, err := scoreboard.GenerateAnswerKey(configPath)
if err != nil {
return err
}
if err := scoreboard.WriteAnswerKey(ak, outputPath); err != nil {
return fmt.Errorf("write answer key: %w", err)
}

out := cmd.OutOrStdout()
if _, err := fmt.Fprintf(out, "Generated answer key: %d objectives → %s\n", ak.TotalObjectives, outputPath); err != nil {
return err
}
keys := make([]string, 0, len(ak.Groups))
for g := range ak.Groups {
keys = append(keys, g)
}
sort.Strings(keys)
for _, g := range keys {
if _, err := fmt.Fprintf(out, " %s: %d\n", g, ak.Groups[g]); err != nil {
return err
}
}
return nil
}
Loading
Loading