From 772c9da0818b6456063a6d3dbd4dd46cd00161f8 Mon Sep 17 00:00:00 2001 From: Teppei Fukuda Date: Thu, 23 Mar 2017 23:58:05 +0900 Subject: [PATCH] Notify the difference from the previous scan result (#392) add diff option --- README.ja.md | 3 + README.md | 3 + commands/report.go | 55 ++++++-- commands/util.go | 123 ++++++++++++++++-- commands/util_test.go | 294 ++++++++++++++++++++++++++++++++++++++++++ config/config.go | 1 + report/localfile.go | 32 ++++- 7 files changed, 490 insertions(+), 21 deletions(-) create mode 100644 commands/util_test.go diff --git a/README.ja.md b/README.ja.md index 8aaf3245dc..8ecdaed452 100644 --- a/README.ja.md +++ b/README.ja.md @@ -939,6 +939,7 @@ report: [-cvedb-path=/path/to/cve.sqlite3] [-cvedb-url=http://127.0.0.1:1323 or mysql connection string] [-cvss-over=7] + [-diff] [-ignore-unscored-cves] [-to-email] [-to-slack] @@ -986,6 +987,8 @@ report: http://cve-dictionary.com:8080 or mysql connection string -cvss-over float -cvss-over=6.5 means reporting CVSS Score 6.5 and over (default: 0 (means report all)) + -diff + Difference between previous result and current result -debug debug mode -debug-sql diff --git a/README.md b/README.md index 0d8b4dfa46..b084f2f0dd 100644 --- a/README.md +++ b/README.md @@ -950,6 +950,7 @@ report: [-cvedb-path=/path/to/cve.sqlite3] [-cvedb-url=http://127.0.0.1:1323 or mysql connection string] [-cvss-over=7] + [-diff] [-ignore-unscored-cves] [-to-email] [-to-slack] @@ -997,6 +998,8 @@ report: http://cve-dictionary.com:8080 or mysql connection string -cvss-over float -cvss-over=6.5 means reporting CVSS Score 6.5 and over (default: 0 (means report all)) + -diff + Difference between previous result and current result -debug debug mode -debug-sql diff --git a/commands/report.go b/commands/report.go index b05b549e4d..f695284d96 100644 --- a/commands/report.go +++ b/commands/report.go @@ -74,6 +74,8 @@ type ReportCmd struct { azureContainer string pipe bool + + diff bool } // Name return subcommand name @@ -95,6 +97,7 @@ func (*ReportCmd) Usage() string { [-cvedb-path=/path/to/cve.sqlite3] [-cvedb-url=http://127.0.0.1:1323 or mysql connection string] [-cvss-over=7] + [-diff] [-ignore-unscored-cves] [-to-email] [-to-slack] @@ -171,6 +174,11 @@ func (p *ReportCmd) SetFlags(f *flag.FlagSet) { 0, "-cvss-over=6.5 means reporting CVSS Score 6.5 and over (default: 0 (means report all))") + f.BoolVar(&p.diff, + "diff", + false, + fmt.Sprintf("Difference between previous result and current result ")) + f.BoolVar( &p.ignoreUnscoredCves, "ignore-unscored-cves", @@ -273,11 +281,6 @@ func (p *ReportCmd) Execute(_ context.Context, f *flag.FlagSet, _ ...interface{} c.Conf.HTTPProxy = p.httpProxy c.Conf.Pipe = p.pipe - jsonDir, err := jsonDir(f.Args()) - if err != nil { - util.Log.Errorf("Failed to read from JSON: %s", err) - return subcommands.ExitFailure - } c.Conf.FormatXML = p.formatXML c.Conf.FormatJSON = p.formatJSON @@ -287,6 +290,19 @@ func (p *ReportCmd) Execute(_ context.Context, f *flag.FlagSet, _ ...interface{} c.Conf.FormatFullText = p.formatFullText c.Conf.GZIP = p.gzip + c.Conf.Diff = p.diff + + var dir string + var err error + if p.diff { + dir, err = jsonDir([]string{}) + } else { + dir, err = jsonDir(f.Args()) + } + if err != nil { + util.Log.Errorf("Failed to read from JSON: %s", err) + return subcommands.ExitFailure + } // report reports := []report.ResultWriter{ @@ -303,7 +319,7 @@ func (p *ReportCmd) Execute(_ context.Context, f *flag.FlagSet, _ ...interface{} if p.toLocalFile { reports = append(reports, report.LocalFileWriter{ - CurrentDir: jsonDir, + CurrentDir: dir, }) } @@ -363,7 +379,8 @@ func (p *ReportCmd) Execute(_ context.Context, f *flag.FlagSet, _ ...interface{} } } - history, err := loadOneScanHistory(jsonDir) + var history models.ScanHistory + history, err = loadOneScanHistory(dir) if err != nil { util.Log.Error(err) return subcommands.ExitFailure @@ -388,8 +405,7 @@ func (p *ReportCmd) Execute(_ context.Context, f *flag.FlagSet, _ ...interface{} return subcommands.ExitFailure } filled.Lang = c.Conf.Lang - - if err := overwriteJSONFile(jsonDir, *filled); err != nil { + if err := overwriteJSONFile(dir, *filled); err != nil { util.Log.Errorf("Failed to write JSON: %s", err) return subcommands.ExitFailure } @@ -400,10 +416,31 @@ func (p *ReportCmd) Execute(_ context.Context, f *flag.FlagSet, _ ...interface{} } } + if p.diff { + currentHistory := models.ScanHistory{ScanResults: results} + previousHistory, err := loadPreviousScanHistory(currentHistory) + if err != nil { + util.Log.Error(err) + return subcommands.ExitFailure + } + + history, err = diff(currentHistory, previousHistory) + if err != nil { + util.Log.Error(err) + return subcommands.ExitFailure + } + results = []models.ScanResult{} + for _, r := range history.ScanResults { + filled, _ := r.FillCveDetail() + results = append(results, *filled) + } + } + var res models.ScanResults for _, r := range results { res = append(res, r.FilterByCvssOver()) } + for _, w := range reports { if err := w.Write(res...); err != nil { util.Log.Errorf("Failed to report: %s", err) diff --git a/commands/util.go b/commands/util.go index f11473f150..73a44ad30e 100644 --- a/commands/util.go +++ b/commands/util.go @@ -26,6 +26,7 @@ import ( "regexp" "sort" "strings" + "time" c "github.com/future-architect/vuls/config" "github.com/future-architect/vuls/cveapi" @@ -121,6 +122,19 @@ func jsonDir(args []string) (string, error) { return dirs[0], nil } +// loadOneServerScanResult read JSON data of one server +func loadOneServerScanResult(jsonFile string) (result models.ScanResult, err error) { + var data []byte + if data, err = ioutil.ReadFile(jsonFile); err != nil { + err = fmt.Errorf("Failed to read %s: %s", jsonFile, err) + return + } + if json.Unmarshal(data, &result) != nil { + err = fmt.Errorf("Failed to parse %s: %s", jsonFile, err) + } + return +} + // loadOneScanHistory read JSON data func loadOneScanHistory(jsonDir string) (scanHistory models.ScanHistory, err error) { var results []models.ScanResult @@ -130,20 +144,16 @@ func loadOneScanHistory(jsonDir string) (scanHistory models.ScanHistory, err err return } for _, f := range files { - if filepath.Ext(f.Name()) != ".json" { + if filepath.Ext(f.Name()) != ".json" || strings.HasSuffix(f.Name(), "_diff.json") { continue } + var r models.ScanResult - var data []byte path := filepath.Join(jsonDir, f.Name()) - if data, err = ioutil.ReadFile(path); err != nil { - err = fmt.Errorf("Failed to read %s: %s", path, err) - return - } - if json.Unmarshal(data, &r) != nil { - err = fmt.Errorf("Failed to parse %s: %s", path, err) + if r, err = loadOneServerScanResult(path); err != nil { return } + results = append(results, r) } if len(results) == 0 { @@ -170,14 +180,111 @@ func fillCveInfoFromCveDB(r models.ScanResult) (*models.ScanResult, error) { return r.FillCveDetail() } +func loadPreviousScanHistory(current models.ScanHistory) (previous models.ScanHistory, err error) { + var dirs jsonDirs + if dirs, err = lsValidJSONDirs(); err != nil { + return + } + + for _, result := range current.ScanResults { + for _, dir := range dirs[1:] { + var r models.ScanResult + path := filepath.Join(dir, result.ServerName+".json") + if r, err = loadOneServerScanResult(path); err != nil { + continue + } + if r.Family == result.Family && r.Release == result.Release { + previous.ScanResults = append(previous.ScanResults, r) + break + } + } + } + return previous, nil +} + +func diff(currentHistory, previousHistory models.ScanHistory) (diffHistory models.ScanHistory, err error) { + for _, currentResult := range currentHistory.ScanResults { + found := false + var previousResult models.ScanResult + for _, previousResult = range previousHistory.ScanResults { + if currentResult.ServerName == previousResult.ServerName { + found = true + break + } + } + + if found { + currentResult.ScannedCves = getNewCves(previousResult, currentResult) + + currentResult.KnownCves = []models.CveInfo{} + currentResult.UnknownCves = []models.CveInfo{} + + currentResult.Packages = models.PackageInfoList{} + for _, s := range currentResult.ScannedCves { + currentResult.Packages = append(currentResult.Packages, s.Packages...) + } + currentResult.Packages = currentResult.Packages.UniqByName() + } + + diffHistory.ScanResults = append(diffHistory.ScanResults, currentResult) + } + return diffHistory, err +} + +func getNewCves(previousResult, currentResult models.ScanResult) (newVulninfos []models.VulnInfo) { + previousCveIDsSet := map[string]bool{} + for _, previousVulnInfo := range previousResult.ScannedCves { + previousCveIDsSet[previousVulnInfo.CveID] = true + } + + for _, v := range currentResult.ScannedCves { + if previousCveIDsSet[v.CveID] { + if isCveInfoUpdated(currentResult, previousResult, v.CveID) { + newVulninfos = append(newVulninfos, v) + } + } else { + newVulninfos = append(newVulninfos, v) + } + } + return +} + +func isCveInfoUpdated(currentResult, previousResult models.ScanResult, CveID string) bool { + type lastModified struct { + Nvd time.Time + Jvn time.Time + } + + previousModifies := lastModified{} + for _, c := range previousResult.KnownCves { + if CveID == c.CveID { + previousModifies.Nvd = c.CveDetail.Nvd.LastModifiedDate + previousModifies.Jvn = c.CveDetail.Jvn.LastModifiedDate + } + } + + currentModifies := lastModified{} + for _, c := range currentResult.KnownCves { + if CveID == c.CveDetail.CveID { + currentModifies.Nvd = c.CveDetail.Nvd.LastModifiedDate + currentModifies.Jvn = c.CveDetail.Jvn.LastModifiedDate + } + } + return !currentModifies.Nvd.Equal(previousModifies.Nvd) || + !currentModifies.Jvn.Equal(previousModifies.Jvn) +} + func overwriteJSONFile(dir string, r models.ScanResult) error { before := c.Conf.FormatJSON + beforeDiff := c.Conf.Diff c.Conf.FormatJSON = true + c.Conf.Diff = false w := report.LocalFileWriter{CurrentDir: dir} if err := w.Write(r); err != nil { return fmt.Errorf("Failed to write summary report: %s", err) } c.Conf.FormatJSON = before + c.Conf.Diff = beforeDiff return nil } diff --git a/commands/util_test.go b/commands/util_test.go new file mode 100644 index 0000000000..ddcde598e2 --- /dev/null +++ b/commands/util_test.go @@ -0,0 +1,294 @@ +/* Vuls - Vulnerability Scanner +Copyright (C) 2016 Future Architect, Inc. Japan. + +This program is free software: you can redistribute it and/or modify +it under the terms of the GNU General Public License as published by +the Free Software Foundation, either version 3 of the License, or +(at your option) any later version. + +This program is distributed in the hope that it will be useful, +but WITHOUT ANY WARRANTY; without even the implied warranty of +MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +GNU General Public License for more details. + +You should have received a copy of the GNU General Public License +along with this program. If not, see . +*/ + +package commands + +import ( + "testing" + "time" + + "reflect" + + "github.com/future-architect/vuls/models" + "github.com/k0kubun/pp" + cve "github.com/kotakanbe/go-cve-dictionary/models" +) + +func TestDiff(t *testing.T) { + atCurrent, _ := time.Parse("2006-01-02", "2014-12-31") + atPrevious, _ := time.Parse("2006-01-02", "2014-11-31") + var tests = []struct { + inCurrent models.ScanHistory + inPrevious models.ScanHistory + out models.ScanResult + }{ + { + models.ScanHistory{ + ScanResults: models.ScanResults{ + { + ScannedAt: atCurrent, + ServerName: "u16", + Family: "ubuntu", + Release: "16.04", + ScannedCves: []models.VulnInfo{ + { + CveID: "CVE-2012-6702", + Packages: models.PackageInfoList{ + { + Name: "libexpat1", + Version: "2.1.0-7", + Release: "", + NewVersion: "2.1.0-7ubuntu0.16.04.2", + NewRelease: "", + Repository: "", + }, + }, + DistroAdvisories: []models.DistroAdvisory{}, + CpeNames: []string{}, + }, + { + CveID: "CVE-2014-9761", + Packages: models.PackageInfoList{ + { + Name: "libc-bin", + Version: "2.21-0ubuntu5", + Release: "", + NewVersion: "2.23-0ubuntu5", + NewRelease: "", + Repository: "", + }, + }, + DistroAdvisories: []models.DistroAdvisory{}, + CpeNames: []string{}, + }, + }, + KnownCves: []models.CveInfo{}, + UnknownCves: []models.CveInfo{}, + IgnoredCves: []models.CveInfo{}, + + Packages: models.PackageInfoList{}, + + Errors: []string{}, + Optional: [][]interface{}{}, + }, + }, + }, + models.ScanHistory{ + ScanResults: models.ScanResults{ + { + ScannedAt: atPrevious, + ServerName: "u16", + Family: "ubuntu", + Release: "16.04", + ScannedCves: []models.VulnInfo{ + { + CveID: "CVE-2012-6702", + Packages: models.PackageInfoList{ + { + Name: "libexpat1", + Version: "2.1.0-7", + Release: "", + NewVersion: "2.1.0-7ubuntu0.16.04.2", + NewRelease: "", + Repository: "", + }, + }, + DistroAdvisories: []models.DistroAdvisory{}, + CpeNames: []string{}, + }, + { + CveID: "CVE-2014-9761", + Packages: models.PackageInfoList{ + { + Name: "libc-bin", + Version: "2.21-0ubuntu5", + Release: "", + NewVersion: "2.23-0ubuntu5", + NewRelease: "", + Repository: "", + }, + }, + DistroAdvisories: []models.DistroAdvisory{}, + CpeNames: []string{}, + }, + }, + KnownCves: []models.CveInfo{}, + UnknownCves: []models.CveInfo{}, + IgnoredCves: []models.CveInfo{}, + + Packages: models.PackageInfoList{}, + + Errors: []string{}, + Optional: [][]interface{}{}, + }, + }, + }, + models.ScanResult{ + ScannedAt: atCurrent, + ServerName: "u16", + Family: "ubuntu", + Release: "16.04", + KnownCves: []models.CveInfo{}, + UnknownCves: []models.CveInfo{}, + IgnoredCves: []models.CveInfo{}, + + // Packages: models.PackageInfoList{}, + + Errors: []string{}, + Optional: [][]interface{}{}, + }, + }, + { + models.ScanHistory{ + ScanResults: models.ScanResults{ + { + ScannedAt: atCurrent, + ServerName: "u16", + Family: "ubuntu", + Release: "16.04", + ScannedCves: []models.VulnInfo{ + { + CveID: "CVE-2016-6662", + Packages: models.PackageInfoList{ + { + Name: "mysql-libs", + Version: "5.1.73", + Release: "7.el6", + NewVersion: "5.1.73", + NewRelease: "8.el6_8", + Repository: "", + }, + }, + DistroAdvisories: []models.DistroAdvisory{}, + CpeNames: []string{}, + }, + }, + KnownCves: []models.CveInfo{ + { + CveDetail: cve.CveDetail{ + CveID: "CVE-2016-6662", + Nvd: cve.Nvd{ + LastModifiedDate: time.Date(2016, 1, 1, 0, 0, 0, 0, time.Local), + }, + }, + VulnInfo: models.VulnInfo{ + CveID: "CVE-2016-6662", + }, + }, + }, + UnknownCves: []models.CveInfo{}, + IgnoredCves: []models.CveInfo{}, + }, + }, + }, + models.ScanHistory{ + ScanResults: models.ScanResults{ + { + ScannedAt: atPrevious, + ServerName: "u16", + Family: "ubuntu", + Release: "16.04", + ScannedCves: []models.VulnInfo{ + { + CveID: "CVE-2016-6662", + Packages: models.PackageInfoList{ + { + Name: "mysql-libs", + Version: "5.1.73", + Release: "7.el6", + NewVersion: "5.1.73", + NewRelease: "8.el6_8", + Repository: "", + }, + }, + DistroAdvisories: []models.DistroAdvisory{}, + CpeNames: []string{}, + }, + }, + KnownCves: []models.CveInfo{ + { + CveDetail: cve.CveDetail{ + CveID: "CVE-2016-6662", + Nvd: cve.Nvd{ + LastModifiedDate: time.Date(2017, 3, 15, 13, 40, 57, 0, time.Local), + }, + }, + VulnInfo: models.VulnInfo{ + CveID: "CVE-2016-6662", + }, + }, + }, + UnknownCves: []models.CveInfo{}, + IgnoredCves: []models.CveInfo{}, + }, + }, + }, + models.ScanResult{ + ScannedAt: atCurrent, + ServerName: "u16", + Family: "ubuntu", + Release: "16.04", + ScannedCves: []models.VulnInfo{ + { + CveID: "CVE-2016-6662", + Packages: models.PackageInfoList{ + { + Name: "mysql-libs", + Version: "5.1.73", + Release: "7.el6", + NewVersion: "5.1.73", + NewRelease: "8.el6_8", + Repository: "", + }, + }, + DistroAdvisories: []models.DistroAdvisory{}, + CpeNames: []string{}, + }, + }, + KnownCves: []models.CveInfo{}, + UnknownCves: []models.CveInfo{}, + IgnoredCves: []models.CveInfo{}, + Packages: models.PackageInfoList{ + models.PackageInfo{ + Name: "mysql-libs", + Version: "5.1.73", + Release: "7.el6", + NewVersion: "5.1.73", + NewRelease: "8.el6_8", + Repository: "", + Changelog: models.Changelog{ + Contents: "", + Method: "", + }, + }, + }, + }, + }, + } + + var s models.ScanHistory + for _, tt := range tests { + s, _ = diff(tt.inCurrent, tt.inPrevious) + for _, actual := range s.ScanResults { + if !reflect.DeepEqual(actual, tt.out) { + h := pp.Sprint(actual) + x := pp.Sprint(tt.out) + t.Errorf("diff result : \n %s \n output result : \n %s", h, x) + } + } + } +} diff --git a/config/config.go b/config/config.go index eb843149be..ce1d73e07f 100644 --- a/config/config.go +++ b/config/config.go @@ -75,6 +75,7 @@ type Config struct { AzureContainer string Pipe bool + Diff bool } // ValidateOnConfigtest validates diff --git a/report/localfile.go b/report/localfile.go index 5571d109da..79f192a171 100644 --- a/report/localfile.go +++ b/report/localfile.go @@ -50,7 +50,13 @@ func (w LocalFileWriter) Write(rs ...models.ScanResult) (err error) { path := filepath.Join(w.CurrentDir, r.ReportFileName()) if c.Conf.FormatJSON { - p := path + ".json" + var p string + if c.Conf.Diff { + p = path + "_diff.json" + } else { + p = path + ".json" + } + var b []byte if b, err = json.Marshal(r); err != nil { return fmt.Errorf("Failed to Marshal to JSON: %s", err) @@ -61,7 +67,13 @@ func (w LocalFileWriter) Write(rs ...models.ScanResult) (err error) { } if c.Conf.FormatShortText { - p := path + "_short.txt" + var p string + if c.Conf.Diff { + p = path + "_short_diff.txt" + } else { + p = path + "_short.txt" + } + if err := writeFile( p, []byte(formatShortPlainText(r)), 0600); err != nil { return fmt.Errorf( @@ -70,7 +82,13 @@ func (w LocalFileWriter) Write(rs ...models.ScanResult) (err error) { } if c.Conf.FormatFullText { - p := path + "_full.txt" + var p string + if c.Conf.Diff { + p = path + "_full_diff.txt" + } else { + p = path + "_full.txt" + } + if err := writeFile( p, []byte(formatFullPlainText(r)), 0600); err != nil { return fmt.Errorf( @@ -79,7 +97,13 @@ func (w LocalFileWriter) Write(rs ...models.ScanResult) (err error) { } if c.Conf.FormatXML { - p := path + ".xml" + var p string + if c.Conf.Diff { + p = path + "_diff.xml" + } else { + p = path + ".xml" + } + var b []byte if b, err = xml.Marshal(r); err != nil { return fmt.Errorf("Failed to Marshal to XML: %s", err)