post inicial
This commit is contained in:
@@ -0,0 +1,290 @@
|
||||
package tools
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"context"
|
||||
"crypto/sha256"
|
||||
"crypto/sha512"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"hash"
|
||||
"io"
|
||||
"net/http"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"regexp"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"sysagent/internal/fsutil"
|
||||
"sysagent/internal/policy"
|
||||
)
|
||||
|
||||
const (
|
||||
downloadDefaultTimeout = 10 * time.Minute
|
||||
downloadMaxTimeout = 2 * time.Hour
|
||||
)
|
||||
|
||||
// downloadTool descarga archivos por HTTP(S) con límite de tamaño y
|
||||
// verificación de checksum (§41, §68).
|
||||
type downloadTool struct{}
|
||||
|
||||
type downloadArgs struct {
|
||||
URL string `json:"url"`
|
||||
Dest string `json:"dest"`
|
||||
SHA256 string `json:"sha256"`
|
||||
SHA512 string `json:"sha512"`
|
||||
ChecksumURL string `json:"checksum_url"`
|
||||
Overwrite bool `json:"overwrite"`
|
||||
MaxBytes int64 `json:"max_bytes"`
|
||||
TimeoutSeconds int `json:"timeout_seconds"`
|
||||
Reason string `json:"reason"`
|
||||
}
|
||||
|
||||
func (downloadTool) Name() string { return "download" }
|
||||
func (downloadTool) Description() string {
|
||||
return "Download a file over HTTP(S) to disk (default .agent/downloads/). Verifies sha256/sha512 when given, or looks the file up in a checksum file published by the vendor (checksum_url, e.g. SHA256SUMS). Always verify when the vendor publishes a checksum. Requires ALLOW_NETWORK and ALLOW_DOWNLOADS."
|
||||
}
|
||||
func (downloadTool) Schema() map[string]any {
|
||||
return obj([]string{"url"}, map[string]any{
|
||||
"url": str("http(s) URL of the file"),
|
||||
"dest": str("destination file or directory (default PROJECT_ROOT/.agent/downloads/)"),
|
||||
"sha256": str("expected SHA-256 hex digest"),
|
||||
"sha512": str("expected SHA-512 hex digest"),
|
||||
"checksum_url": str("URL of a checksum list (SHA256SUMS, *.sha256, BSD format) that contains this file"),
|
||||
"overwrite": boolean("replace dest if it exists"),
|
||||
"max_bytes": num("size limit in bytes (default and maximum: MAX_DOWNLOAD_SIZE of the policy)"),
|
||||
"timeout_seconds": num("timeout (default 600, max 7200)"),
|
||||
"reason": str("why (audit log)"),
|
||||
})
|
||||
}
|
||||
|
||||
func (downloadTool) Run(ctx context.Context, env *Env, raw json.RawMessage) Result {
|
||||
var a downloadArgs
|
||||
if err := decode(raw, &a); err != nil {
|
||||
return fail("download", err)
|
||||
}
|
||||
label := "download " + a.URL
|
||||
if err := env.Guard.CheckCapability("download", policy.CapNetwork, policy.CapDownloads); err != nil {
|
||||
return fail(label, err)
|
||||
}
|
||||
if _, err := validateURL(a.URL); err != nil {
|
||||
return fail(label, err)
|
||||
}
|
||||
if a.SHA256 != "" && a.SHA512 != "" {
|
||||
return fail(label, errors.New("give sha256 or sha512, not both"))
|
||||
}
|
||||
algo, want := "", ""
|
||||
switch {
|
||||
case a.SHA256 != "":
|
||||
algo, want = "sha256", strings.ToLower(strings.TrimSpace(a.SHA256))
|
||||
case a.SHA512 != "":
|
||||
algo, want = "sha512", strings.ToLower(strings.TrimSpace(a.SHA512))
|
||||
}
|
||||
if want != "" && !validDigest(algo, want) {
|
||||
return fail(label, fmt.Errorf("invalid %s digest %q", algo, want))
|
||||
}
|
||||
|
||||
limit := env.Policy.MaxDownloadSize
|
||||
if a.MaxBytes > 0 && a.MaxBytes < limit {
|
||||
limit = a.MaxBytes
|
||||
}
|
||||
timeout := downloadDefaultTimeout
|
||||
if a.TimeoutSeconds > 0 {
|
||||
timeout = min(time.Duration(a.TimeoutSeconds)*time.Second, downloadMaxTimeout)
|
||||
}
|
||||
|
||||
// Destino: archivo concreto o directorio (se usa el nombre de la URL).
|
||||
dest := a.Dest
|
||||
if dest == "" {
|
||||
dest = filepath.Join(env.Root, ".agent", "downloads")
|
||||
}
|
||||
dest = env.Guard.Resolve(dest, "")
|
||||
if st, err := os.Stat(dest); err == nil && st.IsDir() || strings.HasSuffix(a.Dest, "/") || a.Dest == "" {
|
||||
dest = filepath.Join(dest, fileNameFromURL(a.URL))
|
||||
}
|
||||
label = fmt.Sprintf("download %s -> %s", a.URL, dest)
|
||||
|
||||
fc := &fileChange{env: env, path: dest, reason: a.Reason}
|
||||
if err := env.Guard.CheckPath(policy.OpWrite, dest, ""); err != nil {
|
||||
return fail(label, err)
|
||||
}
|
||||
if _, err := os.Stat(dest); err == nil && !a.Overwrite {
|
||||
return fail(label, fmt.Errorf("%s exists; set overwrite=true to replace it", dest))
|
||||
}
|
||||
if env.DryRun {
|
||||
return dryRun(fmt.Sprintf("download %s to %s (limit %d bytes, checksum: %s)", a.URL, dest, limit, orDefault(algo, "none")))
|
||||
}
|
||||
|
||||
// Checksum publicado por el proveedor.
|
||||
if a.ChecksumURL != "" {
|
||||
body, resp, _, err := httpGet(ctx, env, a.ChecksumURL, webTimeout, 1<<20, "text/plain")
|
||||
if err != nil {
|
||||
return fail(label, fmt.Errorf("failed to fetch checksum file: %w", err))
|
||||
}
|
||||
if resp.StatusCode != 200 {
|
||||
return fail(label, fmt.Errorf("checksum file returned HTTP %d", resp.StatusCode))
|
||||
}
|
||||
fAlgo, fWant, ok := findChecksum(string(body), fileNameFromURL(a.URL))
|
||||
if !ok {
|
||||
return fail(label, fmt.Errorf("%s not listed in %s", fileNameFromURL(a.URL), a.ChecksumURL))
|
||||
}
|
||||
if want != "" && (fAlgo != algo || fWant != want) {
|
||||
return fail(label, errors.New("the given digest does not match the one in checksum_url"))
|
||||
}
|
||||
algo, want = fAlgo, fWant
|
||||
}
|
||||
|
||||
if err := os.MkdirAll(filepath.Dir(dest), 0o755); err != nil {
|
||||
return fail(label, err)
|
||||
}
|
||||
if err := fc.backupIfNeeded(); err != nil {
|
||||
return fail(label, err)
|
||||
}
|
||||
|
||||
size, sums, ctype, err := fetchToFile(ctx, env, a.URL, dest, limit, timeout, algo, want)
|
||||
if err != nil {
|
||||
return fail(label, err)
|
||||
}
|
||||
|
||||
verified := "NOT VERIFIED (no checksum provided; prefer a vendor-published checksum)"
|
||||
if want != "" {
|
||||
verified = "verified (" + algo + ")"
|
||||
}
|
||||
detail := fmt.Sprintf("saved: %s\nsize: %d bytes\ncontent_type: %s\nsha256: %s\n", dest, size, ctype, sums["sha256"])
|
||||
if s, ok := sums["sha512"]; ok {
|
||||
detail += "sha512: " + s + "\n"
|
||||
}
|
||||
detail += "checksum: " + verified
|
||||
res := fc.done(fmt.Sprintf("%s (%d bytes, %s)", label, size, strings.SplitN(verified, " ", 2)[0]), detail)
|
||||
res.Audit = append(res.Audit, "url", a.URL, "sha256", sums["sha256"], "checksum", strings.SplitN(verified, " ", 2)[0])
|
||||
return res
|
||||
}
|
||||
|
||||
// fetchToFile descarga a un temporal en el directorio destino calculando los
|
||||
// hashes mientras se escribe. Solo si el tamaño y el checksum son correctos
|
||||
// se renombra al destino: una descarga fallida o manipulada nunca queda en
|
||||
// la ruta final.
|
||||
func fetchToFile(ctx context.Context, env *Env, rawURL, dest string, limit int64, timeout time.Duration, algo, want string) (int64, map[string]string, string, error) {
|
||||
ctx, cancel := context.WithTimeout(ctx, timeout)
|
||||
defer cancel()
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodGet, rawURL, nil)
|
||||
if err != nil {
|
||||
return 0, nil, "", err
|
||||
}
|
||||
req.Header.Set("User-Agent", env.userAgent())
|
||||
resp, err := env.httpClient().Do(req)
|
||||
if err != nil {
|
||||
return 0, nil, "", fmt.Errorf("GET %s: %w", rawURL, err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
if resp.StatusCode != 200 {
|
||||
return 0, nil, "", fmt.Errorf("GET %s: HTTP %d", rawURL, resp.StatusCode)
|
||||
}
|
||||
if resp.ContentLength > limit {
|
||||
return 0, nil, "", fmt.Errorf("file is %d bytes, above the limit of %d (MAX_DOWNLOAD_SIZE)", resp.ContentLength, limit)
|
||||
}
|
||||
|
||||
tmp, err := os.CreateTemp(filepath.Dir(dest), "."+filepath.Base(dest)+".sysagent-dl-*")
|
||||
if err != nil {
|
||||
return 0, nil, "", fmt.Errorf("failed to create temporary file: %w", err)
|
||||
}
|
||||
tmpName := tmp.Name()
|
||||
ok := false
|
||||
defer func() {
|
||||
if !ok {
|
||||
tmp.Close()
|
||||
os.Remove(tmpName)
|
||||
}
|
||||
}()
|
||||
|
||||
h256, h512 := sha256.New(), sha512.New()
|
||||
writers := []io.Writer{tmp, h256}
|
||||
if algo == "sha512" {
|
||||
writers = append(writers, h512)
|
||||
}
|
||||
n, err := io.Copy(io.MultiWriter(writers...), io.LimitReader(resp.Body, limit+1))
|
||||
if err != nil {
|
||||
return 0, nil, "", fmt.Errorf("download interrupted after %d bytes: %w", n, err)
|
||||
}
|
||||
if n > limit {
|
||||
return 0, nil, "", fmt.Errorf("download exceeded the limit of %d bytes (MAX_DOWNLOAD_SIZE); aborted", limit)
|
||||
}
|
||||
if err := tmp.Sync(); err != nil {
|
||||
return 0, nil, "", err
|
||||
}
|
||||
sums := map[string]string{"sha256": hex.EncodeToString(h256.Sum(nil))}
|
||||
if algo == "sha512" {
|
||||
sums["sha512"] = hex.EncodeToString(h512.Sum(nil))
|
||||
}
|
||||
if want != "" && sums[algo] != want {
|
||||
return 0, nil, "", fmt.Errorf("CHECKSUM MISMATCH: expected %s %s, got %s; the file was discarded", algo, want, sums[algo])
|
||||
}
|
||||
if err := tmp.Close(); err != nil {
|
||||
return 0, nil, "", err
|
||||
}
|
||||
if err := os.Chmod(tmpName, 0o644); err != nil {
|
||||
return 0, nil, "", err
|
||||
}
|
||||
if err := os.Rename(tmpName, dest); err != nil {
|
||||
return 0, nil, "", fmt.Errorf("failed to move download into place: %w", err)
|
||||
}
|
||||
ok = true
|
||||
_ = fsutil.SyncDir(filepath.Dir(dest))
|
||||
return n, sums, resp.Header.Get("Content-Type"), nil
|
||||
}
|
||||
|
||||
var (
|
||||
gnuSumRe = regexp.MustCompile(`^([0-9a-fA-F]{64}|[0-9a-fA-F]{128})\s+\*?(.+)$`)
|
||||
bsdSumRe = regexp.MustCompile(`^(SHA256|SHA512)\s*\((.+)\)\s*=\s*([0-9a-fA-F]+)$`)
|
||||
)
|
||||
|
||||
// findChecksum busca name en un archivo de checksums en formato GNU
|
||||
// ("<hex> nombre", "<hex> *nombre") o BSD ("SHA256 (nombre) = <hex>").
|
||||
// Un archivo con un único hash sin nombre (p.ej. "x.tar.gz.sha256") también vale.
|
||||
func findChecksum(content, name string) (algo, digest string, ok bool) {
|
||||
var lone []string
|
||||
sc := bufio.NewScanner(strings.NewReader(content))
|
||||
for sc.Scan() {
|
||||
line := strings.TrimSpace(sc.Text())
|
||||
if m := gnuSumRe.FindStringSubmatch(line); m != nil {
|
||||
if filepath.Base(strings.TrimSpace(m[2])) == name {
|
||||
return algoFor(m[1]), strings.ToLower(m[1]), true
|
||||
}
|
||||
continue
|
||||
}
|
||||
if m := bsdSumRe.FindStringSubmatch(line); m != nil {
|
||||
if filepath.Base(m[2]) == name {
|
||||
return strings.ToLower(m[1]), strings.ToLower(m[3]), true
|
||||
}
|
||||
continue
|
||||
}
|
||||
if len(line) == 64 || len(line) == 128 {
|
||||
if _, err := hex.DecodeString(line); err == nil {
|
||||
lone = append(lone, line)
|
||||
}
|
||||
}
|
||||
}
|
||||
if len(lone) == 1 {
|
||||
return algoFor(lone[0]), strings.ToLower(lone[0]), true
|
||||
}
|
||||
return "", "", false
|
||||
}
|
||||
|
||||
func algoFor(hexDigest string) string {
|
||||
if len(hexDigest) == 128 {
|
||||
return "sha512"
|
||||
}
|
||||
return "sha256"
|
||||
}
|
||||
|
||||
func validDigest(algo, d string) bool {
|
||||
var h hash.Hash = sha256.New()
|
||||
if algo == "sha512" {
|
||||
h = sha512.New()
|
||||
}
|
||||
b, err := hex.DecodeString(d)
|
||||
return err == nil && len(b) == h.Size()
|
||||
}
|
||||
Reference in New Issue
Block a user