@@ -0,0 +1,354 @@
|
||||
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
|
||||
}
|
||||
Reference in New Issue
Block a user