diff --git a/.claude/settings.json b/.claude/settings.json new file mode 100644 index 0000000..bcf43f6 --- /dev/null +++ b/.claude/settings.json @@ -0,0 +1,3 @@ +{ + "includeCoAuthoredBy": false +} diff --git a/cmd/policy/pull.go b/cmd/policy/pull.go index de74d83..407975b 100644 --- a/cmd/policy/pull.go +++ b/cmd/policy/pull.go @@ -4,13 +4,14 @@ import "github.com/pkg/errors" type PullCmd struct { Policies []string `arg:"" name:"policy" help:"Policies to pull from the remote registry."` + UntarDir string `name:"untardir" type:"existingdir" help:"Directory to extract the policy bundle to (automatically extracts when set)."` } func (c *PullCmd) Run(g *Globals) error { var errs error for _, policyRef := range c.Policies { - err := g.App.Pull(policyRef) + err := g.App.Pull(policyRef, c.UntarDir) if err != nil { g.App.UI.Problem().WithErr(err).Msgf("Failed to pull policy: %s", policyRef) errs = err diff --git a/pkg/app/extract.go b/pkg/app/extract.go new file mode 100644 index 0000000..d8de055 --- /dev/null +++ b/pkg/app/extract.go @@ -0,0 +1,191 @@ +package app + +import ( + "archive/tar" + "compress/gzip" + "io" + "os" + "path/filepath" + "strings" + + "github.com/opcr-io/policy/oci" + perr "github.com/opcr-io/policy/pkg/errors" + "github.com/opcr-io/policy/pkg/x" +) + +// ExtractPolicyBundle extracts a policy bundle from OCI store to the specified directory. +// +//nolint:gocognit,funlen // Security checks require comprehensive validation logic. +func (c *PolicyApp) ExtractPolicyBundle(ociClient *oci.Oci, ref string, destDir string) error { + // Get reference descriptor + refDescriptor, err := c.getRefDescriptor(ociClient, ref) + if err != nil { + return perr.ErrExtractFailed.WithError(err).WithMessage("failed to get reference descriptor") + } + + // Fetch the tarball from OCI store + reader, err := ociClient.GetStore().Fetch(c.Context, *refDescriptor) + if err != nil { + return perr.ErrExtractFailed.WithError(err).WithMessage("failed to fetch policy bundle") + } + + defer func() { + if closeErr := reader.Close(); closeErr != nil { + c.UI.Problem().WithErr(closeErr).Msg("Failed to close OCI policy reader") + } + }() + + // Create gzip reader + gzReader, err := gzip.NewReader(reader) + if err != nil { + return perr.ErrExtractFailed.WithError(err).WithMessage("failed to create gzip reader") + } + + defer func() { + if closeErr := gzReader.Close(); closeErr != nil { + c.UI.Problem().WithErr(closeErr).Msg("Failed to close gzip reader") + } + }() + + // Get absolute path for security checks + absDestDir, err := filepath.Abs(destDir) + if err != nil { + return perr.ErrExtractFailed.WithError(err).WithMessage("failed to get absolute path") + } + + // Validate that the destination directory exists + stat, err := os.Stat(absDestDir) + if err != nil { + return perr.ErrExtractFailed.WithError(err).WithMessage("destination directory [%s] does not exist", absDestDir) + } + + if !stat.IsDir() { + return perr.ErrExtractFailed.WithMessage("[%s] is not a directory", absDestDir) + } + + // Extract tar archive + tarReader := tar.NewReader(gzReader) + + for { + header, err := tarReader.Next() + if err == io.EOF { + break // End of archive + } + + if err != nil { + return perr.ErrExtractFailed.WithError(err).WithMessage("failed to read tar header") + } + + // Security check: sanitize and validate header.Name before use + // This prevents path traversal attacks (CWE-22) + cleanedName := filepath.Clean(header.Name) + + // Reject absolute paths + if filepath.IsAbs(cleanedName) { + return perr.ErrExtractFailed.WithMessage("unsafe absolute path in archive: %s", header.Name) + } + + // Reject paths that escape the destination directory + if strings.HasPrefix(cleanedName, ".."+string(filepath.Separator)) || cleanedName == ".." { + return perr.ErrExtractFailed.WithMessage("unsafe path traversal detected: %s", header.Name) + } + + // Construct target path with sanitized name + targetPath := filepath.Join(absDestDir, cleanedName) + + // Final safety check: ensure resolved path is within destination + if !isPathSafe(targetPath, absDestDir) { + return perr.ErrExtractFailed.WithMessage("unsafe path detected: %s", header.Name) + } + + switch header.Typeflag { + case tar.TypeDir: + // Create directory + if err := os.MkdirAll(targetPath, x.OwnerReadWriteExecute); err != nil { + return perr.ErrExtractFailed.WithError(err).WithMessage("failed to create directory [%s]", targetPath) + } + + case tar.TypeReg: + // Create parent directory if needed + if err := os.MkdirAll(filepath.Dir(targetPath), x.OwnerReadWriteExecute); err != nil { + return perr.ErrExtractFailed.WithError(err).WithMessage("failed to create parent directory") + } + + // Create and write file + outFile, err := os.Create(targetPath) + if err != nil { + return perr.ErrExtractFailed.WithError(err).WithMessage("failed to create file [%s]", targetPath) + } + + // Copy file content + //nolint:gosec // G110: Controlled tar extraction from trusted OCI registry, not user input. + if _, err := io.Copy(outFile, tarReader); err != nil { + outFile.Close() + return perr.ErrExtractFailed.WithError(err).WithMessage("failed to write file content") + } + + if err := outFile.Close(); err != nil { + c.UI.Problem().WithErr(err).Msgf("Failed to close file [%s]", targetPath) + } + + case tar.TypeSymlink: + // Handle symlinks carefully - ensure they don't point outside destDir + // Security: sanitize linkname to prevent symlink-based attacks (CWE-59) + rawLinkTarget := header.Linkname + cleanedLinkTarget := filepath.Clean(rawLinkTarget) + + // Reject absolute symlink targets + if filepath.IsAbs(cleanedLinkTarget) { + return perr.ErrExtractFailed.WithMessage("unsafe absolute symlink target: %s -> %s", header.Name, rawLinkTarget) + } + + // Resolve symlink target relative to the file's directory + symlinkDir := filepath.Dir(targetPath) + resolvedTarget := filepath.Join(symlinkDir, cleanedLinkTarget) + + // Security check: ensure symlink target resolves within destination + if !isPathSafe(resolvedTarget, absDestDir) { + return perr.ErrExtractFailed.WithMessage("unsafe symlink detected: %s -> %s", header.Name, rawLinkTarget) + } + + // Create symlink with sanitized target + if err := os.Symlink(cleanedLinkTarget, targetPath); err != nil { + c.UI.Problem().WithErr(err).Msgf("Failed to create symlink [%s]", targetPath) + } + + default: + c.UI.Problem().Msgf("Skipping unknown file type %v for [%s]", header.Typeflag, header.Name) + } + } + + return nil +} + +// isPathSafe checks if the target path is within the allowed directory. +// This prevents path traversal attacks. +func isPathSafe(targetPath, allowedDir string) bool { + // Clean and normalize paths + cleanTarget := filepath.Clean(targetPath) + cleanAllowed := filepath.Clean(allowedDir) + + // Get absolute paths + absTarget, err := filepath.Abs(cleanTarget) + if err != nil { + return false + } + + absAllowed, err := filepath.Abs(cleanAllowed) + if err != nil { + return false + } + + // Check if target is within allowed directory + // Use filepath.Rel to check if target is a subdirectory of allowed + rel, err := filepath.Rel(absAllowed, absTarget) + if err != nil { + return false + } + + // If rel starts with "..", it's outside the allowed directory + return !strings.HasPrefix(rel, ".."+string(filepath.Separator)) && rel != ".." +} diff --git a/pkg/app/pull.go b/pkg/app/pull.go index e5cdd34..534db26 100644 --- a/pkg/app/pull.go +++ b/pkg/app/pull.go @@ -9,7 +9,7 @@ import ( "github.com/pkg/errors" ) -func (c *PolicyApp) Pull(userRef string) error { +func (c *PolicyApp) Pull(userRef string, untarDir string) error { defer c.Cancel() ref, err := parser.CalculatePolicyRef(userRef, c.Configuration.DefaultDomain) @@ -35,6 +35,22 @@ func (c *PolicyApp) Pull(userRef string) error { WithStringValue("digest", digest.String()). Msgf("Pulled ref [%s].", ref) + // If untarDir is set, extract the policy bundle + if untarDir != "" { + c.UI.Normal(). + WithStringValue("directory", untarDir). + Msg("Extracting policy bundle.") + + err = c.ExtractPolicyBundle(ociClient, ref, untarDir) + if err != nil { + return errors.Wrap(err, "failed to extract policy bundle") + } + + c.UI.Normal(). + WithStringValue("directory", untarDir). + Msgf("Extracted policy bundle to directory.") + } + return nil } diff --git a/pkg/app/repl.go b/pkg/app/repl.go index b00ca63..8ca6a13 100644 --- a/pkg/app/repl.go +++ b/pkg/app/repl.go @@ -35,7 +35,7 @@ func (c *PolicyApp) Repl(ref string, maxErrors int) error { descriptor, ok := existingRefs[existingRefParsed] if !ok { - err := c.Pull(ref) + err := c.Pull(ref, "") if err != nil { return err } diff --git a/pkg/errors/errors.go b/pkg/errors/errors.go index eb23eb6..f420b08 100644 --- a/pkg/errors/errors.go +++ b/pkg/errors/errors.go @@ -15,6 +15,7 @@ var ( ErrReplFailed = NewPolicyError("repl failed") ErrTagFailed = NewPolicyError("tag failed") ErrTemplateFailed = NewPolicyError("template failed") + ErrExtractFailed = NewPolicyError("extract failed") ) type PolicyCLIError struct {