diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..c761729 --- /dev/null +++ b/Makefile @@ -0,0 +1,57 @@ +# 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: 2021 German Federal Office for Information Security (BSI) +# Software-Engineering: 2021 Intevation GmbH +# +# Makefile to build csaf_distribution components + +SHELL = /bin/bash +BUILD = go build +MKDIR = mkdir -p + +.PHONY: build build_linux build_win tag_checked_out mostlyclean + +all: + @echo choose a target from: build build_linux build_win mostlyclean + @echo prepend \`make BUILDTAG=1\` to checkout the highest git tag before building + @echo or set BUILDTAG to a specific tag + +# Build all binaries +build: build_linux build_win + +# if BUILDTAG == 1 set it to the highest git tag +ifeq ($(strip $(BUILDTAG)),1) +override BUILDTAG = $(shell git tag --sort=-version:refname | head -n 1) +endif + +ifdef BUILDTAG +# add the git tag checkout to the requirements of our build targets +build_linux build_win: tag_checked_out +endif + +tag_checked_out: + $(if $(strip $(BUILDTAG)),,$(error no git tag found)) + git checkout -q tags/${BUILDTAG} + @echo Don\'t forget that we are in checked out tag $(BUILDTAG) now. + + +# Build binaries and place them under bin-$(GOOS)-$(GOARCH) +# Using 'Target-specific Variable Values' to specify the build target system + +GOARCH = amd64 +build_linux: GOOS = linux +build_win: GOOS = windows + +build_linux build_win: + $(eval BINDIR = bin-$(GOOS)-$(GOARCH)/ ) + $(MKDIR) $(BINDIR) + env GOARCH=$(GOARCH) GOOS=$(GOOS) $(BUILD) -o $(BINDIR) -v ./cmd/... + + +# Remove bin-*-* directories +mostlyclean: + rm -rf ./bin-*-* + @echo Files in \`go env GOCACHE\` remain. diff --git a/README.md b/README.md index f058e58..927c0bd 100644 --- a/README.md +++ b/README.md @@ -2,23 +2,29 @@ **WIP**: A proof of concept for a CSAF trusted provider, checker and aggregator. - ## Setup - A recent version of **Go** (1.17+) should be installed. [Go installation](https://go.dev/doc/install) - Clone the repository `git clone https://github.com/csaf-poc/csaf_distribution.git ` -- Build Go components - ``` bash - cd csaf_distribution - go build -v ./cmd/... -``` +- Build Go components Makefile supplies the following targets: + - Build For GNU/Linux System: `make build_linux` + - Build For Windows System (cross build): `make build_win` + - Build For both linux and windows: `make build` + - Build from a specific github tag by passing the intended tag to the `BUILDTAG` variable. + E.g. `make BUILDTAG=v1.0.0 build` or `make BUILDTAG=1 build_linux`. + The special value `1` means checking out the highest github tag for the build. + - Remove the generated binaries und their directories: `make mostlyclean` -- [Install](http://nginx.org/en/docs/install.html) **nginx** +Binaries will be placed in directories named like `bin-linux-amd64/` and `bin-windows-amd64/`. + +- [Install](https://nginx.org/en/docs/install.html) **nginx** +- To install server certificate on nginx see [docs/install-server-certificate.md](docs/install-server-certificate.md) - To configure nginx see [docs/provider-setup.md](docs/provider-setup.md) ## csaf_uploader + csaf_uploader is a command line tool that uploads CSAF documents to the trusted provider (CSAF_Provider). Following options are supported: @@ -27,13 +33,14 @@ Following options are supported: | -a, --action=[upload\|create] | Action to perform (default: upload) | | -u, --url=URL | URL of the CSAF provider (default:https://localhost/cgi-bin/csaf_provider.go) | | -t, --tlp=[csaf\|white\|green\|amber\|red] | TLP of the feed (default: csaf) | -| -x, --external-signed | CASF files are signed externally. | +| -x, --external-signed | CASF files are signed externally. Assumes .asc files beside CSAF files | | -k, --key=KEY-FILE | OpenPGP key to sign the CSAF files | | -p, --password=PASSWORD | Authentication password for accessing the CSAF provider | | -P, --passphrase=PASSPHRASE | Passphrase to unlock the OpenPGP key | | -i, --password-interactive | Enter password interactively | | -I, --passphrase-interacive | Enter passphrase interactively | | -c, --config=INI-FILE | Path to config ini file | +| --insecure | Do not check TSL certificates from provider | | -h, --help | Show help | E.g. creating the initial directiories and files diff --git a/cmd/csaf_checker/processor.go b/cmd/csaf_checker/processor.go index 3e8d403..49e4d62 100644 --- a/cmd/csaf_checker/processor.go +++ b/cmd/csaf_checker/processor.go @@ -20,6 +20,7 @@ import ( "errors" "fmt" "io" + "log" "net/http" "net/url" "regexp" @@ -43,6 +44,7 @@ type processor struct { redirects map[string]string noneTLS map[string]struct{} alreadyChecked map[string]whereType + pmdURL string pmd256 []byte pmd interface{} keys []*crypto.KeyRing @@ -119,6 +121,7 @@ func (p *processor) clean() { for k := range p.alreadyChecked { delete(p.alreadyChecked, k) } + p.pmdURL = "" p.pmd256 = nil p.pmd = nil p.keys = nil @@ -131,6 +134,7 @@ func (p *processor) clean() { p.badIndices = nil p.badChanges = nil } + func (p *processor) run(reporters []reporter, domains []string) (*Report, error) { var report Report @@ -607,7 +611,7 @@ func (p *processor) checkChanges(base string, mask whereType) error { func (p *processor) processROLIEFeeds(domain string, feeds [][]csaf.Feed) error { - base, err := url.Parse("https://" + domain + "/.well-known/csaf/") + base, err := url.Parse(p.pmdURL) if err != nil { return err } @@ -654,7 +658,10 @@ func (p *processor) checkCSAFs(domain string) error { } // No rolie feeds - base := "https://" + domain + "/.well-known/csaf" + base, err := basePath(p.pmdURL) + if err != nil { + return err + } if err := p.checkIndex(base, indexMask); err != nil && err != errContinue { return err @@ -701,50 +708,144 @@ func (p *processor) checkMissing(string) error { return nil } -func (p *processor) checkProviderMetadata(domain string) error { +var providerMetadataLocations = [...]string{ + ".well-known/csaf", + "security/data/csaf", + "advisories/csaf", + "security/csaf", +} + +// locateProviderMetadata searches for provider-metadata.json at various +// locations mentioned in "7.1.7 Requirement 7: provider-metadata.json". +func (p *processor) locateProviderMetadata( + domain string, + found func(string, io.Reader) error, +) error { client := p.httpClient() - url := "https://" + domain + "/.well-known/csaf/provider-metadata.json" + tryURL := func(url string) (bool, error) { + res, err := client.Get(url) + if err != nil || res.StatusCode != http.StatusOK || + res.Header.Get("Content-Type") != "application/json" { + // ignore this as it is expected. + return false, nil + } - use(&p.badProviderMetadatas) - - res, err := client.Get(url) - if err != nil { - p.badProviderMetadata("Fetching %s: %v.", url, err) - return errStop + if err := func() error { + defer res.Body.Close() + return found(url, res.Body) + }(); err != nil { + return false, err + } + return true, nil } - if res.StatusCode != http.StatusOK { - p.badProviderMetadata("Fetching %s failed. Status code: %d (%s)", - url, res.StatusCode, res.Status) - return errStop + for _, loc := range providerMetadataLocations { + url := "https://" + domain + "/" + loc + ok, err := tryURL(url) + if err != nil { + if err == errContinue { + continue + } + return err + } + if ok { + return nil + } } - // Calculate checksum for later comparison. - hash := sha256.New() + // Read from security.txt - if err := func() error { - defer res.Body.Close() - tee := io.TeeReader(res.Body, hash) - return json.NewDecoder(tee).Decode(&p.pmd) - }(); err != nil { - p.badProviderMetadata("Decoding JSON failed: %v", err) - return errStop - } - - p.pmd256 = hash.Sum(nil) - - errors, err := csaf.ValidateProviderMetadata(p.pmd) + path := "https://" + domain + "/.well-known/security.txt" + res, err := client.Get(path) if err != nil { return err } - if len(errors) > 0 { - p.badProviderMetadata("Validating against JSON schema failed:") - for _, msg := range errors { - p.badProviderMetadata(strings.ReplaceAll(msg, `%`, `%%`)) + + if res.StatusCode != http.StatusOK { + return err + } + + loc, err := func() (string, error) { + defer res.Body.Close() + return extractProviderURL(res.Body) + }() + + if err != nil { + log.Printf("error: %v\n", err) + return nil + } + + if loc != "" { + if _, err = tryURL(loc); err == errContinue { + err = nil } } + + return err +} + +func extractProviderURL(r io.Reader) (string, error) { + sc := bufio.NewScanner(r) + const csaf = "CSAF:" + + for sc.Scan() { + line := sc.Text() + if strings.HasPrefix(line, csaf) { + line = strings.TrimSpace(line[len(csaf):]) + if !strings.HasPrefix(line, "https://") { + return "", errors.New("CASF: found in security.txt, but does not start with https://") + } + return line, nil + } + } + if err := sc.Err(); err != nil { + return "", err + } + return "", nil +} + +func (p *processor) checkProviderMetadata(domain string) error { + + use(&p.badProviderMetadatas) + + found := func(url string, content io.Reader) error { + + // Calculate checksum for later comparison. + hash := sha256.New() + + tee := io.TeeReader(content, hash) + if err := json.NewDecoder(tee).Decode(&p.pmd); err != nil { + p.badProviderMetadata("%s: Decoding JSON failed: %v", url, err) + return errContinue + } + + p.pmd256 = hash.Sum(nil) + + errors, err := csaf.ValidateProviderMetadata(p.pmd) + if err != nil { + return err + } + if len(errors) > 0 { + p.badProviderMetadata("%s: Validating against JSON schema failed:", url) + for _, msg := range errors { + p.badProviderMetadata(strings.ReplaceAll(msg, `%`, `%%`)) + } + return errStop + } + p.pmdURL = url + return nil + } + + if err := p.locateProviderMetadata(domain, found); err != nil { + return err + } + + if p.pmdURL == "" { + p.badProviderMetadata("No provider-metadata.json found.") + return errStop + } return nil } @@ -851,7 +952,7 @@ func (p *processor) checkPGPKeys(domain string) error { client := p.httpClient() - base, err := url.Parse("https://" + domain + "/.well-known/csaf/provider-metadata.json") + base, err := url.Parse(p.pmdURL) if err != nil { return err } diff --git a/cmd/csaf_provider/actions.go b/cmd/csaf_provider/actions.go index 652e0dd..29f07ef 100644 --- a/cmd/csaf_provider/actions.go +++ b/cmd/csaf_provider/actions.go @@ -29,6 +29,8 @@ import ( const dateFormat = time.RFC3339 +// cleanFileName removes the "/" "\" charachters and replace the two or more +// occurences of "." with only one from the passed string. func cleanFileName(s string) string { s = strings.ReplaceAll(s, `/`, ``) s = strings.ReplaceAll(s, `\`, ``) @@ -37,6 +39,10 @@ func cleanFileName(s string) string { return s } +// loadCSAF loads the csaf file from the request, calls the "UploadLimter" function to +// set the upload limit size of the file and the "cleanFileName" to refine +// the filename. It returns the filename, file content in a buffer of bytes +// and an error. func (c *controller) loadCSAF(r *http.Request) (string, []byte, error) { file, handler, err := r.FormFile("csaf") if err != nil { @@ -123,6 +129,8 @@ func (c *controller) tlpParam(r *http.Request) (tlp, error) { return "", fmt.Errorf("unsupported TLP type '%s'", t) } +// create calls the "ensureFolders" functions to create the directories and files. +// It returns a struct by success, otherwise an error. func (c *controller) create(*http.Request) (interface{}, error) { if err := ensureFolders(c.cfg); err != nil { return nil, err diff --git a/cmd/csaf_provider/config.go b/cmd/csaf_provider/config.go index 65e1dce..920d5d6 100644 --- a/cmd/csaf_provider/config.go +++ b/cmd/csaf_provider/config.go @@ -21,14 +21,16 @@ import ( ) const ( + // The environment name, that contains the path to the config file. configEnv = "CSAF_CONFIG" - defaultConfigPath = "/usr/lib/casf/config.toml" - defaultFolder = "/var/www/" - defaultWeb = "/var/www/html" - defaultOpenPGPURL = "https://openpgp.circl.lu/pks/lookup?op=get&search=${FINGERPRINT}" - defaultUploadLimit = 50 * 1024 * 1024 + defaultConfigPath = "/usr/lib/casf/config.toml" // Default path to the config file. + defaultFolder = "/var/www/" // Default folder path. + defaultWeb = "/var/www/html" // Default web path. + defaultOpenPGPURL = "https://openpgp.circl.lu/pks/lookup?op=get&search=${FINGERPRINT}" // Default OpenPGP URL. + defaultUploadLimit = 50 * 1024 * 1024 // Default limit size of the uploaded file. ) +// configs contains the config values for the provider. type config struct { Password *string `toml:"password"` Key string `toml:"key"` @@ -56,6 +58,7 @@ const ( tlpRed tlp = "red" ) +// valid returns true if the checked tlp matches one of the defined tlps. func (t tlp) valid() bool { switch t { case tlpCSAF, tlpWhite, tlpGreen, tlpAmber, tlpRed: @@ -73,6 +76,8 @@ func (t *tlp) UnmarshalText(text []byte) error { return fmt.Errorf("invalid config TLP value: %v", string(text)) } +// uploadLimiter returns a reader that reads from a given r reader but stops +// with EOF after the defined bytes in the "UploadLimit" config option. func (cfg *config) uploadLimiter(r io.Reader) io.Reader { // Zero or less means no upload limit. if cfg.UploadLimit == nil || *cfg.UploadLimit < 1 { @@ -100,6 +105,8 @@ func (cfg *config) modelTLPs() []csaf.TLPLabel { return tlps } +// loadCryptoKey loads the armored data into the key stored in the file specified by the +// "key" config value and return it with nil, otherwise an error. func (cfg *config) loadCryptoKey() (*crypto.Key, error) { f, err := os.Open(cfg.Key) if err != nil { @@ -109,11 +116,18 @@ func (cfg *config) loadCryptoKey() (*crypto.Key, error) { return crypto.NewKeyFromArmoredReader(f) } +// checkPassword compares the given hashed password with the plaintext in the "password" config value. +// It returns true if these matches or if the "password" config value is not set, otherwise false. func (cfg *config) checkPassword(hash string) bool { return cfg.Password == nil || bcrypt.CompareHashAndPassword([]byte(hash), []byte(*cfg.Password)) == nil } +// loadConfig extracts the config values from the config file. The path to the +// file is taken either from environment variable "CSAF_CONFIG" or from the +// defined default path in "defaultConfigPath". +// Default values are set in case some are missing in the file. +// It returns these values in a struct and nil if there is no error. func loadConfig() (*config, error) { path := os.Getenv(configEnv) if path == "" { diff --git a/cmd/csaf_provider/controller.go b/cmd/csaf_provider/controller.go index 5ea05f4..a5636be 100644 --- a/cmd/csaf_provider/controller.go +++ b/cmd/csaf_provider/controller.go @@ -37,11 +37,14 @@ func asMultiError(err error) multiError { return multiError([]string{err.Error()}) } +// controller contains the config values and the html templates. type controller struct { cfg *config tmpl *template.Template } +// newController assigns the given configs to a controller variable and parses the html template +// if the config value "NoWebUI" is true. It returns the controller variable and nil, otherwise error. func newController(cfg *config) (*controller, error) { c := controller{cfg: cfg} @@ -56,6 +59,8 @@ func newController(cfg *config) (*controller, error) { return &c, nil } +// bind binds the paths with the corresponding http.handler and wraps it with the respective middleware, +// according to the "NoWebUI" config value. func (c *controller) bind(pim *pathInfoMux) { if !c.cfg.NoWebUI { pim.handleFunc("/", c.auth(c.index)) @@ -66,6 +71,9 @@ func (c *controller) bind(pim *pathInfoMux) { pim.handleFunc("/api/create", c.auth(api(c.create))) } +// auth wraps the given http.HandlerFunc and returns an new one after authenticating the +// password contained in the header "X-CSAF-PROVIDER-AUTH" with the "password" config value +// if set, otherwise returns the given http.HandlerFunc. func (c *controller) auth( fn func(http.ResponseWriter, *http.Request), ) func(http.ResponseWriter, *http.Request) { @@ -83,6 +91,9 @@ func (c *controller) auth( } } +// render sets the headers for the response. It applies the given template "tmpl" to +// the given object "arg" and writes the output to http.ResponseWriter. +// It logs a warning in case of error. func (c *controller) render(rw http.ResponseWriter, tmpl string, arg interface{}) { rw.Header().Set("Content-type", "text/html; charset=utf-8") rw.Header().Set("X-Content-Type-Options", "nosniff") @@ -91,17 +102,24 @@ func (c *controller) render(rw http.ResponseWriter, tmpl string, arg interface{} } } +// failed constructs the error messages by calling "asMultiError" and calls "render" +// function to render the passed template and error object. func (c *controller) failed(rw http.ResponseWriter, tmpl string, err error) { result := map[string]interface{}{"Error": asMultiError(err)} c.render(rw, tmpl, result) } +// index calls the "render" function and passes the "index.html" and c.cfg to it. func (c *controller) index(rw http.ResponseWriter, r *http.Request) { c.render(rw, "index.html", map[string]interface{}{ "Config": c.cfg, }) } +// web executes the given function "fn", calls the "render" function and passes +// the result content from "fn", the given template and the http.ResponseWriter to it +// in case of no error occurred, otherwise calls the "failed" function and passes the given +// template and the error from "fn". func (c *controller) web( fn func(*http.Request) (interface{}, error), tmpl string, @@ -116,6 +134,8 @@ func (c *controller) web( } } +// writeJSON sets the header for the response and writes the JSON encoding of the given "content". +// It logs out an error message in case of an error. func writeJSON(rw http.ResponseWriter, content interface{}, code int) { rw.Header().Set("Content-type", "application/json; charset=utf-8") rw.Header().Set("X-Content-Type-Options", "nosniff") diff --git a/cmd/csaf_provider/create.go b/cmd/csaf_provider/create.go index 7507dfd..b4bf281 100644 --- a/cmd/csaf_provider/create.go +++ b/cmd/csaf_provider/create.go @@ -18,6 +18,8 @@ import ( "github.com/csaf-poc/csaf_distribution/util" ) +// ensureFolders initializes the paths and call functions to create +// the directories and files. func ensureFolders(c *config) error { wellknown := filepath.Join(c.Web, ".well-known") @@ -38,6 +40,8 @@ func ensureFolders(c *config) error { return createSecurity(c, wellknown) } +// createWellknown creates ".well-known" directory if not exist and returns nil. +// An error is returned if the it is not a directory. func createWellknown(wellknown string) error { st, err := os.Stat(wellknown) if err != nil { @@ -52,6 +56,10 @@ func createWellknown(wellknown string) error { return nil } +// createFeedFolders creates the feed folders according to the tlp values +// in the "tlps" config option if they do not already exist. +// No creation for the "csaf" option will be done. +// It creates also symbolic links to feed folders. func createFeedFolders(c *config, wellknown string) error { for _, t := range c.TLPs { if t == tlpCSAF { @@ -75,6 +83,8 @@ func createFeedFolders(c *config, wellknown string) error { return nil } +// createSecurity creats the "security.txt" file if does not exist +// and writes the CSAF field inside the file. func createSecurity(c *config, wellknown string) error { security := filepath.Join(wellknown, "security.txt") if _, err := os.Stat(security); err != nil { @@ -93,6 +103,7 @@ func createSecurity(c *config, wellknown string) error { return nil } +// createProviderMetadata creates the provider-metadata.json file if does not exist. func createProviderMetadata(c *config, wellknownCSAF string) error { path := filepath.Join(wellknownCSAF, "provider-metadata.json") _, err := os.Stat(path) diff --git a/csaf/validation.go b/csaf/validation.go index f171234..c22906a 100644 --- a/csaf/validation.go +++ b/csaf/validation.go @@ -121,9 +121,15 @@ func (cs *compiledSchema) validate(doc interface{}) ([]string, error) { res := make([]string, 0, len(errs)) for i := range errs { - if e := &errs[i]; e.InstanceLocation != "" && e.Error != "" { - res = append(res, e.InstanceLocation+": "+e.Error) + e := &errs[i] + if e.Error == "" { + continue } + loc := e.InstanceLocation + if loc == "" { + loc = e.AbsoluteKeywordLocation + } + res = append(res, loc+": "+e.Error) } return res, nil diff --git a/docs/install-server-certificate.md b/docs/install-server-certificate.md new file mode 100644 index 0000000..94b0340 --- /dev/null +++ b/docs/install-server-certificate.md @@ -0,0 +1,72 @@ +# Configure TLS Certificate for HTTPS + +## Get a webserver TLS certificate + +There are three ways to get a TLS certificate for your HTTPS server: + 1. Get it from a certificate provider who will run a certificate + authority (CA) and also offers + [extended validation](https://en.wikipedia.org/wiki/Extended_Validation_Certificate) (EV) + for the certificate. This will cost a fee. + If possible, create the private key yourself, + then send a Certificate Signing Request (CSR). + Overall follow the documentation of the CA operator. + 2. Get a domain validated TLS certificate via + [Let's encrypt](https://letsencrypt.org/) without a fee. + See their instruction, e.g. + [certbot for nignx on Ubuntu](https://certbot.eff.org/instructions?ws=nginx&os=ubuntufocal). + 3. Run your own little CA. Which has the major drawback that someone + will have to import the root certificate in the webbrowsers manually. + Suitable for development purposes. + +To decide between 1. and 2. you will need to weight the extra +efforts and costs of the level of extended validation against +a bit of extra trust for the security advisories +that will be served under the domain. + + +## Install the files for ngnix + +Place the certificates on the server machine. +This includes the certificate for your webserver, the intermediate +certificates and the root certificate. The latter may already be on your +machine as part of the trust anchors for webbrowsers. + +Follow the [nginx documentation](https://docs.nginx.com/nginx/admin-guide/security-controls/terminating-ssl-http/) +to further configure TLS with your private key and the certificates. + +We recommend to + * restrict the TLS protocol version and ciphers following a current + recommendation (e.g. [BSI-TR-02102-2](https://www.bsi.bund.de/SharedDocs/Downloads/EN/BSI/Publications/TechGuidelines/TG02102/BSI-TR-02102-2.html)). + + +### Example configuration + +Assuming the relevant server block is in `/etc/nginx/sites-enabled/default`, +change the `listen` configuration and add options so nginx +finds your your private key and the certificate chain. + +```nginx +server { + listen 443 ssl http2 default_server; # ipv4 + listen [::]:443 ssl http2 default_server; # ipv6 + server_name www.example.com + + ssl_certificate /etc/ssl/{domainName}.pem; # or bundle.crt + ssl_certificate_key /etc/ssl/{domainName}.key"; + + ssl_protocols TLSv1.2 TLSv1.3; + # Other Config + # ... +} +``` + +Replace `{domainName}` with the name for your certificate in the example. + +Reload or restart nginx to apply the changes (e.g. `systemctl reload nginx` +on Debian or Ubuntu.) + +Technical hints: + * When allowing or requiring `TLSv1.3` webbrowsers like +Chromium (seen with version 98) may have higher requirements +on the server certificates they allow, +otherwise they do not connect with `ERR_SSL_KEY_USAGE_INCOMPATIBLE`. diff --git a/docs/provider-setup.md b/docs/provider-setup.md index da47fca..d737f03 100644 --- a/docs/provider-setup.md +++ b/docs/provider-setup.md @@ -7,7 +7,7 @@ The following instructions are for an Debian 11 server setup. ```(shell) apt-get install nginx fcgiwrap cp /usr/share/doc/fcgiwrap/examples/nginx.conf /etc/nginx/fcgiwrap.conf -systemctl status fcgiwrap.servic +systemctl status fcgiwrap.service systemctl status fcgiwrap.socket systemctl is-enabled fcgiwrap.service systemctl is-enabled fcgiwrap.socket