Files
lgpo-server/internal/bundle/bundle.go
groot d191b871de
All checks were successful
release-tag / release-image (push) Successful in 1m37s
init
2026-08-05 10:46:09 +02:00

355 lines
8.4 KiB
Go

package bundle
import (
"archive/zip"
"crypto/sha256"
"encoding/binary"
"encoding/hex"
"errors"
"fmt"
"hash"
"io"
"os"
"path"
"path/filepath"
"sort"
"strings"
)
const (
DefaultMaxFiles = 20000
DefaultMaxExpanded = int64(2 << 30) // 2 GiB
DefaultMaxSingleFile = int64(512 << 20)
)
type Inspection struct {
ArtifactHash string
SemanticHash string
Size int64
FileCount int
PolicyFiles int
}
type fileDigest struct {
name string
size uint64
hash [sha256.Size]byte
}
func InspectZip(filename string) (Inspection, error) {
f, err := os.Open(filename)
if err != nil {
return Inspection{}, err
}
defer f.Close()
st, err := f.Stat()
if err != nil {
return Inspection{}, err
}
artifact := sha256.New()
if _, err := io.Copy(artifact, f); err != nil {
return Inspection{}, err
}
zr, err := zip.OpenReader(filename)
if err != nil {
return Inspection{}, fmt.Errorf("invalid ZIP: %w", err)
}
defer zr.Close()
if len(zr.File) == 0 {
return Inspection{}, errors.New("ZIP is empty")
}
if len(zr.File) > DefaultMaxFiles {
return Inspection{}, fmt.Errorf("ZIP has too many entries: %d", len(zr.File))
}
var total uint64
var payload []fileDigest
backupXML := 0
fileCount := 0
for _, zf := range zr.File {
name, err := cleanArchivePath(zf.Name)
if err != nil {
return Inspection{}, err
}
if zf.FileInfo().IsDir() {
continue
}
if !zf.Mode().IsRegular() {
return Inspection{}, fmt.Errorf("unsupported ZIP entry type: %q", zf.Name)
}
if zf.UncompressedSize64 > uint64(DefaultMaxSingleFile) {
return Inspection{}, fmt.Errorf("ZIP entry too large: %q", zf.Name)
}
total += zf.UncompressedSize64
if total > uint64(DefaultMaxExpanded) {
return Inspection{}, errors.New("expanded ZIP exceeds safety limit")
}
fileCount++
lower := strings.ToLower(name)
if path.Base(lower) == "backup.xml" {
backupXML++
}
if !isPolicyPayload(lower) {
continue
}
rc, err := zf.Open()
if err != nil {
return Inspection{}, err
}
h := sha256.New()
_, copyErr := io.Copy(h, io.LimitReader(rc, DefaultMaxSingleFile+1))
closeErr := rc.Close()
if copyErr != nil {
return Inspection{}, copyErr
}
if closeErr != nil {
return Inspection{}, closeErr
}
var sum [sha256.Size]byte
copy(sum[:], h.Sum(nil))
payload = append(payload, fileDigest{name: lower, size: zf.UncompressedSize64, hash: sum})
}
if backupXML == 0 {
return Inspection{}, errors.New("no backup.xml found; expected a Microsoft GPO backup")
}
if len(payload) == 0 {
return Inspection{}, errors.New("no policy payload under DomainSysvol/GPO found")
}
sort.Slice(payload, func(i, j int) bool { return payload[i].name < payload[j].name })
semantic := sha256.New()
for _, item := range payload {
writeField(semantic, []byte(item.name))
var size [8]byte
binary.BigEndian.PutUint64(size[:], item.size)
writeField(semantic, size[:])
writeField(semantic, item.hash[:])
}
return Inspection{
ArtifactHash: hex.EncodeToString(artifact.Sum(nil)),
SemanticHash: hex.EncodeToString(semantic.Sum(nil)),
Size: st.Size(),
FileCount: fileCount,
PolicyFiles: len(payload),
}, nil
}
func writeField(h hash.Hash, b []byte) {
var n [8]byte
binary.BigEndian.PutUint64(n[:], uint64(len(b)))
_, _ = h.Write(n[:])
_, _ = h.Write(b)
}
func isPolicyPayload(lower string) bool {
return strings.Contains("/"+lower, "/domainsysvol/gpo/")
}
func cleanArchivePath(name string) (string, error) {
name = strings.ReplaceAll(name, "\\", "/")
if strings.ContainsRune(name, '\x00') {
return "", errors.New("ZIP path contains NUL")
}
clean := path.Clean(name)
if clean == "." || clean == "" {
return "", nil
}
if strings.HasPrefix(clean, "/") || clean == ".." || strings.HasPrefix(clean, "../") || strings.Contains(clean, ":") {
return "", fmt.Errorf("unsafe ZIP path: %q", name)
}
return clean, nil
}
type ExtractLimits struct {
MaxFiles int
MaxExpanded int64
MaxSingleFile int64
}
func (l ExtractLimits) withDefaults() ExtractLimits {
if l.MaxFiles <= 0 {
l.MaxFiles = DefaultMaxFiles
}
if l.MaxExpanded <= 0 {
l.MaxExpanded = DefaultMaxExpanded
}
if l.MaxSingleFile <= 0 {
l.MaxSingleFile = DefaultMaxSingleFile
}
return l
}
func ExtractZip(filename, destination string, limits ExtractLimits) error {
limits = limits.withDefaults()
zr, err := zip.OpenReader(filename)
if err != nil {
return err
}
defer zr.Close()
if len(zr.File) > limits.MaxFiles {
return fmt.Errorf("ZIP has too many entries: %d", len(zr.File))
}
if err := os.MkdirAll(destination, 0o700); err != nil {
return err
}
root, err := filepath.Abs(destination)
if err != nil {
return err
}
var total int64
for _, zf := range zr.File {
name, err := cleanArchivePath(zf.Name)
if err != nil {
return err
}
if name == "" {
continue
}
if zf.UncompressedSize64 > uint64(limits.MaxSingleFile) {
return fmt.Errorf("ZIP entry too large: %q", zf.Name)
}
total += int64(zf.UncompressedSize64)
if total > limits.MaxExpanded {
return errors.New("expanded ZIP exceeds safety limit")
}
if !zf.FileInfo().IsDir() && !zf.Mode().IsRegular() {
return fmt.Errorf("unsupported ZIP entry type: %q", zf.Name)
}
target := filepath.Join(root, filepath.FromSlash(name))
absTarget, err := filepath.Abs(target)
if err != nil {
return err
}
if absTarget != root && !strings.HasPrefix(absTarget, root+string(os.PathSeparator)) {
return fmt.Errorf("ZIP path escapes destination: %q", zf.Name)
}
if zf.FileInfo().IsDir() {
if err := os.MkdirAll(absTarget, 0o700); err != nil {
return err
}
continue
}
if err := os.MkdirAll(filepath.Dir(absTarget), 0o700); err != nil {
return err
}
rc, err := zf.Open()
if err != nil {
return err
}
out, err := os.OpenFile(absTarget, os.O_CREATE|os.O_WRONLY|os.O_TRUNC, 0o600)
if err != nil {
rc.Close()
return err
}
_, copyErr := io.Copy(out, io.LimitReader(rc, limits.MaxSingleFile+1))
closeOutErr := out.Close()
closeInErr := rc.Close()
if copyErr != nil {
return copyErr
}
if closeOutErr != nil {
return closeOutErr
}
if closeInErr != nil {
return closeInErr
}
}
return nil
}
// FindImportRoot locates the directory that should be passed to LGPO.exe /g.
// It accepts both a normal GPMC/Backup-GPO archive root and archives wrapped
// in one additional directory by common ZIP tools.
func FindImportRoot(extractedRoot string) (string, error) {
root, err := filepath.Abs(extractedRoot)
if err != nil {
return "", err
}
type candidate struct {
path string
depth int
}
var candidates []candidate
err = filepath.WalkDir(root, func(current string, entry os.DirEntry, walkErr error) error {
if walkErr != nil {
return walkErr
}
rel, err := filepath.Rel(root, current)
if err != nil {
return err
}
depth := 0
if rel != "." {
depth = len(strings.Split(filepath.ToSlash(rel), "/"))
}
if entry.IsDir() && depth > 4 {
return filepath.SkipDir
}
if entry.IsDir() {
return nil
}
if strings.EqualFold(entry.Name(), "manifest.xml") {
candidates = append(candidates, candidate{path: filepath.Dir(current), depth: depth})
return nil
}
if strings.EqualFold(entry.Name(), "backup.xml") {
backupDir := filepath.Dir(current)
candidates = append(candidates, candidate{path: backupDir, depth: depth})
if backupDir != root {
parent := filepath.Dir(backupDir)
if parent == root || strings.HasPrefix(parent, root+string(os.PathSeparator)) {
candidates = append(candidates, candidate{path: parent, depth: depth - 1})
}
}
}
return nil
})
if err != nil {
return "", err
}
if len(candidates) == 0 {
return "", errors.New("no GPO backup root found after extraction")
}
sort.Slice(candidates, func(i, j int) bool {
if candidates[i].depth == candidates[j].depth {
return candidates[i].path < candidates[j].path
}
return candidates[i].depth < candidates[j].depth
})
for _, c := range candidates {
if hasBackupAtOrBelow(c.path) {
return c.path, nil
}
}
return "", errors.New("no usable GPO backup root found")
}
func hasBackupAtOrBelow(dir string) bool {
if _, err := os.Stat(filepath.Join(dir, "backup.xml")); err == nil {
return true
}
entries, err := os.ReadDir(dir)
if err != nil {
return false
}
for _, entry := range entries {
if !entry.IsDir() {
continue
}
if _, err := os.Stat(filepath.Join(dir, entry.Name(), "backup.xml")); err == nil {
return true
}
}
return false
}