-
Notifications
You must be signed in to change notification settings - Fork 10
Live verification scoring + standalone score command #355
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Draft
mkultraWasHere
wants to merge
24
commits into
main
Choose a base branch
from
feat/live-verification
base: main
Could not load branches
Branch not found: {{ refName }}
Loading
Could not load tags
Nothing to show
Loading
Are you sure you want to change the base?
Some commits from the old base branch may be removed from the timeline,
and old review comments may become outdated.
Draft
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 82d8db6
fix(score): harden live verification checks
mkultraWasHere 64c7fa6
feat(score): add Azure Bastion SSH support for live verification
mkultraWasHere 10c48ec
fix(score): harden Azure BastionShellRunner
mkultraWasHere 327577f
feat(score): auto-discover Azure Bastion, Kali VM, and SSH key
mkultraWasHere 7f17b57
docs: add scoring.md and update scoreboard.md references
mkultraWasHere 0913287
fix(score): use NetBIOS names for DCSync, remove technique scoring, a…
mkultraWasHere 7c27deb
fix: address Copilot PR review feedback
mkultraWasHere 369c0a8
fix(score): stderr summary, failed check reporting, filter krbtgt can…
mkultraWasHere 80e7c2a
fix: align remaining /tmp/report.jsonl references to ./report.jsonl
mkultraWasHere 3fb3be8
fix(score): emit failed_check for missing dc_ip on live_auth, validat…
mkultraWasHere 921b0aa
fix(score): improve error when Azure resource ID used without -p azure
mkultraWasHere 364c88e
fix: guard parseNXCOutput against empty username and fix FailedCheck …
mkultraWasHere 0752581
fix(score): guard empty username, deduplicate cred matching, align SS…
mkultraWasHere a4dfc50
fix(variant): re-encrypt PowerShell SecureStrings with mapped passwords
mkultraWasHere a2796fb
fix(score): local auth fallback for host admin checks and nxc flag co…
mkultraWasHere 6a662ae
fix(variant): randomize share names to prevent GOAD fingerprinting
mkultraWasHere 0b79484
fix(score): fallback to admin_users list when findings lack hostname tag
mkultraWasHere 9cbb680
feat(score): add reset subcommand for between-run range cleanup
mkultraWasHere 3e76c3c
fix(score): add rogue account detection, group membership diff, and e…
mkultraWasHere 5eb115f
fix(score): replace extension-based /tmp cleanup with catch-all approach
mkultraWasHere 313a7bb
chore(scoreboard): add second agent prompt variant
mkultraWasHere 7d9426b
Merge branch 'main' into feat/live-verification
mkultraWasHere 4711421
fix(validate): distinguish transport errors from empty results in Win…
mkultraWasHere File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Some comments aren't visible on the classic Files Changed page.
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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) | ||
| 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() | ||
|
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 | ||
| } | ||
Oops, something went wrong.
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
Uh oh!
There was an error while loading. Please reload this page.