diff --git a/cmd/csaf_uploader/config.go b/cmd/csaf_uploader/config.go new file mode 100644 index 00000000..9d2ae200 --- /dev/null +++ b/cmd/csaf_uploader/config.go @@ -0,0 +1,190 @@ +// This file is Free Software under the MIT License +// without warranty, see README.md and LICENSES/MIT.txt for details. +// +// SPDX-License-Identifier: MIT +// +// SPDX-FileCopyrightText: 2023 German Federal Office for Information Security (BSI) +// Software-Engineering: 2023 Intevation GmbH + +package main + +import ( + "crypto/tls" + "errors" + "fmt" + "os" + + "github.com/ProtonMail/gopenpgp/v2/crypto" + "github.com/csaf-poc/csaf_distribution/v2/internal/certs" + "github.com/csaf-poc/csaf_distribution/v2/internal/options" + "golang.org/x/crypto/bcrypt" + "golang.org/x/term" +) + +const ( + defaultURL = "https://localhost/cgi-bin/csaf_provider.go" + defaultAction = "upload" + defaultTLP = "csaf" +) + +// The supported flag config of the uploader command line +type config struct { + //lint:ignore SA5008 We are using choice twice: upload, create. + Action string `short:"a" long:"action" choice:"upload" choice:"create" description:"Action to perform" toml:"action"` + URL string `short:"u" long:"url" description:"URL of the CSAF provider" value-name:"URL" toml:"url"` + //lint:ignore SA5008 We are using choice many times: csaf, white, green, amber, red. + TLP string `short:"t" long:"tlp" choice:"csaf" choice:"white" choice:"green" choice:"amber" choice:"red" description:"TLP of the feed" toml:"tlp"` + ExternalSigned bool `short:"x" long:"external-signed" description:"CSAF files are signed externally. Assumes .asc files beside CSAF files." toml:"external_signed"` + NoSchemaCheck bool `short:"s" long:"no-schema-check" description:"Do not check files against CSAF JSON schema locally." toml:"no_schema_check"` + + Key *string `short:"k" long:"key" description:"OpenPGP key to sign the CSAF files" value-name:"KEY-FILE" toml:"key"` + Password *string `short:"p" long:"password" description:"Authentication password for accessing the CSAF provider" value-name:"PASSWORD" toml:"password"` + Passphrase *string `short:"P" long:"passphrase" description:"Passphrase to unlock the OpenPGP key" value-name:"PASSPHRASE" toml:"passphrase"` + ClientCert *string `long:"client-cert" description:"TLS client certificate file (PEM encoded data)" value-name:"CERT-FILE.crt" toml:"client_cert"` + ClientKey *string `long:"client-key" description:"TLS client private key file (PEM encoded data)" value-name:"KEY-FILE.pem" toml:"client_key"` + ClientPassphrase *string `long:"client-passphrase" description:"Optional passphrase for the client cert (limited, experimental, see downloader doc)" value-name:"PASSPHRASE" toml:"client_passphrase"` + + PasswordInteractive bool `short:"i" long:"password-interactive" description:"Enter password interactively" toml:"password_interactive"` + PassphraseInteractive bool `short:"I" long:"passphrase-interactive" description:"Enter OpenPGP key passphrase interactively" toml:"passphrase_interactive"` + + Insecure bool `long:"insecure" description:"Do not check TLS certificates from provider" toml:"insecure"` + + Config string `short:"c" long:"config" description:"Path to config TOML file" value-name:"TOML-FILE" toml:"-"` + Version bool `long:"version" description:"Display version of the binary" toml:"-"` + + clientCerts []tls.Certificate + cachedAuth string + keyRing *crypto.KeyRing +} + +// iniPaths are the potential file locations of the the config file. +var configPaths = []string{ + "~/.config/csaf/uploader.toml", + "~/.csaf_uploader.toml", + "csaf_uploader.toml", +} + +// parseArgsConfig parses the command line and if need a config file. +func parseArgsConfig() ([]string, *config, error) { + p := options.Parser[config]{ + DefaultConfigLocations: configPaths, + ConfigLocation: func(cfg *config) string { return cfg.Config }, + Usage: "[OPTIONS] advisories...", + HasVersion: func(cfg *config) bool { return cfg.Version }, + SetDefaults: func(cfg *config) { + cfg.URL = defaultURL + cfg.Action = defaultAction + cfg.TLP = defaultTLP + }, + // Re-establish default values if not set. + EnsureDefaults: func(cfg *config) { + if cfg.URL == "" { + cfg.URL = defaultURL + } + if cfg.Action == "" { + cfg.Action = defaultAction + } + if cfg.TLP == "" { + cfg.TLP = defaultTLP + } + }, + } + return p.Parse() +} + +// prepareCertificates loads the client side certificates used by the HTTP client. +func (cfg *config) prepareCertificates() error { + cert, err := certs.LoadCertificate( + cfg.ClientCert, cfg.ClientKey, cfg.ClientPassphrase) + if err != nil { + return err + } + cfg.clientCerts = cert + return nil +} + +// readInteractive prints a message to command line and retrieves the password from it. +func readInteractive(prompt string, pw **string) error { + fmt.Print(prompt) + p, err := term.ReadPassword(int(os.Stdin.Fd())) + if err != nil { + return err + } + ps := string(p) + *pw = &ps + return nil +} + +// prepareInteractive prompts for interactive passwords. +func (cfg *config) prepareInteractive() error { + if cfg.PasswordInteractive { + if err := readInteractive("Enter auth password: ", &cfg.Password); err != nil { + return err + } + } + if cfg.PassphraseInteractive { + if err := readInteractive("Enter OpenPGP passphrase: ", &cfg.Passphrase); err != nil { + return err + } + } + return nil +} + +// loadOpenPGPKey loads an OpenPGP key. +func loadOpenPGPKey(filename string) (*crypto.Key, error) { + f, err := os.Open(filename) + if err != nil { + return nil, err + } + defer f.Close() + return crypto.NewKeyFromArmoredReader(f) +} + +// prepareOpenPGPKey loads the configured OpenPGP key. +func (cfg *config) prepareOpenPGPKey() error { + if cfg.Action != "upload" || cfg.Key == nil { + return nil + } + if cfg.ExternalSigned { + return errors.New("refused to sign external signed files") + } + key, err := loadOpenPGPKey(*cfg.Key) + if err != nil { + return err + } + if cfg.Passphrase != nil { + if key, err = key.Unlock([]byte(*cfg.Passphrase)); err != nil { + return err + } + } + cfg.keyRing, err = crypto.NewKeyRing(key) + return err +} + +// preparePassword pre-calculates the auth header. +func (cfg *config) preparePassword() error { + if cfg.Password != nil { + hash, err := bcrypt.GenerateFromPassword( + []byte(*cfg.Password), bcrypt.DefaultCost) + if err != nil { + return err + } + cfg.cachedAuth = string(hash) + } + return nil +} + +// prepare prepares internal state of a loaded configuration. +func (cfg *config) prepare() error { + for _, prepare := range []func(*config) error{ + (*config).prepareCertificates, + (*config).prepareInteractive, + (*config).prepareOpenPGPKey, + (*config).preparePassword, + } { + if err := prepare(cfg); err != nil { + return err + } + } + return nil +} diff --git a/cmd/csaf_uploader/main.go b/cmd/csaf_uploader/main.go index 96293ef9..522af41d 100644 --- a/cmd/csaf_uploader/main.go +++ b/cmd/csaf_uploader/main.go @@ -9,453 +9,12 @@ // Implements a command line tool that uploads csaf documents to csaf_provider. package main -import ( - "bytes" - "crypto/tls" - "encoding/json" - "errors" - "fmt" - "io" - "log" - "mime/multipart" - "net/http" - "net/textproto" - "os" - "path/filepath" - "strings" - - "github.com/ProtonMail/gopenpgp/v2/armor" - "github.com/ProtonMail/gopenpgp/v2/constants" - "github.com/ProtonMail/gopenpgp/v2/crypto" - "github.com/csaf-poc/csaf_distribution/v2/csaf" - "github.com/csaf-poc/csaf_distribution/v2/internal/certs" - "github.com/csaf-poc/csaf_distribution/v2/util" - "github.com/jessevdk/go-flags" - "github.com/mitchellh/go-homedir" - "golang.org/x/crypto/bcrypt" - "golang.org/x/term" -) - -// The supported flag options of the uploader command line -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"` - ExternalSigned bool `short:"x" long:"external-signed" description:"CSAF files are signed externally. Assumes .asc files beside CSAF files."` - NoSchemaCheck bool `short:"s" long:"no-schema-check" description:"Do not check files against CSAF JSON schema locally."` - - 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"` - ClientCert *string `long:"client-cert" description:"TLS client certificate file (PEM encoded data)" value-name:"CERT-FILE.crt"` - ClientKey *string `long:"client-key" description:"TLS client private key file (PEM encoded data)" value-name:"KEY-FILE.pem"` - ClientPassphrase *string `long:"client-passphrase" description:"Optional passphrase for the client cert (limited, experimental, see downloader doc)" value-name:"PASSPHRASE"` - - PasswordInteractive bool `short:"i" long:"password-interactive" description:"Enter password interactively" no-ini:"true"` - PassphraseInteractive bool `short:"I" long:"passphrase-interactive" description:"Enter OpenPGP key passphrase interactively" no-ini:"true"` - - Insecure bool `long:"insecure" description:"Do not check TLS certificates from provider"` - - Config *string `short:"c" long:"config" description:"Path to config ini file" value-name:"INI-FILE" no-ini:"true"` - Version bool `long:"version" description:"Display version of the binary"` - - clientCerts []tls.Certificate -} - -type processor struct { - opts *options - cachedAuth string - keyRing *crypto.KeyRing -} - -// iniPaths are the potential file locations of the the config file. -var iniPaths = []string{ - "~/.config/csaf/uploader.ini", - "~/.csaf_uploader.ini", - "csaf_uploader.ini", -} - -func (o *options) prepare() error { - // Load client certs. - cert, err := certs.LoadCertificate( - o.ClientCert, o.ClientKey, o.ClientPassphrase) - if err != nil { - return err - } - o.clientCerts = cert - return nil -} - -// loadKey loads an OpenPGP key. -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 { - if opts.ExternalSigned { - return nil, errors.New("refused to sign external signed files") - } - 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 -} - -// httpClient initializes the http.Client according to the "Insecure" flag -// and the TLS client files for authentication and returns it. -func (p *processor) httpClient() *http.Client { - var client http.Client - var tlsConfig tls.Config - - if p.opts.Insecure { - tlsConfig.InsecureSkipVerify = true - } - - if len(p.opts.clientCerts) != 0 { - tlsConfig.Certificates = p.opts.clientCerts - } - - client.Transport = &http.Transport{ - TLSClientConfig: &tlsConfig, - } - - return &client -} - -// writeStrings prints the passed messages under the specific passed header. -func writeStrings(header string, messages []string) { - if len(messages) > 0 { - fmt.Println(header) - for _, msg := range messages { - fmt.Printf("\t%s\n", msg) - } - } -} - -// create sends an request to create the initial files and directories -// on the server. It prints the response messages. -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 := p.httpClient().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) - } - - writeStrings("Errors:", result.Errors) - - return nil -} - -var escapeQuotes = strings.NewReplacer("\\", "\\\\", `"`, "\\\"").Replace - -// createFormFile creates an [io.Writer] like [mime/multipart.Writer.CreateFromFile]. -// This version allows to set the mime type, too. -func createFormFile(w *multipart.Writer, fieldname, filename, mimeType string) (io.Writer, error) { - // Source: https://cs.opensource.google/go/go/+/refs/tags/go1.20:src/mime/multipart/writer.go;l=140 - h := make(textproto.MIMEHeader) - h.Set("Content-Disposition", - fmt.Sprintf(`form-data; name="%s"; filename="%s"`, - escapeQuotes(fieldname), escapeQuotes(filename))) - h.Set("Content-Type", mimeType) - return w.CreatePart(h) -} - -// uploadRequest creates the request for uploading a csaf document by passing the filename. -// According to the flags values the multipart sections of the request are established. -// It returns the created http request. -func (p *processor) uploadRequest(filename string) (*http.Request, error) { - data, err := os.ReadFile(filename) - if err != nil { - return nil, err - } - - if !p.opts.NoSchemaCheck { - var doc any - if err := json.NewDecoder(bytes.NewReader(data)).Decode(&doc); err != nil { - return nil, err - } - errs, err := csaf.ValidateCSAF(doc) - if err != nil { - return nil, err - } - if len(errs) > 0 { - writeStrings("Errors:", errs) - return nil, errors.New("local schema check failed") - } - - eval := util.NewPathEval() - if err := util.IDMatchesFilename(eval, doc, filepath.Base(filename)); err != nil { - return nil, err - } - } - - body := new(bytes.Buffer) - writer := multipart.NewWriter(body) - - // As the csaf_provider only accepts uploads with mime type - // "application/json" we have to set this. - part, err := createFormFile( - writer, "csaf", filepath.Base(filename), "application/json") - 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 := armor.ArmorWithTypeAndCustomHeaders( - sig.Data, constants.PGPSignatureHeader, "", "") - if err != nil { - return nil, err - } - if err := writer.WriteField("signature", armored); err != nil { - return nil, err - } - } - - if p.opts.ExternalSigned { - signature, err := os.ReadFile(filename + ".asc") - if err != nil { - return nil, err - } - if err := writer.WriteField("signature", string(signature)); 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 -} - -// process attemps to upload a file to the server. -// It prints the response messages. -func (p *processor) process(filename string) error { - - if bn := filepath.Base(filename); !util.ConformingFileName(bn) { - return fmt.Errorf("%q is not a conforming file name", bn) - } - - req, err := p.uploadRequest(filename) - if err != nil { - return err - } - - resp, err := p.httpClient().Do(req) - if err != nil { - return err - } - defer resp.Body.Close() - - var uploadErr error - if resp.StatusCode != http.StatusOK { - uploadErr = fmt.Errorf("upload failed: %s", resp.Status) - fmt.Printf("HTTPS %s\n", uploadErr) - } - - // We expect a JSON answer so all other is not valid. - if !strings.Contains(resp.Header.Get("Content-Type"), "application/json") { - var sb strings.Builder - if _, err := io.Copy(&sb, resp.Body); err != nil { - return fmt.Errorf("reading non-JSON reply from server failed: %v", err) - } - return fmt.Errorf("non-JSON reply from server: %v", sb.String()) - } - - 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) - } - - writeStrings("Warnings:", result.Warnings) - writeStrings("Errors:", result.Errors) - - return uploadErr -} - -// findIniFile looks for a file in the pre-defined paths in "iniPaths". -// The returned value will be the name of file if found, otherwise an empty string. -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 "" -} - -// readInteractive prints a message to command line and retrieves the password from it. -func readInteractive(prompt string, pw **string) error { - fmt.Print(prompt) - p, err := term.ReadPassword(int(os.Stdin.Fd())) - if err != nil { - return err - } - ps := string(p) - *pw = &ps - return nil -} - -func check(err error) { - if err != nil { - if flags.WroteHelp(err) { - os.Exit(0) - } - log.Fatalf("error: %v\n", err) - } -} +import "github.com/csaf-poc/csaf_distribution/v2/internal/options" func main() { - var opts options - - parser := flags.NewParser(&opts, flags.Default) - - args, err := parser.Parse() - check(err) - - if opts.Version { - fmt.Println(util.SemVersion) - return - } - - if opts.Config != nil { - iniParser := flags.NewIniParser(parser) - iniParser.ParseAsDefaults = true - name, err := homedir.Expand(*opts.Config) - check(err) - check(iniParser.ParseFile(name)) - } else if iniFile := findIniFile(); iniFile != "" { - iniParser := flags.NewIniParser(parser) - iniParser.ParseAsDefaults = true - check(iniParser.ParseFile(iniFile)) - } - - check(opts.prepare()) - - 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 - } - - if len(args) == 0 { - log.Println("No CSAF files given.") - } - - for _, arg := range args { - if err := p.process(arg); err != nil { - log.Fatalf("error: processing %q failed: %v\n", arg, err) - } - } + args, cfg, err := parseArgsConfig() + options.ErrorCheck(err) + options.ErrorCheck(cfg.prepare()) + p := &processor{cfg: cfg} + options.ErrorCheck(p.run(args)) } diff --git a/cmd/csaf_uploader/processor.go b/cmd/csaf_uploader/processor.go new file mode 100644 index 00000000..11f6ed61 --- /dev/null +++ b/cmd/csaf_uploader/processor.go @@ -0,0 +1,291 @@ +// This file is Free Software under the MIT License +// without warranty, see README.md and LICENSES/MIT.txt for details. +// +// SPDX-License-Identifier: MIT +// +// SPDX-FileCopyrightText: 2022, 2023 German Federal Office for Information Security (BSI) +// Software-Engineering: 2022, 2023 Intevation GmbH + +package main + +import ( + "bytes" + "crypto/tls" + "encoding/json" + "errors" + "fmt" + "io" + "log" + "mime/multipart" + "net/http" + "net/textproto" + "os" + "path/filepath" + "strings" + + "github.com/ProtonMail/gopenpgp/v2/armor" + "github.com/ProtonMail/gopenpgp/v2/constants" + "github.com/ProtonMail/gopenpgp/v2/crypto" + + "github.com/csaf-poc/csaf_distribution/v2/csaf" + "github.com/csaf-poc/csaf_distribution/v2/util" +) + +type processor struct { + cfg *config +} + +// httpClient initializes the http.Client according to the "Insecure" flag +// and the TLS client files for authentication and returns it. +func (p *processor) httpClient() *http.Client { + var client http.Client + var tlsConfig tls.Config + + if p.cfg.Insecure { + tlsConfig.InsecureSkipVerify = true + } + + if len(p.cfg.clientCerts) != 0 { + tlsConfig.Certificates = p.cfg.clientCerts + } + + client.Transport = &http.Transport{ + TLSClientConfig: &tlsConfig, + } + + return &client +} + +// writeStrings prints the passed messages under the specific passed header. +func writeStrings(header string, messages []string) { + if len(messages) > 0 { + fmt.Println(header) + for _, msg := range messages { + fmt.Printf("\t%s\n", msg) + } + } +} + +// create sends an request to create the initial files and directories +// on the server. It prints the response messages. +func (p *processor) create() error { + req, err := http.NewRequest(http.MethodGet, p.cfg.URL+"/api/create", nil) + if err != nil { + return err + } + req.Header.Set("X-CSAF-PROVIDER-AUTH", p.cfg.cachedAuth) + + resp, err := p.httpClient().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) + } + + writeStrings("Errors:", result.Errors) + + return nil +} + +var escapeQuotes = strings.NewReplacer("\\", "\\\\", `"`, "\\\"").Replace + +// createFormFile creates an [io.Writer] like [mime/multipart.Writer.CreateFromFile]. +// This version allows to set the mime type, too. +func createFormFile(w *multipart.Writer, fieldname, filename, mimeType string) (io.Writer, error) { + // Source: https://cs.opensource.google/go/go/+/refs/tags/go1.20:src/mime/multipart/writer.go;l=140 + h := make(textproto.MIMEHeader) + h.Set("Content-Disposition", + fmt.Sprintf(`form-data; name="%s"; filename="%s"`, + escapeQuotes(fieldname), escapeQuotes(filename))) + h.Set("Content-Type", mimeType) + return w.CreatePart(h) +} + +// uploadRequest creates the request for uploading a csaf document by passing the filename. +// According to the flags values the multipart sections of the request are established. +// It returns the created http request. +func (p *processor) uploadRequest(filename string) (*http.Request, error) { + data, err := os.ReadFile(filename) + if err != nil { + return nil, err + } + + if !p.cfg.NoSchemaCheck { + var doc any + if err := json.NewDecoder(bytes.NewReader(data)).Decode(&doc); err != nil { + return nil, err + } + errs, err := csaf.ValidateCSAF(doc) + if err != nil { + return nil, err + } + if len(errs) > 0 { + writeStrings("Errors:", errs) + return nil, errors.New("local schema check failed") + } + + eval := util.NewPathEval() + if err := util.IDMatchesFilename(eval, doc, filepath.Base(filename)); err != nil { + return nil, err + } + } + + body := new(bytes.Buffer) + writer := multipart.NewWriter(body) + + // As the csaf_provider only accepts uploads with mime type + // "application/json" we have to set this. + part, err := createFormFile( + writer, "csaf", filepath.Base(filename), "application/json") + if err != nil { + return nil, err + } + + if _, err := part.Write(data); err != nil { + return nil, err + } + + if err := writer.WriteField("tlp", p.cfg.TLP); err != nil { + return nil, err + } + + if p.cfg.keyRing == nil && p.cfg.Passphrase != nil { + if err := writer.WriteField("passphrase", *p.cfg.Passphrase); err != nil { + return nil, err + } + } + + if p.cfg.keyRing != nil { + sig, err := p.cfg.keyRing.SignDetached(crypto.NewPlainMessage(data)) + if err != nil { + return nil, err + } + armored, err := armor.ArmorWithTypeAndCustomHeaders( + sig.Data, constants.PGPSignatureHeader, "", "") + if err != nil { + return nil, err + } + if err := writer.WriteField("signature", armored); err != nil { + return nil, err + } + } + + if p.cfg.ExternalSigned { + signature, err := os.ReadFile(filename + ".asc") + if err != nil { + return nil, err + } + if err := writer.WriteField("signature", string(signature)); err != nil { + return nil, err + } + } + + if err := writer.Close(); err != nil { + return nil, err + } + + req, err := http.NewRequest(http.MethodPost, p.cfg.URL+"/api/upload", body) + if err != nil { + return nil, err + } + + req.Header.Set("X-CSAF-PROVIDER-AUTH", p.cfg.cachedAuth) + req.Header.Set("Content-Type", writer.FormDataContentType()) + + return req, nil +} + +// process attemps to upload a file to the server. +// It prints the response messages. +func (p *processor) process(filename string) error { + + if bn := filepath.Base(filename); !util.ConformingFileName(bn) { + return fmt.Errorf("%q is not a conforming file name", bn) + } + + req, err := p.uploadRequest(filename) + if err != nil { + return err + } + + resp, err := p.httpClient().Do(req) + if err != nil { + return err + } + defer resp.Body.Close() + + var uploadErr error + if resp.StatusCode != http.StatusOK { + uploadErr = fmt.Errorf("upload failed: %s", resp.Status) + fmt.Printf("HTTPS %s\n", uploadErr) + } + + // We expect a JSON answer so all other is not valid. + if !strings.Contains(resp.Header.Get("Content-Type"), "application/json") { + var sb strings.Builder + if _, err := io.Copy(&sb, resp.Body); err != nil { + return fmt.Errorf("reading non-JSON reply from server failed: %v", err) + } + return fmt.Errorf("non-JSON reply from server: %v", sb.String()) + } + + 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) + } + + writeStrings("Warnings:", result.Warnings) + writeStrings("Errors:", result.Errors) + + return uploadErr +} + +func (p *processor) run(args []string) error { + + if p.cfg.Action == "create" { + if err := p.create(); err != nil { + return err + } + } + + if len(args) == 0 { + log.Println("No CSAF files given.") + } + + for _, arg := range args { + if err := p.process(arg); err != nil { + return fmt.Errorf("processing %q failed: %v", arg, err) + } + } + + return nil +} diff --git a/docs/csaf_uploader.md b/docs/csaf_uploader.md index 5926461e..7ff0db7a 100644 --- a/docs/csaf_uploader.md +++ b/docs/csaf_uploader.md @@ -3,7 +3,7 @@ ### Usage ``` - csaf_uploader [OPTIONS] +csaf_uploader [OPTIONS] Application Options: -a, --action=[upload|create] Action to perform (default: upload) @@ -20,7 +20,7 @@ Application Options: -i, --password-interactive Enter password interactively -I, --passphrase-interactive Enter OpenPGP key passphrase interactively --insecure Do not check TLS certificates from provider - -c, --config=INI-FILE Path to config ini file + -c, --config=TOML-FILE Path to config TOML file --version Display version of the binary Help Options: @@ -47,16 +47,25 @@ By default csaf_uploader will try to load a config file from the following places: ``` - "~/.config/csaf/uploader.ini", - "~/.csaf_uploader.ini", - "csaf_uploader.ini", + "~/.config/csaf/uploader.toml", + "~/.csaf_uploader.toml", + "csaf_uploader.toml", ``` -The command line options can be written in the init file, except: -`password-interactive`, `passphrase-interactive` and `config`. -An example: - +The command line options can be written in the config file: ``` -action=create -u=https://localhost/cgi-bin/csaf_provider.go +action = "upload" +url = "https://localhost/cgi-bin/csaf_provider.go" +tlp = "csaf" +external_signed = false +no_schema_check = false +# key = "/path/to/openpgp/key/file" # not set by default +# password = "auth-key to access the provider" # not set by default +# passphrase = "OpenPGP passphrase" # not set by default +# client_cert = "/path/to/client/cert" # not set by default +# client_key = "/path/to/client/cert.key" # not set by default +# client_passphrase = "client cert passphrase" # not set by default +password_interactive = false +passphrase_interactive = false +insecure = false ```