291 lines
9.5 KiB
Go
291 lines
9.5 KiB
Go
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()
|
|
}
|