mirror of
https://github.com/gocsaf/csaf.git
synced 2025-12-22 11:55:40 +01:00
uploader: use the TOML config file infrastructure, too. (#439)
* Make uploader use the TOML config file intrastructure, too. * Improve method naming a bit. * Improve method naming a bit. * Add forgotten struct tags for TOML * Add version to command line only parameters in uploader documentation * Be explicit about supported options in config file. * allow interactive flags in config file. --------- Co-authored-by: JanHoefelmeyer <Jan Höfelmeyer jhoefelmeyer@intevation.de>
This commit is contained in:
parent
5c935901ab
commit
f2657bb51a
4 changed files with 507 additions and 458 deletions
190
cmd/csaf_uploader/config.go
Normal file
190
cmd/csaf_uploader/config.go
Normal file
|
|
@ -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) <https://www.bsi.bund.de>
|
||||
// Software-Engineering: 2023 Intevation GmbH <https://intevation.de>
|
||||
|
||||
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
|
||||
}
|
||||
|
|
@ -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))
|
||||
}
|
||||
|
|
|
|||
291
cmd/csaf_uploader/processor.go
Normal file
291
cmd/csaf_uploader/processor.go
Normal file
|
|
@ -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) <https://www.bsi.bund.de>
|
||||
// Software-Engineering: 2022, 2023 Intevation GmbH <https://intevation.de>
|
||||
|
||||
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
|
||||
}
|
||||
|
|
@ -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
|
||||
```
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue