Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
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
24 changes: 19 additions & 5 deletions cli/cmd/provision.go
Original file line number Diff line number Diff line change
Expand Up @@ -191,12 +191,26 @@ func bootstrapInventory(invPath string) error {
}

func bootstrapFromProviderTemplate(invPath string, cfg *config.Config) error {
labName := "GOAD"
if cfg.ResolvedProvider() == "proxmox" {
labName = cfg.ProxmoxLab()
}
providerName := cfg.ResolvedProvider()
templatePath := filepath.Join(cfg.ProjectRoot, "ad", labName, "providers", providerName, "inventory")

// Resolve the lab tree that holds the provider inventory template. For a
// variant environment, read from the variant target tree so the
// bootstrapped inventory (which carries domain_name and the asset layout)
// points at the variant's ad/<target>/ assets rather than the stock
// ad/GOAD/ tree. Falls back to the stock/proxmox path for non-variants.
var templatePath string
if ec := cfg.ActiveEnvironment(); ec.Variant {
if _, target := cfg.ResolvedVariantPaths(); target != "" {
templatePath = filepath.Join(target, "providers", providerName, "inventory")
}
}
if templatePath == "" {
labName := "GOAD"
if providerName == "proxmox" {
labName = cfg.ProxmoxLab()
}
templatePath = filepath.Join(cfg.ProjectRoot, "ad", labName, "providers", providerName, "inventory")
}

data, err := os.ReadFile(templatePath)
if err != nil {
Expand Down
96 changes: 87 additions & 9 deletions cli/internal/variant/generator.go
Original file line number Diff line number Diff line change
Expand Up @@ -715,18 +715,64 @@ func (g *Generator) applyReplacements(content string) string {
}

if g.isNameComponent(r.Old) {
pattern := `\b` + regexp.QuoteMeta(r.Old) + `\b`
re, err := regexp.Compile(pattern)
if err == nil {
content = re.ReplaceAllString(content, r.New)
}
content = replaceNameComponent(content, r.Old, r.New)
} else {
content = strings.ReplaceAll(content, r.Old, r.New)
}
}
return content
}

// replaceNameComponent replaces every occurrence of a name-component token
// (a firstname/surname fragment) with its replacement, honoring both normal
// word boundaries AND CamelCase boundaries. Go's regexp (RE2) has no
// lookahead, so we match a leading word boundary plus the token, then accept a
// match only when the character immediately after it is:
//
// (a) absent (end of string),
// (b) a non-word character (normal word boundary), or
// (c) an uppercase ASCII letter (CamelCase boundary, e.g. "StarkWallpaper").
//
// A trailing lowercase letter, digit, or underscore is rejected so that
// "starky"/"starkey" are left intact.
func replaceNameComponent(content, old, replacement string) string {
re, err := regexp.Compile(`\b` + regexp.QuoteMeta(old))
if err != nil {
return content
}

var b strings.Builder
last := 0
for _, loc := range re.FindAllStringIndex(content, -1) {
start, end := loc[0], loc[1]

accept := true
if end < len(content) {
c := content[end]
isWord := c == '_' ||
('0' <= c && c <= '9') ||
('a' <= c && c <= 'z') ||
('A' <= c && c <= 'Z')
isUpper := 'A' <= c && c <= 'Z'
// Reject only when the following char is a word char that is NOT
// an uppercase letter (i.e. lowercase letter, digit, or underscore).
if isWord && !isUpper {
accept = false
}
}

b.WriteString(content[last:start])
if accept {
b.WriteString(replacement)
} else {
b.WriteString(content[start:end])
}
last = end
}
b.WriteString(content[last:])
return b.String()
}

// isNameComponent returns true if old is a firstname/surname component needing word-boundary protection.
func (g *Generator) isNameComponent(old string) bool {
if _, ok := g.mappings.Misc[old]; !ok {
Expand Down Expand Up @@ -845,14 +891,23 @@ var textFilenames = map[string]bool{
func (g *Generator) transformFile(srcPath, relPath string) (transformed bool) {
ext := filepath.Ext(srcPath)
base := filepath.Base(srcPath)
targetFile := filepath.Join(g.TargetPath, relPath)

// Rename the output basename so a file named after an identity
// (e.g. files/srv02/all/arya.txt) is written under the same rewritten
// name that config.json now references (kathleen.txt). Only the final
// path element is transformed; the directory portion of relPath is left
// untouched. Uses the same replacement machinery as file content.
relDir := filepath.Dir(relPath)
newBase := g.applyReplacements(base)
targetFile := filepath.Join(g.TargetPath, relDir, newBase)

if err := os.MkdirAll(filepath.Dir(targetFile), 0o755); err != nil {
fmt.Printf("Warning: mkdir failed for %s: %v\n", relPath, err)
return false
}

if textExtensions[ext] || textFilenames[base] {
isInventory := strings.HasPrefix(base, "inventory")
if textExtensions[ext] || textFilenames[base] || isInventory {
content, err := os.ReadFile(srcPath)
if err != nil {
fmt.Printf("Warning: Could not read %s: %v\n", relPath, err)
Expand All @@ -876,6 +931,10 @@ func (g *Generator) transformFile(srcPath, relPath string) (transformed bool) {
}
}

if isInventory {
newContent = g.repointDomainName(newContent)
}

if err := os.WriteFile(targetFile, []byte(newContent), 0o644); err != nil {
fmt.Printf("Warning: Could not write %s: %v\n", relPath, err)
return false
Expand All @@ -887,6 +946,22 @@ func (g *Generator) transformFile(srcPath, relPath string) (transformed bool) {
return false
}

// repointDomainName rewrites the Ansible `domain_name` inventory variable to
// the variant target folder basename. Playbooks resolve vuln scripts/files as
// ad/{{ domain_name }}/..., so for a variant this must point at the variant's
// own ad/<target>/ tree rather than the stock ad/GOAD/ tree. Only the value is
// changed; the key, indentation, and surrounding lines are preserved.
func (g *Generator) repointDomainName(content string) string {
target := filepath.Base(g.TargetPath)
re := regexp.MustCompile(`(?m)^(\s*domain_name\s*=).*$`)
return re.ReplaceAllStringFunc(content, func(line string) string {
if idx := strings.Index(line, "="); idx >= 0 {
return line[:idx+1] + target
}
return line
})
}

// copyAndTransform copies the source directory, transforming text files.
func (g *Generator) copyAndTransform() error {
fmt.Println("\n=== Copying and Transforming Files ===")
Expand All @@ -908,9 +983,12 @@ func (g *Generator) copyAndTransform() error {
return nil
}

// Skip .git files
// Skip git metadata (the .git directory itself is skipped above), but KEEP
// .gitkeep placeholders: they preserve otherwise-empty directories the lab
// relies on (e.g. files/srv02/wwwroot/upload/), which would silently vanish
// from the variant if dropped.
rel, _ := filepath.Rel(g.SourcePath, path)
if strings.Contains(rel, ".git") {
if strings.Contains(rel, ".git") && d.Name() != ".gitkeep" {
return nil
}

Expand Down
37 changes: 37 additions & 0 deletions cli/internal/variant/replace_name_component_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
package variant

import "testing"

// TestReplaceNameComponent locks in the CamelCase-aware boundary matching that
// closes the compound-token leak (e.g. the GPO name "StarkWallpaper"). A name
// component is replaced when it is preceded by a word boundary and followed by
// end-of-string, a non-word character, or an UPPERCASE letter (CamelCase); it is
// left intact when followed by a lowercase letter, digit, or underscore.
func TestReplaceNameComponent(t *testing.T) {
cases := []struct {
name string
in string
old string
repl string
want string
}{
{"camelCase boundary (the StarkWallpaper bug)", `New-GPO -Name "StarkWallpaper"`, "Stark", "Research", `New-GPO -Name "ResearchWallpaper"`},
{"trailing backslash is a boundary", `north\Stark`, "Stark", "Research", `north\Research`},
{"trailing dot is a boundary", "Stark.txt", "Stark", "Research", "Research.txt"},
{"end of string", "Stark", "Stark", "Research", "Research"},
{"standalone word", "the Stark house", "Stark", "Research", "the Research house"},
{"multiple occurrences, mixed boundaries", "Stark and StarkWallpaper", "Stark", "Research", "Research and ResearchWallpaper"},
{"lowercase continuation is NOT replaced", "Starkey", "Stark", "Research", "Starkey"},
{"digit continuation is NOT replaced", "Stark1", "Stark", "Research", "Stark1"},
{"underscore continuation is NOT replaced", "Stark_svc", "Stark", "Research", "Stark_svc"},
{"no left boundary is NOT replaced", "aStark", "Stark", "Research", "aStark"},
{"no occurrence is unchanged", "nothing here", "Stark", "Research", "nothing here"},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
if got := replaceNameComponent(tc.in, tc.old, tc.repl); got != tc.want {
t.Errorf("replaceNameComponent(%q, %q, %q) = %q, want %q", tc.in, tc.old, tc.repl, got, tc.want)
}
})
}
}
Loading