484 lines
14 KiB
Go
484 lines
14 KiB
Go
package main
|
|
|
|
import (
|
|
"bufio"
|
|
"context"
|
|
"encoding/json"
|
|
"errors"
|
|
"fmt"
|
|
"io"
|
|
"os"
|
|
"os/exec"
|
|
"path/filepath"
|
|
"regexp"
|
|
"sort"
|
|
"strings"
|
|
"sync"
|
|
"time"
|
|
|
|
"git.eidonix.com/eidonix/cletus-plugins/sdk/manifest"
|
|
)
|
|
|
|
// report collects the outcome of each check.
|
|
type report struct {
|
|
failures, warnings int
|
|
}
|
|
|
|
func (r *report) ok(name string) { fmt.Printf("ok %s\n", name) }
|
|
|
|
func (r *report) fail(name, detail string) {
|
|
r.failures++
|
|
fmt.Printf("FAIL %s\n%s", name, indent(detail))
|
|
}
|
|
|
|
func (r *report) warn(name, detail string) {
|
|
r.warnings++
|
|
fmt.Printf("warn %s\n%s", name, indent(detail))
|
|
}
|
|
|
|
func indent(s string) string {
|
|
s = strings.TrimRight(s, "\n")
|
|
if s == "" {
|
|
return ""
|
|
}
|
|
return " " + strings.ReplaceAll(s, "\n", "\n ") + "\n"
|
|
}
|
|
|
|
func runCheck(args []string) error {
|
|
if len(args) > 0 {
|
|
return errors.New("check takes no arguments; run it in the plugin's folder")
|
|
}
|
|
r := &report{}
|
|
plugin, err := readPlugin(".")
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
if errs := plugin.Validate(); len(errs) > 0 {
|
|
var b strings.Builder
|
|
for _, e := range errs {
|
|
fmt.Fprintln(&b, e)
|
|
}
|
|
r.fail("plugin.json", b.String())
|
|
} else {
|
|
r.ok("plugin.json")
|
|
}
|
|
checkFiles(r, plugin)
|
|
own := ownGoFiles()
|
|
checkSources(r, own)
|
|
checkGoMod(r)
|
|
if out, err := goRun(".", goEnv("", ""), "vet", "./..."); err != nil {
|
|
r.fail("go vet", out)
|
|
} else {
|
|
r.ok("go vet")
|
|
}
|
|
checkImports(r, plugin)
|
|
checkDependencies(r)
|
|
for _, target := range [][2]string{{"darwin", "arm64"}, {"windows", "amd64"}} {
|
|
name := "build " + target[0] + "/" + target[1]
|
|
if out, err := goRun(".", goEnv(target[0], target[1]), "build", "-trimpath", "-o", os.DevNull, "./cmd/worker"); err != nil {
|
|
r.fail(name, out)
|
|
} else {
|
|
r.ok(name)
|
|
}
|
|
}
|
|
if out, err := goRun(".", goEnv("", ""), "test", "-count=1", "./..."); err != nil {
|
|
r.fail("go test", out)
|
|
} else {
|
|
r.ok("go test")
|
|
}
|
|
optionalTool(r, "staticcheck", "./...")
|
|
optionalTool(r, "govulncheck", "./...")
|
|
checkRuntime(r, plugin)
|
|
|
|
fmt.Println()
|
|
switch {
|
|
case r.failures > 0:
|
|
return fmt.Errorf("%d check(s) failed, %d warning(s)", r.failures, r.warnings)
|
|
case r.warnings > 0:
|
|
fmt.Printf("All checks passed with %d warning(s). Explain each in docs/plan.md; reviewers read them.\n", r.warnings)
|
|
default:
|
|
fmt.Println("All checks passed.")
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func checkFiles(r *report, plugin *manifest.Plugin) {
|
|
var problems []string
|
|
changelog, err := os.ReadFile("CHANGELOG.md")
|
|
if err != nil {
|
|
problems = append(problems, "CHANGELOG.md is missing")
|
|
} else if !regexp.MustCompile(`(?m)^#+\s*v?` + regexp.QuoteMeta(plugin.Version) + `\b`).Match(changelog) {
|
|
problems = append(problems, "CHANGELOG.md has no heading for version "+plugin.Version)
|
|
}
|
|
for _, path := range []string{"docs/plan.md", "docs/guide.md", "go.mod", "cmd/worker"} {
|
|
if _, err := os.Stat(path); err != nil {
|
|
problems = append(problems, path+" is missing")
|
|
}
|
|
}
|
|
if plan, err := os.ReadFile("docs/plan.md"); err == nil {
|
|
for _, perm := range plugin.Permissions {
|
|
if !strings.Contains(string(plan), perm) {
|
|
problems = append(problems, "docs/plan.md doesn't explain the "+perm+" permission")
|
|
}
|
|
}
|
|
}
|
|
if len(problems) > 0 {
|
|
r.fail("repository files", strings.Join(problems, "\n"))
|
|
} else {
|
|
r.ok("repository files")
|
|
}
|
|
}
|
|
|
|
// ownGoFiles lists the plugin's own Go files (not vendor/, not hidden folders).
|
|
func ownGoFiles() []string {
|
|
var files []string
|
|
_ = filepath.WalkDir(".", func(path string, d os.DirEntry, err error) error {
|
|
if err != nil {
|
|
return nil
|
|
}
|
|
if d.IsDir() && path != "." && (d.Name() == "vendor" || strings.HasPrefix(d.Name(), ".") || d.Name() == "node_modules") {
|
|
return filepath.SkipDir
|
|
}
|
|
if !d.IsDir() && strings.HasSuffix(path, ".go") {
|
|
files = append(files, path)
|
|
}
|
|
return nil
|
|
})
|
|
return files
|
|
}
|
|
|
|
var (
|
|
secretRE = regexp.MustCompile(`-----BEGIN [A-Z ]*PRIVATE KEY-----|AKIA[0-9A-Z]{16}|\bsk-[A-Za-z0-9_-]{20,}|\bghp_[A-Za-z0-9]{30,}|\bxox[abp]-[A-Za-z0-9-]{10,}`)
|
|
fileOpRE = regexp.MustCompile(`\bos\.(Open|OpenFile|ReadFile|WriteFile|Create|Remove|RemoveAll|ReadDir|Rename|Mkdir|MkdirAll)\(`)
|
|
linknameRE = regexp.MustCompile(`//go:linkname`)
|
|
)
|
|
|
|
func checkSources(r *report, files []string) {
|
|
var gofmtBad, cgo, secrets, linkname, fileOps []string
|
|
if len(files) > 0 {
|
|
out, _ := exec.Command("gofmt", append([]string{"-l"}, files...)...).Output()
|
|
for _, line := range strings.Split(strings.TrimSpace(string(out)), "\n") {
|
|
if line != "" {
|
|
gofmtBad = append(gofmtBad, line)
|
|
}
|
|
}
|
|
}
|
|
_ = filepath.WalkDir(".", func(path string, d os.DirEntry, err error) error {
|
|
if err != nil || d.IsDir() {
|
|
if d != nil && d.IsDir() && path != "." && strings.HasPrefix(d.Name(), ".") {
|
|
return filepath.SkipDir
|
|
}
|
|
return nil
|
|
}
|
|
info, err := d.Info()
|
|
if err != nil || info.Size() > 4<<20 {
|
|
return nil
|
|
}
|
|
data, err := os.ReadFile(path)
|
|
if err != nil {
|
|
return nil
|
|
}
|
|
text := string(data)
|
|
vendored := strings.HasPrefix(filepath.ToSlash(path), "vendor/")
|
|
if strings.HasSuffix(path, ".go") && regexp.MustCompile(`(?m)^import\s+"C"`).MatchString(text) {
|
|
cgo = append(cgo, path)
|
|
}
|
|
if !vendored && secretRE.MatchString(text) {
|
|
secrets = append(secrets, path)
|
|
}
|
|
if strings.HasSuffix(path, ".go") && !vendored {
|
|
if linknameRE.MatchString(text) {
|
|
linkname = append(linkname, path)
|
|
}
|
|
if !strings.HasSuffix(path, "_test.go") && fileOpRE.MatchString(text) {
|
|
fileOps = append(fileOps, path)
|
|
}
|
|
}
|
|
return nil
|
|
})
|
|
if len(gofmtBad) > 0 {
|
|
r.fail("gofmt", "not formatted (run gofmt -w):\n"+strings.Join(gofmtBad, "\n"))
|
|
} else {
|
|
r.ok("gofmt")
|
|
}
|
|
if len(cgo) > 0 {
|
|
r.fail("no cgo", "these files use cgo, which Cletus plugins can't:\n"+strings.Join(cgo, "\n"))
|
|
} else {
|
|
r.ok("no cgo")
|
|
}
|
|
if len(secrets) > 0 {
|
|
r.fail("no secrets", "these files look like they hold a key or token:\n"+strings.Join(secrets, "\n"))
|
|
} else {
|
|
r.ok("no secrets")
|
|
}
|
|
if len(linkname) > 0 {
|
|
r.fail("no go:linkname", strings.Join(linkname, "\n"))
|
|
}
|
|
if len(fileOps) > 0 {
|
|
r.warn("direct file access", "these files open, write or delete files directly. Each path must come from\nhost.files.resolve, a dialog, or pluginsdk.DataDir(); anything else needs files.computer:\n"+strings.Join(fileOps, "\n"))
|
|
}
|
|
}
|
|
|
|
func checkGoMod(r *report) {
|
|
data, err := os.ReadFile("go.mod")
|
|
if err != nil {
|
|
return
|
|
}
|
|
text := string(data)
|
|
var problems []string
|
|
if regexp.MustCompile(`(?m)^\s*replace\b|=>`).MatchString(text) {
|
|
problems = append(problems, "go.mod has replace directives; the store builds only from go.mod and vendor/")
|
|
}
|
|
if strings.Contains(text, "require") {
|
|
if _, err := os.Stat("vendor/modules.txt"); err != nil {
|
|
problems = append(problems, "vendor/ is missing; run go mod vendor and commit it")
|
|
} else if out, err := goRun(".", goEnv("", ""), "build", "-mod=vendor", "-o", os.DevNull, "./cmd/worker"); err != nil {
|
|
problems = append(problems, "vendor/ doesn't match go.mod; run go mod vendor:\n"+out)
|
|
}
|
|
}
|
|
if len(problems) > 0 {
|
|
r.fail("go.mod and vendor", strings.Join(problems, "\n"))
|
|
} else {
|
|
r.ok("go.mod and vendor")
|
|
}
|
|
}
|
|
|
|
type goPackage struct {
|
|
ImportPath string
|
|
Imports []string
|
|
Standard bool
|
|
Module *struct{ Path string }
|
|
}
|
|
|
|
func listPackages(args ...string) ([]goPackage, error) {
|
|
cmd := exec.Command("go", append([]string{"list", "-json"}, args...)...)
|
|
cmd.Env = goEnv("", "")
|
|
out, err := cmd.Output()
|
|
if err != nil {
|
|
var exit *exec.ExitError
|
|
if errors.As(err, &exit) {
|
|
return nil, fmt.Errorf("%s", exit.Stderr)
|
|
}
|
|
return nil, err
|
|
}
|
|
var pkgs []goPackage
|
|
dec := json.NewDecoder(strings.NewReader(string(out)))
|
|
for {
|
|
var p goPackage
|
|
if err := dec.Decode(&p); err == io.EOF {
|
|
break
|
|
} else if err != nil {
|
|
return nil, err
|
|
}
|
|
pkgs = append(pkgs, p)
|
|
}
|
|
return pkgs, nil
|
|
}
|
|
|
|
var netPackages = map[string]bool{"net": true, "net/http": true, "net/rpc": true, "net/smtp": true, "crypto/tls": true, "net/http/httputil": true}
|
|
|
|
func checkImports(r *report, plugin *manifest.Plugin) {
|
|
own, err := listPackages("./...")
|
|
if err != nil {
|
|
r.fail("imports", err.Error())
|
|
return
|
|
}
|
|
var failures, notes []string
|
|
for _, p := range own {
|
|
for _, imp := range p.Imports {
|
|
switch {
|
|
case imp == "plugin":
|
|
failures = append(failures, p.ImportPath+" imports plugin (loading code at run time is not allowed)")
|
|
case imp == "os/exec" && !plugin.Has("processes"):
|
|
failures = append(failures, p.ImportPath+" imports os/exec but the plugin doesn't declare processes")
|
|
case netPackages[imp] && !plugin.HasNet():
|
|
failures = append(failures, p.ImportPath+" imports "+imp+" but the plugin declares no net permission")
|
|
case imp == "unsafe" || imp == "reflect" || imp == "syscall" || strings.HasPrefix(imp, "golang.org/x/sys/"):
|
|
notes = append(notes, p.ImportPath+" imports "+imp)
|
|
}
|
|
}
|
|
}
|
|
if len(failures) > 0 {
|
|
r.fail("imports match permissions", strings.Join(failures, "\n"))
|
|
} else {
|
|
r.ok("imports match permissions")
|
|
}
|
|
if len(notes) > 0 {
|
|
r.warn("low-level imports", "explain each in docs/plan.md:\n"+strings.Join(notes, "\n"))
|
|
}
|
|
deps, err := listPackages("-deps", "./cmd/worker")
|
|
if err != nil {
|
|
return
|
|
}
|
|
var depNotes []string
|
|
for _, p := range deps {
|
|
if p.Standard || p.Module == nil || isOwnModule(p.Module.Path) {
|
|
continue
|
|
}
|
|
for _, imp := range p.Imports {
|
|
if imp == "os/exec" && !plugin.Has("processes") || netPackages[imp] && !plugin.HasNet() {
|
|
depNotes = append(depNotes, p.ImportPath+" imports "+imp)
|
|
}
|
|
}
|
|
}
|
|
if len(depNotes) > 0 {
|
|
sort.Strings(depNotes)
|
|
r.warn("dependencies that can start programs or connect", "your plugin doesn't declare this; make sure these code paths are never used:\n"+strings.Join(depNotes, "\n"))
|
|
}
|
|
}
|
|
|
|
func isOwnModule(path string) bool {
|
|
data, err := os.ReadFile("go.mod")
|
|
if err != nil {
|
|
return false
|
|
}
|
|
m := regexp.MustCompile(`(?m)^module\s+(\S+)`).FindStringSubmatch(string(data))
|
|
return len(m) == 2 && m[1] == path
|
|
}
|
|
|
|
func checkDependencies(r *report) {
|
|
f, err := os.Open("vendor/modules.txt")
|
|
if err != nil {
|
|
return
|
|
}
|
|
defer f.Close()
|
|
var missing []string
|
|
sc := bufio.NewScanner(f)
|
|
for sc.Scan() {
|
|
fields := strings.Fields(sc.Text())
|
|
if len(fields) < 3 || fields[0] != "#" || fields[2] == "=>" {
|
|
continue
|
|
}
|
|
dir := filepath.Join("vendor", filepath.FromSlash(fields[1]))
|
|
if _, err := os.Stat(dir); err != nil {
|
|
continue // a module with no packages in the build
|
|
}
|
|
if !hasLicense(dir) {
|
|
missing = append(missing, fields[1]+" "+fields[2])
|
|
}
|
|
}
|
|
if len(missing) > 0 {
|
|
r.warn("dependency licences", "no licence file found; the store must be able to confirm each licence allows distribution:\n"+strings.Join(missing, "\n"))
|
|
} else {
|
|
r.ok("dependency licences present")
|
|
}
|
|
}
|
|
|
|
func hasLicense(dir string) bool {
|
|
entries, err := os.ReadDir(dir)
|
|
if err != nil {
|
|
return false
|
|
}
|
|
for _, e := range entries {
|
|
name := strings.ToUpper(e.Name())
|
|
if strings.HasPrefix(name, "LICENSE") || strings.HasPrefix(name, "LICENCE") || strings.HasPrefix(name, "COPYING") {
|
|
return true
|
|
}
|
|
}
|
|
return false
|
|
}
|
|
|
|
func optionalTool(r *report, tool string, args ...string) {
|
|
if _, err := exec.LookPath(tool); err != nil {
|
|
fmt.Printf("skip %s (not installed; the store runs it)\n", tool)
|
|
return
|
|
}
|
|
cmd := exec.Command(tool, args...)
|
|
cmd.Env = goEnv("", "")
|
|
out, err := cmd.CombinedOutput()
|
|
if err != nil {
|
|
r.fail(tool, string(out))
|
|
} else {
|
|
r.ok(tool)
|
|
}
|
|
}
|
|
|
|
// checkRuntime starts the worker and compares its tools with plugin.json.
|
|
func checkRuntime(r *report, plugin *manifest.Plugin) {
|
|
wd, _ := os.Getwd()
|
|
bin, cleanup, err := buildWorker(wd)
|
|
if err != nil {
|
|
r.fail("worker starts", err.Error())
|
|
return
|
|
}
|
|
defer cleanup()
|
|
tmp, err := os.MkdirTemp("", "cletus-plugin-check-")
|
|
if err != nil {
|
|
r.fail("worker starts", err.Error())
|
|
return
|
|
}
|
|
defer os.RemoveAll(tmp)
|
|
log := &syncBuffer{}
|
|
host := &testHost{plugin: plugin, filesDir: tmp, dataDir: filepath.Join(tmp, "data"), capabilities: map[string]bool{}, log: log}
|
|
if err := host.start(bin); err != nil {
|
|
r.fail("worker starts", err.Error()+"\n"+log.String())
|
|
return
|
|
}
|
|
defer host.stop()
|
|
r.ok("worker starts and answers the handshake")
|
|
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
|
|
defer cancel()
|
|
var tools []struct {
|
|
Def struct {
|
|
Function struct {
|
|
Name string `json:"name"`
|
|
Description string `json:"description"`
|
|
} `json:"function"`
|
|
} `json:"def"`
|
|
Mutating bool `json:"mutating"`
|
|
RequiresApproval bool `json:"requires_approval"`
|
|
}
|
|
if err := host.call(ctx, "tools.list", nil, &tools); err != nil {
|
|
r.fail("tools match plugin.json", "tools.list: "+err.Error())
|
|
return
|
|
}
|
|
declared := map[string]manifest.Tool{}
|
|
for _, t := range plugin.Tools {
|
|
declared[t.Name] = t
|
|
}
|
|
var problems []string
|
|
for _, t := range tools {
|
|
name := t.Def.Function.Name
|
|
d, ok := declared[name]
|
|
switch {
|
|
case !ok:
|
|
problems = append(problems, "the worker offers "+name+", which plugin.json doesn't list")
|
|
case d.Mutating != t.Mutating || d.RequiresApproval != t.RequiresApproval:
|
|
problems = append(problems, name+": mutating or requires_approval differs between plugin.json and the worker")
|
|
}
|
|
if strings.TrimSpace(t.Def.Function.Description) == "" {
|
|
problems = append(problems, name+" has no description")
|
|
}
|
|
delete(declared, name)
|
|
}
|
|
for name := range declared {
|
|
problems = append(problems, "plugin.json lists "+name+", which the worker doesn't offer")
|
|
}
|
|
if len(problems) > 0 {
|
|
sort.Strings(problems)
|
|
r.fail("tools match plugin.json", strings.Join(problems, "\n"))
|
|
} else {
|
|
r.ok("tools match plugin.json")
|
|
}
|
|
}
|
|
|
|
// syncBuffer is a strings.Builder safe for the worker's log and the host's
|
|
// call log to write at once.
|
|
type syncBuffer struct {
|
|
mu sync.Mutex
|
|
b strings.Builder
|
|
}
|
|
|
|
func (s *syncBuffer) Write(p []byte) (int, error) {
|
|
s.mu.Lock()
|
|
defer s.mu.Unlock()
|
|
return s.b.Write(p)
|
|
}
|
|
|
|
func (s *syncBuffer) String() string {
|
|
s.mu.Lock()
|
|
defer s.mu.Unlock()
|
|
return s.b.String()
|
|
}
|