diff --git a/cmd/csaf_uploader/main.go b/cmd/csaf_uploader/main.go new file mode 100644 index 0000000..86a57dd --- /dev/null +++ b/cmd/csaf_uploader/main.go @@ -0,0 +1,319 @@ +package main + +import ( + "bytes" + "encoding/json" + "fmt" + "log" + "mime/multipart" + "net/http" + "os" + "path/filepath" + + "github.com/ProtonMail/gopenpgp/v2/crypto" + "github.com/jessevdk/go-flags" + "github.com/mitchellh/go-homedir" + "golang.org/x/crypto/bcrypt" + "golang.org/x/crypto/ssh/terminal" +) + +type options struct { + Action string `short:"a" long:"action" choice:"upload" choice:"create" default:"upload" description:"Action to perform"` + URL string `short:"u" long:"url" description:"URL of the CSAF provider" default:"https://localhost/cgi-bin/csaf_provider.go" value-name:"URL"` + TLP string `short:"t" long:"tlp" choice:"csaf" choice:"white" choice:"green" choice:"amber" choice:"red" default:"csaf" description:"TLP of the feed"` + + Key *string `short:"k" long:"key" description:"OpenPGP key to sign the CSAF files" value-name:"KEY-FILE"` + Password *string `short:"p" long:"password" description:"Authentication password for accessing the CSAF provider" value-name:"PASSWORD"` + Passphrase *string `short:"P" long:"passphrase" description:"Passphrase to unlock the OpenPGP key" value-name:"PASSPHRASE"` + + PasswordInteractive bool `short:"i" long:"password-interactive" description:"Enter password interactively" no-ini:"true"` + PassphraseInteractive bool `short:"I" long:"passphrase-interacive" description:"Enter passphrase interactively" no-ini:"true"` + + Config *string `short:"c" long:"config" description:"Path to config ini file" value-name:"INI-FILE" no-ini:"true"` +} + +type processor struct { + opts *options + cachedAuth string + keyRing *crypto.KeyRing +} + +var iniPaths = []string{ + "~/.config/csaf/uploader.ini", + "~/.csaf_uploader.ini", + "csaf_uploader.ini", +} + +func loadKey(filename string) (*crypto.Key, error) { + f, err := os.Open(filename) + if err != nil { + return nil, err + } + defer f.Close() + return crypto.NewKeyFromArmoredReader(f) +} + +func newProcessor(opts *options) (*processor, error) { + p := processor{ + opts: opts, + } + + if opts.Action == "upload" { + if opts.Key != nil { + var err error + var key *crypto.Key + if key, err = loadKey(*opts.Key); err != nil { + return nil, err + } + if opts.Passphrase != nil { + if key, err = key.Unlock([]byte(*opts.Passphrase)); err != nil { + return nil, err + } + } + if p.keyRing, err = crypto.NewKeyRing(key); err != nil { + return nil, err + } + } + } + + // pre-calc the auth header + if opts.Password != nil { + hash, err := bcrypt.GenerateFromPassword( + []byte(*opts.Password), bcrypt.DefaultCost) + if err != nil { + return nil, err + } + p.cachedAuth = string(hash) + } + + return &p, nil +} + +func (p *processor) create() error { + req, err := http.NewRequest(http.MethodGet, p.opts.URL+"/api/create", nil) + if err != nil { + return err + } + req.Header.Set("X-CSAF-PROVIDER-AUTH", p.cachedAuth) + + resp, err := http.DefaultClient.Do(req) + if err != nil { + return err + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + log.Printf("Create failed: %s\n", resp.Status) + } + + var result struct { + Message string `json:"message"` + Errors []string `json:"errors"` + } + + if err := json.NewDecoder(resp.Body).Decode(&result); err != nil { + return err + } + + if result.Message != "" { + fmt.Printf("\t%s\n", result.Message) + } + + if len(result.Errors) > 0 { + fmt.Println("Errors:") + for _, err := range result.Errors { + fmt.Printf("\t%s\n", err) + } + } + return nil +} + +func (p *processor) uploadRequest(filename string) (*http.Request, error) { + data, err := os.ReadFile(filename) + if err != nil { + return nil, err + } + + body := new(bytes.Buffer) + writer := multipart.NewWriter(body) + + part, err := writer.CreateFormFile("csaf", filepath.Base(filename)) + if err != nil { + return nil, err + } + + if _, err := part.Write(data); err != nil { + return nil, err + } + + if err := writer.WriteField("tlp", p.opts.TLP); err != nil { + return nil, err + } + + if p.keyRing == nil && p.opts.Passphrase != nil { + if err := writer.WriteField("passphrase", *p.opts.Passphrase); err != nil { + return nil, err + } + } + + if p.keyRing != nil { + sig, err := p.keyRing.SignDetached(crypto.NewPlainMessage(data)) + if err != nil { + return nil, err + } + armored, err := sig.GetArmored() + if err != nil { + return nil, err + } + if err := writer.WriteField("signature", armored); err != nil { + return nil, err + } + } + + if err := writer.Close(); err != nil { + return nil, err + } + + req, err := http.NewRequest(http.MethodPost, p.opts.URL+"/api/upload", body) + if err != nil { + return nil, err + } + + req.Header.Set("X-CSAF-PROVIDER-AUTH", p.cachedAuth) + req.Header.Set("Content-Type", writer.FormDataContentType()) + + return req, nil +} + +func (p *processor) process(filename string) error { + + req, err := p.uploadRequest(filename) + if err != nil { + return err + } + + resp, err := http.DefaultClient.Do(req) + if err != nil { + return err + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + log.Printf("Upload failed: %s\n", resp.Status) + } + + var result struct { + Name string `json:"name"` + ReleaseDate string `json:"release_date"` + Warnings []string `json:"warnings"` + Errors []string `json:"errors"` + } + + if err := json.NewDecoder(resp.Body).Decode(&result); err != nil { + return err + } + + if result.Name != "" { + fmt.Printf("Name: %s\n", result.Name) + } + if result.ReleaseDate != "" { + fmt.Printf("Release date: %s\n", result.ReleaseDate) + } + + if len(result.Warnings) > 0 { + fmt.Println("Warnings:") + for _, warning := range result.Warnings { + fmt.Printf("\t%s\n", warning) + } + } + + if len(result.Errors) > 0 { + fmt.Println("Errors:") + for _, err := range result.Errors { + fmt.Printf("\t%s\n", err) + } + } + + return nil +} + +func findIniFile() string { + for _, f := range iniPaths { + name, err := homedir.Expand(f) + if err != nil { + log.Printf("warn: %v\n", err) + continue + } + if _, err := os.Stat(name); err == nil { + return name + } + } + return "" +} + +func readInteractive(prompt string, pw **string) error { + fmt.Print(prompt) + p, err := terminal.ReadPassword(int(os.Stdin.Fd())) + if err != nil { + return err + } + ps := string(p) + *pw = &ps + return nil +} + +func check(err error) { + if err != nil { + log.Fatalf("error: %v\n", err) + } +} + +func checkParser(err error) { + if err != nil { + if e, ok := err.(*flags.Error); ok && e.Type == flags.ErrHelp { + os.Exit(0) + } + os.Exit(1) + } +} + +func main() { + var opts options + + parser := flags.NewParser(&opts, flags.Default) + + args, err := parser.Parse() + checkParser(err) + + if opts.Config != nil { + iniParser := flags.NewIniParser(parser) + iniParser.ParseAsDefaults = true + name, err := homedir.Expand(*opts.Config) + check(err) + checkParser(iniParser.ParseFile(name)) + } else if iniFile := findIniFile(); iniFile != "" { + iniParser := flags.NewIniParser(parser) + iniParser.ParseAsDefaults = true + checkParser(iniParser.ParseFile(iniFile)) + } + + if opts.PasswordInteractive { + check(readInteractive("Enter auth password: ", &opts.Password)) + } + + if opts.PassphraseInteractive { + check(readInteractive("Enter OpenPGP passphrase: ", &opts.Passphrase)) + } + + p, err := newProcessor(&opts) + check(err) + + if opts.Action == "create" { + check(p.create()) + return + } + + for _, arg := range args { + check(p.process(arg)) + } +} diff --git a/go.mod b/go.mod index 8814c8e..4508260 100644 --- a/go.mod +++ b/go.mod @@ -7,6 +7,8 @@ require ( github.com/PaesslerAG/gval v1.1.2 github.com/PaesslerAG/jsonpath v0.1.1 github.com/ProtonMail/gopenpgp/v2 v2.3.0 + github.com/jessevdk/go-flags v1.5.0 + github.com/mitchellh/go-homedir v1.1.0 github.com/santhosh-tekuri/jsonschema/v5 v5.0.0 golang.org/x/crypto v0.0.0-20211202192323-5770296d904e ) @@ -18,5 +20,6 @@ require ( github.com/pkg/errors v0.9.1 // indirect github.com/sirupsen/logrus v1.4.2 // indirect golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1 // indirect + golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1 // indirect golang.org/x/text v0.3.6 // indirect ) diff --git a/go.sum b/go.sum index 83173d4..c52e3a5 100644 --- a/go.sum +++ b/go.sum @@ -16,8 +16,12 @@ github.com/ProtonMail/gopenpgp/v2 v2.3.0/go.mod h1:F62x0m3akQuisX36pOgAtKOHZ1E7/ github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/jessevdk/go-flags v1.5.0 h1:1jKYvbxEjfUl0fmqTCOfonvskHHXMjBySTLW4y9LFvc= +github.com/jessevdk/go-flags v1.5.0/go.mod h1:Fw0T6WPc1dYxT4mKEZRfG5kJhaTDP9pj1c2EWnYs/m4= github.com/konsorten/go-windows-terminal-sequences v1.0.1 h1:mweAR1A6xJ3oS2pRaGiHgQ4OO8tzTaLawm8vnODuwDk= github.com/konsorten/go-windows-terminal-sequences v1.0.1/go.mod h1:T0+1ngSBFLxvqU3pZ+m/2kptfBszLMUkC4ZK/EgS/cQ= +github.com/mitchellh/go-homedir v1.1.0 h1:lukF9ziXFxDFPkA1vsr5zpc1XuPDn/wFntq5mG+4E0Y= +github.com/mitchellh/go-homedir v1.1.0/go.mod h1:SfyaCUpYCn1Vlf4IUYiD9fPX4A5wJrkLzIz1N1q0pr0= github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4= github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= @@ -55,9 +59,11 @@ golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5h golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20190422165155-953cdadca894/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20210320140829-1e4c9ba3b0c4/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20210423082822-04245dca01da/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1 h1:SrN+KX8Art/Sf4HNj6Zcz06G7VEz+7w9tdXTPOZ7+l4= golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1 h1:v+OssWQX+hTHEmOBgwxdZxK4zHq3yOs8F9J7mk0PY8E= golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= golang.org/x/text v0.3.2/go.mod h1:bEr9sfX3Q8Zfm5fL9x+3itogRgK3+ptLWKqgva+5dAk=