355 lines
8.4 KiB
Go
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
|
|
}
|