diff --git a/cmd/csaf_checker/main.go b/cmd/csaf_checker/main.go index 9fd0fa0..eafa9c8 100644 --- a/cmd/csaf_checker/main.go +++ b/cmd/csaf_checker/main.go @@ -74,6 +74,14 @@ func (o *options) prepare() error { return nil } +// protectedAccess returns true if we have client certificates or +// extra http headers configured. +// This may be a wrong assumption, because the certs are not checked +// for their domain and custom headers may have other purposes. +func (o *options) protectedAccess() bool { + return len(o.clientCerts) > 0 || len(o.ExtraHeader) > 0 +} + // writeJSON writes the JSON encoding of the given report to the given stream. // It returns nil, otherwise an error. func writeJSON(report *Report, w io.WriteCloser) error { diff --git a/cmd/csaf_checker/processor.go b/cmd/csaf_checker/processor.go index 38644c9..64f0db2 100644 --- a/cmd/csaf_checker/processor.go +++ b/cmd/csaf_checker/processor.go @@ -54,20 +54,24 @@ type processor struct { keys *crypto.KeyRing labelChecker *rolieLabelChecker - invalidAdvisories topicMessages - badFilenames topicMessages - badIntegrities topicMessages - badPGPs topicMessages - badSignatures topicMessages - badProviderMetadata topicMessages - badSecurity topicMessages - badIndices topicMessages - badChanges topicMessages - badFolders topicMessages - badWellknownMetadata topicMessages - badDNSPath topicMessages - badDirListings topicMessages - badROLIEfeed topicMessages + invalidAdvisories topicMessages + badFilenames topicMessages + badIntegrities topicMessages + badPGPs topicMessages + badSignatures topicMessages + badProviderMetadata topicMessages + badSecurity topicMessages + badIndices topicMessages + badChanges topicMessages + badFolders topicMessages + badWellknownMetadata topicMessages + badDNSPath topicMessages + badDirListings topicMessages + badROLIEFeed topicMessages + badROLIEService topicMessages + badROLIECategory topicMessages + badWhitePermissions topicMessages + badAmberRedPermissions topicMessages expr *util.PathEval } @@ -149,6 +153,19 @@ func (m *topicMessages) reset() { *m = nil } // used returns true if we have used this topic. func (m *topicMessages) used() bool { return *m != nil } +// hasErrors checks if there are any error messages. +func (m *topicMessages) hasErrors() bool { + if !m.used() { + return false + } + for _, msg := range *m { + if msg.Type == ErrorType { + return true + } + } + return false +} + // newProcessor returns a processor structure after assigning the given options to the opts attribute // and initializing the "alreadyChecked" and "expr" fields. func newProcessor(opts *options) (*processor, error) { @@ -220,7 +237,11 @@ func (p *processor) clean() { p.badWellknownMetadata.reset() p.badDNSPath.reset() p.badDirListings.reset() - p.badROLIEfeed.reset() + p.badROLIEFeed.reset() + p.badROLIEService.reset() + p.badROLIECategory.reset() + p.badWhitePermissions.reset() + p.badAmberRedPermissions.reset() p.labelChecker = nil } @@ -254,10 +275,27 @@ func (p *processor) run(domains []string) (*Report, error) { continue } - for _, r := range buildReporters(*domain.Role) { + if domain.Role == nil { + log.Printf("No role found in meta data. Ignoring domain %q\n", d) + continue + } + + rules := roleRequirements(*domain.Role) + // TODO: store error base on rules eval in report. + if rules == nil { + log.Printf( + "WARN: Cannot find requirement rules for role %q. Assuming trusted provider.\n", + *domain.Role) + rules = trustedProviderRules + } + + // 18, 19, 20 should always be checked. + for _, r := range rules.reporters([]int{18, 19, 20}) { r.report(p, domain) } + domain.Passed = rules.eval(p) + report.Domains = append(report.Domains, domain) p.clean() } @@ -369,12 +407,8 @@ func (p *processor) checkRedirect(r *http.Request, via []*http.Request) error { return nil } -func (p *processor) httpClient() util.Client { - - if p.client != nil { - return p.client - } - +// fullClient returns a fully configure HTTP client. +func (p *processor) fullClient() util.Client { hClient := http.Client{} hClient.CheckRedirect = p.checkRedirect @@ -414,8 +448,29 @@ func (p *processor) httpClient() util.Client { Limiter: rate.NewLimiter(rate.Limit(*p.opts.Rate), 1), } } + return client +} - p.client = client +// basicClient returns a http Client w/o certs and headers. +func (p *processor) basicClient() *http.Client { + if p.opts.Insecure { + tr := &http.Transport{ + TLSClientConfig: &tls.Config{InsecureSkipVerify: true}, + } + return &http.Client{Transport: tr} + } + return &http.Client{} +} + +// httpClient returns a cached HTTP client to be used to +// download remote ressources. +func (p *processor) httpClient() util.Client { + + if p.client != nil { + return p.client + } + + p.client = p.fullClient() return p.client } @@ -655,11 +710,21 @@ func (p *processor) integrity( // Extract the tlp level of the entry if tlpa, err := p.expr.Eval( - `$.document.distribution`, doc); err != nil { - p.badROLIEfeed.error( + `$.document`, doc); err != nil { + p.badROLIEFeed.error( "Extracting 'tlp level' from %s failed: %v", u, err) } else { tlpe := extractTLP(tlpa) + // If the client has no authorization it shouldn't be able + // to access TLP:AMBER or TLP:RED advisories + if !p.opts.protectedAccess() && + (tlpe == csaf.TLPLabelAmber || tlpe == csaf.TLPLabelRed) { + + p.badAmberRedPermissions.use() + p.badAmberRedPermissions.error( + "Advisory %s of TLP level %v is not access protected.", + u, tlpe) + } // check if current feed has correct or all of their tlp levels entries. if p.labelChecker != nil { p.labelChecker.check(p, tlpe, u) @@ -781,11 +846,15 @@ func (p *processor) integrity( // extractTLP tries to extract a valid TLP label from an advisory // Returns "UNLABELED" if it does not exist, the label otherwise func extractTLP(tlpa any) csaf.TLPLabel { - if distribution, ok := tlpa.(map[string]any); ok { - if tlp, ok := distribution["tlp"]; ok { - if label, ok := tlp.(map[string]any); ok { - if labelstring, ok := label["label"].(string); ok { - return csaf.TLPLabel(labelstring) + if document, ok := tlpa.(map[string]any); ok { + if distri, ok := document["distribution"]; ok { + if distribution, ok := distri.(map[string]any); ok { + if tlp, ok := distribution["tlp"]; ok { + if label, ok := tlp.(map[string]any); ok { + if labelstring, ok := label["label"].(string); ok { + return csaf.TLPLabel(labelstring) + } + } } } } @@ -979,6 +1048,8 @@ func (p *processor) checkCSAFs(_ string) error { return err } } + // check for service category document + p.serviceCheck(feeds) } // No rolie feeds -> try directory_urls. diff --git a/cmd/csaf_checker/report.go b/cmd/csaf_checker/report.go index 269da00..07c8d9c 100644 --- a/cmd/csaf_checker/report.go +++ b/cmd/csaf_checker/report.go @@ -46,6 +46,7 @@ type Domain struct { Publisher *csaf.Publisher `json:"publisher,omitempty"` Role *csaf.MetadataRole `json:"role,omitempty"` Requirements []*Requirement `json:"requirements,omitempty"` + Passed bool `json:"passed"` } // ReportTime stores the time of the report. @@ -80,12 +81,7 @@ func (r *Requirement) Append(msgs []Message) { // HasErrors tells if this domain has errors. func (d *Domain) HasErrors() bool { - for _, r := range d.Requirements { - if r.HasErrors() { - return true - } - } - return false + return !d.Passed } // String implements fmt.Stringer interface. diff --git a/cmd/csaf_checker/reporters.go b/cmd/csaf_checker/reporters.go index 493732e..dfafc6c 100644 --- a/cmd/csaf_checker/reporters.go +++ b/cmd/csaf_checker/reporters.go @@ -12,8 +12,6 @@ import ( "fmt" "sort" "strings" - - "github.com/csaf-poc/csaf_distribution/v2/csaf" ) type ( @@ -46,70 +44,30 @@ type ( mirrorReporter struct{ baseReporter } ) -var reporters = [23]reporter{ - &validReporter{baseReporter{num: 1, description: "Valid CSAF documents"}}, - &filenameReporter{baseReporter{num: 2, description: "Filename"}}, - &tlsReporter{baseReporter{num: 3, description: "TLS"}}, - &tlpWhiteReporter{baseReporter{num: 4, description: "TLP:WHITE"}}, - &tlpAmberRedReporter{baseReporter{num: 5, description: "TLP:AMBER and TLP:RED"}}, - &redirectsReporter{baseReporter{num: 6, description: "Redirects"}}, - &providerMetadataReport{baseReporter{num: 7, description: "provider-metadata.json"}}, - &securityReporter{baseReporter{num: 8, description: "security.txt"}}, - &wellknownMetadataReporter{baseReporter{num: 9, description: "/.well-known/csaf/provider-metadata.json"}}, - &dnsPathReporter{baseReporter{num: 10, description: "DNS path"}}, - &oneFolderPerYearReport{baseReporter{num: 11, description: "One folder per year"}}, - &indexReporter{baseReporter{num: 12, description: "index.txt"}}, - &changesReporter{baseReporter{num: 13, description: "changes.csv"}}, - &directoryListingsReporter{baseReporter{num: 14, description: "Directory listings"}}, - &rolieFeedReporter{baseReporter{num: 15, description: "ROLIE feed"}}, - &rolieServiceReporter{baseReporter{num: 16, description: "ROLIE service document"}}, - &rolieCategoryReporter{baseReporter{num: 17, description: "ROLIE category document"}}, - &integrityReporter{baseReporter{num: 18, description: "Integrity"}}, - &signaturesReporter{baseReporter{num: 19, description: "Signatures"}}, - &publicPGPKeyReporter{baseReporter{num: 20, description: "Public OpenPGP Key"}}, - &listReporter{baseReporter{num: 21, description: "List of CSAF providers"}}, - &hasTwoReporter{baseReporter{num: 22, description: "Two disjoint issuing parties"}}, - &mirrorReporter{baseReporter{num: 23, description: "Mirror"}}, -} - -var roleImplies = map[csaf.MetadataRole][]csaf.MetadataRole{ - csaf.MetadataRoleProvider: {csaf.MetadataRolePublisher}, - csaf.MetadataRoleTrustedProvider: {csaf.MetadataRoleProvider}, -} - -func requirements(role csaf.MetadataRole) [][2]int { - var own [][2]int - switch role { - case csaf.MetadataRoleTrustedProvider: - own = [][2]int{{18, 20}} - case csaf.MetadataRoleProvider: - // TODO: use commented numbers when TLPs should be checked. - own = [][2]int{{6 /* 5 */, 7}, {8, 10}, {11, 14}, {15, 17}} - case csaf.MetadataRolePublisher: - own = [][2]int{{1, 3 /* 4 */}} - } - for _, base := range roleImplies[role] { - own = append(own, requirements(base)...) - } - return own -} - -// buildReporters initializes each report by assigning a number and description to it. -// It returns an array of the reporter interface type. -func buildReporters(role csaf.MetadataRole) []reporter { - var reps []reporter - reqs := requirements(role) - // sort to have them ordered by there number. - sort.Slice(reqs, func(i, j int) bool { return reqs[i][0] < reqs[j][0] }) - for _, req := range reqs { - from, to := req[0]-1, req[1]-1 - for i := from; i <= to; i++ { - if rep := reporters[i]; rep != nil { - reps = append(reps, rep) - } - } - } - return reps +var reporters = [...]reporter{ + 1: &validReporter{baseReporter{num: 1, description: "Valid CSAF documents"}}, + 2: &filenameReporter{baseReporter{num: 2, description: "Filename"}}, + 3: &tlsReporter{baseReporter{num: 3, description: "TLS"}}, + 4: &tlpWhiteReporter{baseReporter{num: 4, description: "TLP:WHITE"}}, + 5: &tlpAmberRedReporter{baseReporter{num: 5, description: "TLP:AMBER and TLP:RED"}}, + 6: &redirectsReporter{baseReporter{num: 6, description: "Redirects"}}, + 7: &providerMetadataReport{baseReporter{num: 7, description: "provider-metadata.json"}}, + 8: &securityReporter{baseReporter{num: 8, description: "security.txt"}}, + 9: &wellknownMetadataReporter{baseReporter{num: 9, description: "/.well-known/csaf/provider-metadata.json"}}, + 10: &dnsPathReporter{baseReporter{num: 10, description: "DNS path"}}, + 11: &oneFolderPerYearReport{baseReporter{num: 11, description: "One folder per year"}}, + 12: &indexReporter{baseReporter{num: 12, description: "index.txt"}}, + 13: &changesReporter{baseReporter{num: 13, description: "changes.csv"}}, + 14: &directoryListingsReporter{baseReporter{num: 14, description: "Directory listings"}}, + 15: &rolieFeedReporter{baseReporter{num: 15, description: "ROLIE feed"}}, + 16: &rolieServiceReporter{baseReporter{num: 16, description: "ROLIE service document"}}, + 17: &rolieCategoryReporter{baseReporter{num: 17, description: "ROLIE category document"}}, + 18: &integrityReporter{baseReporter{num: 18, description: "Integrity"}}, + 19: &signaturesReporter{baseReporter{num: 19, description: "Signatures"}}, + 20: &publicPGPKeyReporter{baseReporter{num: 20, description: "Public OpenPGP Key"}}, + 21: &listReporter{baseReporter{num: 21, description: "List of CSAF providers"}}, + 22: &hasTwoReporter{baseReporter{num: 22, description: "Two disjoint issuing parties"}}, + 23: &mirrorReporter{baseReporter{num: 23, description: "Mirror"}}, } func (bc *baseReporter) requirement(domain *Domain) *Requirement { @@ -194,16 +152,34 @@ func (r *tlsReporter) report(p *processor, domain *Domain) { // report tests if a document labeled TLP:WHITE // is freely accessible and sets the "message" field value // of the "Requirement" struct as a result of that. -func (r *tlpWhiteReporter) report(_ *processor, _ *Domain) { - // TODO +func (r *tlpWhiteReporter) report(p *processor, domain *Domain) { + req := r.requirement(domain) + if !p.badWhitePermissions.used() { + req.message(InfoType, "No advisories labeled TLP:WHITE tested for accessibility.") + return + } + if len(p.badWhitePermissions) == 0 { + req.message(InfoType, "All advisories labeled TLP:WHITE were freely accessible.") + return + } + req.Messages = p.badWhitePermissions } // report tests if a document labeled TLP:AMBER // or TLP:RED is access protected // and sets the "message" field value // of the "Requirement" struct as a result of that. -func (r *tlpAmberRedReporter) report(_ *processor, _ *Domain) { - // TODO +func (r *tlpAmberRedReporter) report(p *processor, domain *Domain) { + req := r.requirement(domain) + if !p.badAmberRedPermissions.used() { + req.message(InfoType, "No advisories labeled TLP:AMBER or TLP:RED tested for accessibility.") + return + } + if len(p.badAmberRedPermissions) == 0 { + req.message(InfoType, "All tested advisories labeled TLP:WHITE or TLP:RED were access-protected.") + return + } + req.Messages = p.badAmberRedPermissions } // report tests if redirects are used and sets the "message" field value @@ -366,23 +342,33 @@ func (r *directoryListingsReporter) report(p *processor, domain *Domain) { // of the "Requirement" struct as a result of that. func (r *rolieFeedReporter) report(p *processor, domain *Domain) { req := r.requirement(domain) - if !p.badROLIEfeed.used() { + if !p.badROLIEFeed.used() { req.message(InfoType, "No checks on the validity of ROLIE feeds performed.") return } - if len(p.badROLIEfeed) == 0 { + if len(p.badROLIEFeed) == 0 { req.message(InfoType, "All checked ROLIE feeds validated fine.") return } - req.Messages = p.badROLIEfeed + req.Messages = p.badROLIEFeed } // report tests whether a ROLIE service document is used and if so, // whether it is a [RFC8322] conform JSON file that lists the // ROLIE feed documents and sets the "message" field value // of the "Requirement" struct as a result of that. -func (r *rolieServiceReporter) report(_ *processor, _ *Domain) { - // TODO +func (r *rolieServiceReporter) report(p *processor, domain *Domain) { + req := r.requirement(domain) + if !p.badROLIEService.used() { + req.message(InfoType, "ROLIE service document was not checked.") + return + } + if len(p.badROLIEService) == 0 { + req.message(InfoType, "ROLIE service document validated fine.") + return + } + req.Messages = p.badROLIEService + } // report tests whether a ROLIE category document is used and if so, @@ -390,8 +376,18 @@ func (r *rolieServiceReporter) report(_ *processor, _ *Domain) { // documents by certain criteria // and sets the "message" field value // of the "Requirement" struct as a result of that. -func (r *rolieCategoryReporter) report(_ *processor, _ *Domain) { - // TODO +func (r *rolieCategoryReporter) report(p *processor, domain *Domain) { + req := r.requirement(domain) + if !p.badROLIECategory.used() { + req.message(InfoType, "No checks on the existence of ROLIE category documents performed.") + return + } + if len(p.badROLIECategory) == 0 { + req.message(InfoType, "All checked ROLIE category documents exist.") + return + } + req.Messages = p.badROLIECategory + } func (r *integrityReporter) report(p *processor, domain *Domain) { diff --git a/cmd/csaf_checker/roliecheck.go b/cmd/csaf_checker/roliecheck.go index 9dcdf11..fe217d6 100644 --- a/cmd/csaf_checker/roliecheck.go +++ b/cmd/csaf_checker/roliecheck.go @@ -9,7 +9,10 @@ package main import ( + "net/http" "net/url" + "sort" + "strings" "github.com/csaf-poc/csaf_distribution/v2/csaf" "github.com/csaf-poc/csaf_distribution/v2/util" @@ -21,7 +24,8 @@ type rolieLabelChecker struct { feedURL string feedLabel csaf.TLPLabel - advisories map[csaf.TLPLabel]map[string]struct{} + advisories map[csaf.TLPLabel]util.Set[string] + openClient util.Client } // tlpLevel returns an inclusion order of TLP colors. @@ -65,21 +69,21 @@ func (ca *rolieLabelChecker) check( // Associate advisory label to urls. advs := ca.advisories[advisoryLabel] if advs == nil { - advs = make(map[string]struct{}) + advs = util.Set[string]{} ca.advisories[advisoryLabel] = advs } - advs[advisory] = struct{}{} + advs.Add(advisory) // If entry shows up in feed of higher tlp level, // give out info or warning switch { case advisoryRank < feedRank: if advisoryRank == 0 { // All kinds of 'UNLABELED' - p.badROLIEfeed.info( + p.badROLIEFeed.info( "Found unlabeled advisory %q in feed %q.", advisory, ca.feedURL) } else { - p.badROLIEfeed.warn( + p.badROLIEFeed.warn( "Found advisory %q labled TLP:%s in feed %q (TLP:%s).", advisory, advisoryLabel, ca.feedURL, ca.feedLabel) @@ -87,21 +91,57 @@ func (ca *rolieLabelChecker) check( case advisoryRank > feedRank: // Must not happen, give error - p.badROLIEfeed.error( + p.badROLIEFeed.error( "%s of TLP level %s must not be listed in feed %s of TLP level %s", advisory, advisoryLabel, ca.feedURL, ca.feedLabel) } + + // If we have an open client then the actual data was downloaded + // through an authorizing client. + if ca.openClient != nil { + switch { + // If we are checking WHITE and we have a test client + // and we get a status forbidden then the access is not open. + case ca.feedLabel == csaf.TLPLabelWhite: + p.badWhitePermissions.use() + res, err := ca.openClient.Get(advisory) + if err != nil { + p.badWhitePermissions.error( + "Unexpected Error %v when trying to fetch: %s", err, advisory) + } else if res.StatusCode == http.StatusForbidden { + p.badWhitePermissions.error( + "Advisory %s of TLP level WHITE is access protected.", advisory) + } + + // If we are checking AMBER or above we need to download + // the data again with the open client. + // If this does not result in status forbidden the + // server may be wrongly configured. + case ca.feedLabel >= csaf.TLPLabelAmber: + p.badAmberRedPermissions.use() + res, err := ca.openClient.Get(advisory) + if err != nil { + p.badAmberRedPermissions.error( + "Unexpected Error %v when trying to fetch: %s", err, advisory) + } else if res.StatusCode == http.StatusOK { + p.badAmberRedPermissions.error( + "Advisory %s of TLP level %v is not properly access protected.", + advisory, advisoryLabel) + + } + } + } } -// processROLIEFeeds goes through all ROLIE feeds and checks there -// integriry and completeness. +// processROLIEFeeds goes through all ROLIE feeds and checks their +// integrity and completeness. func (p *processor) processROLIEFeeds(feeds [][]csaf.Feed) error { base, err := url.Parse(p.pmdURL) if err != nil { return err } - p.badROLIEfeed.use() + p.badROLIEFeed.use() advisories := map[*csaf.Feed][]csaf.AdvisoryFile{} @@ -132,6 +172,10 @@ func (p *processor) processROLIEFeeds(feeds [][]csaf.Feed) error { } } + p.labelChecker = &rolieLabelChecker{ + advisories: map[csaf.TLPLabel]util.Set[string]{}, + } + // Phase 2: check for integrity. for _, fs := range feeds { for i := range fs { @@ -158,13 +202,30 @@ func (p *processor) processROLIEFeeds(feeds [][]csaf.Feed) error { } label := tlpLabel(feed.TLPLabel) - - p.labelChecker = &rolieLabelChecker{ - feedURL: feedURL.String(), - feedLabel: label, - advisories: map[csaf.TLPLabel]map[string]struct{}{}, + if err := p.categoryCheck(feedBase, label); err != nil { + if err != errContinue { + return err + } } + p.labelChecker.feedURL = feedURL.String() + p.labelChecker.feedLabel = label + + // If we are using an authorizing client + // we need an open client to check + // WHITE, AMBER and RED feeds. + var openClient util.Client + if (label == csaf.TLPLabelWhite || label >= csaf.TLPLabelAmber) && + p.opts.protectedAccess() { + openClient = p.basicClient() + } + p.labelChecker.openClient = openClient + + // TODO: Issue a warning if we want check AMBER+ without an + // authorizing client. + + // TODO: Complete criteria for requirement 4. + if err := p.integrity(files, feedBase, rolieMask, p.badProviderMetadata.add); err != nil { if err != errContinue { return err @@ -175,7 +236,7 @@ func (p *processor) processROLIEFeeds(feeds [][]csaf.Feed) error { // Phase 3: Check for completeness. - hasSummary := map[csaf.TLPLabel]struct{}{} + hasSummary := util.Set[csaf.TLPLabel]{} var ( hasUnlabeled = false @@ -214,24 +275,25 @@ func (p *processor) processROLIEFeeds(feeds [][]csaf.Feed) error { } reference := p.labelChecker.advisories[label] - advisories := make(map[string]struct{}, len(reference)) + advisories := make(util.Set[string], len(reference)) for _, adv := range files { u, err := url.Parse(adv.URL()) if err != nil { - p.badProviderMetadata.error("Invalid URL %s in feed: %v.", *feed.URL, err) + p.badProviderMetadata.error( + "Invalid URL %s in feed: %v.", *feed.URL, err) continue } advisories[makeAbs(u).String()] = struct{}{} } - if containsAllKeys(reference, advisories) { - hasSummary[label] = struct{}{} + if advisories.ContainsAll(reference) { + hasSummary.Add(label) } } } if !hasWhite && !hasGreen && !hasUnlabeled { - p.badROLIEfeed.error( + p.badROLIEFeed.error( "One ROLIE feed with a TLP:WHITE, TLP:GREEN or unlabeled tlp must exist, " + "but none were found.") } @@ -244,8 +306,8 @@ func (p *processor) processROLIEFeeds(feeds [][]csaf.Feed) error { csaf.TLPLabelAmber, csaf.TLPLabelRed, } { - if _, ok := hasSummary[label]; !ok && len(p.labelChecker.advisories[label]) > 0 { - p.badROLIEfeed.warn( + if !hasSummary.Contains(label) && len(p.labelChecker.advisories[label]) > 0 { + p.badROLIEFeed.warn( "ROLIE feed for TLP:%s has no accessible listed feed covering all advisories.", label) } @@ -254,12 +316,111 @@ func (p *processor) processROLIEFeeds(feeds [][]csaf.Feed) error { return nil } -// containsAllKeys returns if m2 contains all keys of m1. -func containsAllKeys[K comparable, V any](m1, m2 map[K]V) bool { - for k := range m1 { - if _, ok := m2[k]; !ok { - return false +// categoryCheck checks for the existence of a feeds ROLIE category document and if it does, +// whether the category document contains distinguishing categories +func (p *processor) categoryCheck(folderURL string, label csaf.TLPLabel) error { + labelname := strings.ToLower(string(label)) + urlrc := folderURL + "category-" + labelname + ".json" + + p.badROLIECategory.use() + client := p.httpClient() + res, err := client.Get(urlrc) + if err != nil { + p.badROLIECategory.error( + "Cannot fetch rolie category document %s: %v", urlrc, err) + return errContinue + } + if res.StatusCode != http.StatusOK { + p.badROLIECategory.warn("Fetching %s failed. Status code %d (%s)", + urlrc, res.StatusCode, res.Status) + return errContinue + } + rolieCategory, err := func() (*csaf.ROLIECategoryDocument, error) { + defer res.Body.Close() + return csaf.LoadROLIECategoryDocument(res.Body) + }() + + if err != nil { + p.badROLIECategory.error( + "Loading ROLIE category document %s failed: %v.", urlrc, err) + return errContinue + } + if len(rolieCategory.Categories.Category) == 0 { + p.badROLIECategory.warn( + "No distinguishing categories in ROLIE category document: %s", urlrc) + } + return nil +} + +// serviceCheck checks if a ROLIE service document exists and if it does, +// whether it contains all ROLIE feeds. +func (p *processor) serviceCheck(feeds [][]csaf.Feed) error { + // service category document should be next to the pmd + pmdURL, err := url.Parse(p.pmdURL) + if err != nil { + return err + } + baseURL, err := util.BaseURL(pmdURL) + if err != nil { + return err + } + urls := baseURL + "service.json" + + // load service document + p.badROLIEService.use() + + client := p.httpClient() + res, err := client.Get(urls) + if err != nil { + p.badROLIEService.error( + "Cannot fetch rolie service document %s: %v", urls, err) + return errContinue + } + if res.StatusCode != http.StatusOK { + p.badROLIEService.warn("Fetching %s failed. Status code %d (%s)", + urls, res.StatusCode, res.Status) + return errContinue + } + + rolieService, err := func() (*csaf.ROLIEServiceDocument, error) { + defer res.Body.Close() + return csaf.LoadROLIEServiceDocument(res.Body) + }() + + if err != nil { + p.badROLIEService.error( + "Loading ROLIE service document %s failed: %v.", urls, err) + return errContinue + } + + // Build lists of all feeds in feeds and in the Service Document + var ( + sfeeds = util.Set[string]{} + ffeeds = util.Set[string]{} + ) + for _, col := range rolieService.Service.Workspace { + for _, fd := range col.Collection { + sfeeds.Add(fd.HRef) } } - return true + for _, r := range feeds { + for _, s := range r { + ffeeds.Add(string(*s.URL)) + } + } + + // Check if ROLIE Service Document contains exactly all ROLIE feeds + if m1 := sfeeds.Difference(ffeeds).Keys(); len(m1) != 0 { + sort.Strings(m1) + p.badROLIEService.error( + "The ROLIE service document %s contains nonexistent feed entries: %v", urls, m1) + } + if m2 := ffeeds.Difference(sfeeds).Keys(); len(m2) != 0 { + sort.Strings(m2) + p.badROLIEService.error( + "The ROLIE service document %s is missing feed entries: %v", urls, m2) + } + + // TODO: Check conformity with RFC8322 + return nil } diff --git a/cmd/csaf_checker/rules.go b/cmd/csaf_checker/rules.go new file mode 100644 index 0000000..0899480 --- /dev/null +++ b/cmd/csaf_checker/rules.go @@ -0,0 +1,205 @@ +// 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 ( + "fmt" + "sort" + + "github.com/csaf-poc/csaf_distribution/v2/csaf" +) + +type ruleCondition int + +const ( + condAll ruleCondition = iota + condOneOf +) + +type requirementRules struct { + cond ruleCondition + satisfies int + subs []*requirementRules +} + +var ( + publisherRules = &requirementRules{ + cond: condAll, + subs: ruleAtoms(1, 2, 3 /* 4 */), + } + + providerRules = &requirementRules{ + cond: condAll, + subs: []*requirementRules{ + publisherRules, + {cond: condAll, subs: ruleAtoms(5, 6, 7)}, + {cond: condOneOf, subs: ruleAtoms(8, 9, 10)}, + {cond: condOneOf, subs: []*requirementRules{ + {cond: condAll, subs: ruleAtoms(11, 12, 13, 14)}, + {cond: condAll, subs: ruleAtoms(15, 16, 17)}, + }}, + }, + } + + trustedProviderRules = &requirementRules{ + cond: condAll, + subs: []*requirementRules{ + providerRules, + {cond: condAll, subs: ruleAtoms(18, 19, 20)}, + }, + } +) + +// roleRequirements returns the rules for the given role. +func roleRequirements(role csaf.MetadataRole) *requirementRules { + switch role { + case csaf.MetadataRoleTrustedProvider: + return trustedProviderRules + case csaf.MetadataRoleProvider: + return providerRules + case csaf.MetadataRolePublisher: + return publisherRules + default: + return nil + } +} + +// ruleAtoms is a helper function to build the leaves of +// a rules tree. +func ruleAtoms(nums ...int) []*requirementRules { + rules := make([]*requirementRules, len(nums)) + for i, num := range nums { + rules[i] = &requirementRules{ + cond: condAll, + satisfies: num, + } + } + return rules +} + +// reporters assembles a list of reporters needed for a given set +// of rules. The given nums are mandatory. +func (rules *requirementRules) reporters(nums []int) []reporter { + if rules == nil { + return nil + } + + var recurse func(*requirementRules) + recurse = func(rules *requirementRules) { + if rules.satisfies != 0 { + // There should not be any dupes. + for _, n := range nums { + if n == rules.satisfies { + goto doRecurse + } + } + nums = append(nums, rules.satisfies) + } + doRecurse: + for _, sub := range rules.subs { + recurse(sub) + } + } + recurse(rules) + + sort.Ints(nums) + + reps := make([]reporter, len(nums)) + + for i, n := range nums { + reps[i] = reporters[n] + } + return reps +} + +// eval evalutes a set of rules given a given processor state. +func (rules *requirementRules) eval(p *processor) bool { + if rules == nil { + return false + } + + var recurse func(*requirementRules) bool + + recurse = func(rules *requirementRules) bool { + if rules.satisfies != 0 { + return p.eval(rules.satisfies) + } + switch rules.cond { + case condAll: + for _, sub := range rules.subs { + if !recurse(sub) { + return false + } + } + return true + case condOneOf: + for _, sub := range rules.subs { + if recurse(sub) { + return true + } + } + return false + default: + panic(fmt.Sprintf("unexpected cond %v in eval", rules.cond)) + } + } + + return recurse(rules) +} + +// eval evalutes the processing state for a given requirement. +func (p *processor) eval(requirement int) bool { + + switch requirement { + case 1: + return !p.invalidAdvisories.hasErrors() + case 2: + return !p.badFilenames.hasErrors() + case 3: + return len(p.noneTLS) == 0 + + case 5: + return !p.badAmberRedPermissions.hasErrors() + case 6: + return len(p.redirects) == 0 + case 7: + return !p.badProviderMetadata.hasErrors() + case 8: + return !p.badSecurity.hasErrors() + case 9: + return !p.badWellknownMetadata.hasErrors() + case 10: + return !p.badDNSPath.hasErrors() + + case 11: + return !p.badFolders.hasErrors() + case 12: + return !p.badIndices.hasErrors() + case 13: + return !p.badChanges.hasErrors() + case 14: + return !p.badDirListings.hasErrors() + + case 15: + return !p.badROLIEFeed.hasErrors() + case 16: + return !p.badROLIEService.hasErrors() + case 17: + return !p.badROLIECategory.hasErrors() + + case 18: + return !p.badIntegrities.hasErrors() + case 19: + return !p.badSignatures.hasErrors() + case 20: + return !p.badPGPs.hasErrors() + default: + panic(fmt.Sprintf("evaluating unexpected requirement %d", requirement)) + } +} diff --git a/docs/csaf_checker.md b/docs/csaf_checker.md index e418813..30091e5 100644 --- a/docs/csaf_checker.md +++ b/docs/csaf_checker.md @@ -49,3 +49,8 @@ The checker result is a success if no checks resulted in type 2, and a failure o The `role` given in the `provider-metadata.json` is not yet considered to change the overall result, see https://github.com/csaf-poc/csaf_distribution/issues/221 . + +If a provider hosts one or more advisories with a TLP level of AMBER or RED, then these advisories must be access protected. +To check these advisories, authorization can be given via custom headers or certificates. +The authorization method chosen needs to grant access to all advisories, as otherwise the +checker will be unable to check the advisories it doesn't have permission for, falsifying the result. diff --git a/util/set.go b/util/set.go new file mode 100644 index 0000000..0df693d --- /dev/null +++ b/util/set.go @@ -0,0 +1,53 @@ +// 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 util + +// Set is a simple set type. +type Set[K comparable] map[K]struct{} + +// Contains returns if the set contains a given key or not. +func (s Set[K]) Contains(k K) bool { + _, found := s[k] + return found +} + +// Add adds a key to the set. +func (s Set[K]) Add(k K) { + s[k] = struct{}{} +} + +// Keys returns the keys of the set. +func (s Set[K]) Keys() []K { + keys := make([]K, 0, len(s)) + for k := range s { + keys = append(keys, k) + } + return keys +} + +// Difference returns the differnce of two sets. +func (s Set[K]) Difference(t Set[K]) Set[K] { + d := Set[K]{} + for k := range s { + if !t.Contains(k) { + d.Add(k) + } + } + return d +} + +// ContainsAll returns true if all keys of a given set are in this set. +func (s Set[K]) ContainsAll(t Set[K]) bool { + for k := range t { + if !s.Contains(k) { + return false + } + } + return true +}