Add CentOS parsing+post-processing to reduce false positives in vulnerability processing (#4037)

* Add CentOS parsing and post-processing in fleet

* Add tests and amend SyncCPEDatabase

* Add test for centosPostProcessing

* Changes from PR comments

* Amend software test

* Fix sync test

* Add index to source and vendor

* Use os.MkdirTemp

* Rearrange migrations

* Regenerate test schema

* Add support for testing migrations (#4112)

* Add support for testing migrations

* Rename migration in tests

* Changes suggested in PR

* Go mod tidy
This commit is contained in:
Lucas Manuel Rodriguez 2022-02-14 15:13:44 -03:00 committed by GitHub
parent 57d9546081
commit be72dc356c
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
30 changed files with 1599 additions and 113 deletions

5
.gitignore vendored
View File

@ -57,3 +57,8 @@ terraform.tfstate*
# generated installers
fleet-osquery*
# residual files when running the cpe command
cmd/cpe/etagenv
cmd/cpe/cpe*.sqlite
cmd/cpe/cpe*.sqlite.gz

View File

@ -0,0 +1 @@
* Add parsing of the CentOS repository as well as the loading of the results in fleet vulnerability post-processing (to reduce false positives on RPM packages).

View File

@ -2,6 +2,8 @@ package main
import (
"compress/gzip"
"database/sql"
"flag"
"fmt"
"io"
"net/http"
@ -11,6 +13,7 @@ import (
"github.com/facebookincubator/nvdtools/cpedict"
"github.com/fleetdm/fleet/v4/server/vulnerabilities"
"github.com/fleetdm/fleet/v4/server/vulnerabilities/vuln_centos"
)
func panicif(err error) {
@ -20,11 +23,43 @@ func panicif(err error) {
}
func main() {
fmt.Println("Starting CPE sqlite generation")
var (
runCentOS bool
verbose bool
)
flag.BoolVar(&runCentOS, "centos", true, "Sets whether to run the CentOS sqlite generation")
flag.BoolVar(&verbose, "verbose", false, "Sets verbose mode")
flag.Parse()
dbPath := cpe()
fmt.Printf("Sqlite file %s size: %.2f MB\n", dbPath, getSizeMB(dbPath))
// The CentOS repository data is added to the CPE database.
if runCentOS {
centos(dbPath, verbose)
fmt.Printf("Sqlite file %s size with CentOS data: %.2f MB\n", dbPath, getSizeMB(dbPath))
}
fmt.Println("Compressing DB...")
compressedPath, err := compress(dbPath)
panicif(err)
fmt.Printf("Final compressed file %s size: %.2f MB\n", compressedPath, getSizeMB(compressedPath))
fmt.Println("Done.")
}
func getSizeMB(path string) float64 {
info, err := os.Stat(path)
panicif(err)
return float64(info.Size()) / 1024.0 / 1024.0
}
func cpe() string {
fmt.Println("Starting CPE sqlite generation...")
cwd, err := os.Getwd()
panicif(err)
fmt.Println("CWD:", cwd)
resp, err := http.Get("https://nvd.nist.gov/feeds/xml/cpe/dictionary/official-cpe-dictionary_v2.3.xml.gz")
@ -34,16 +69,6 @@ func main() {
remoteEtag := getSanitizedEtag(resp)
fmt.Println("Got ETag:", remoteEtag)
nvdRelease, err := vulnerabilities.GetLatestNVDRelease(nil)
panicif(err)
if nvdRelease != nil && nvdRelease.Etag == remoteEtag {
fmt.Println("No updates. Exiting...")
return
}
fmt.Println("Needs updating. Generating...")
gr, err := gzip.NewReader(resp.Body)
panicif(err)
defer gr.Close()
@ -56,25 +81,51 @@ func main() {
err = vulnerabilities.GenerateCPEDB(dbPath, cpeDict)
panicif(err)
fmt.Println("Compressing db...")
compressedDB, err := os.Create(fmt.Sprintf("%s.gz", dbPath))
panicif(err)
db, err := os.Open(dbPath)
panicif(err)
w := gzip.NewWriter(compressedDB)
_, err = io.Copy(w, db)
panicif(err)
w.Close()
compressedDB.Close()
file, err := os.Create(path.Join(cwd, "etagenv"))
panicif(err)
file.WriteString(fmt.Sprintf(`ETAG=%s`, remoteEtag))
file.Close()
fmt.Println("Done.")
return dbPath
}
func compress(path string) (string, error) {
compressedPath := fmt.Sprintf("%s.gz", path)
compressedDB, err := os.Create(compressedPath)
if err != nil {
return "", err
}
defer compressedDB.Close()
db, err := os.Open(path)
if err != nil {
return "", err
}
defer db.Close()
w := gzip.NewWriter(compressedDB)
defer w.Close()
_, err = io.Copy(w, db)
if err != nil {
return "", err
}
return compressedPath, nil
}
func centos(dbPath string, verbose bool) {
fmt.Println("Starting CentOS sqlite generation...")
db, err := sql.Open("sqlite3", dbPath)
panicif(err)
defer db.Close()
pkgs, err := vuln_centos.ParseCentOSRepository(vuln_centos.WithVerbose(verbose))
panicif(err)
fmt.Printf("Storing CVE info for %d CentOS packages...\n", len(pkgs))
err = vuln_centos.GenCentOSSqlite(db, pkgs)
panicif(err)
}
func getSanitizedEtag(resp *http.Response) string {

View File

@ -722,6 +722,16 @@ func cronVulnerabilities(
sentry.CaptureException(err)
}
// It's important vulnerabilities.PostProcess runs after ds.CalculateHostsPerSoftware
// because it cleans up any software that's not installed on the fleet (e.g. hosts removal,
// or software being uninstalled on hosts).
if !vulnDisabled {
if err := vulnerabilities.PostProcess(ctx, ds, vulnPath, logger, config); err != nil {
level.Error(logger).Log("msg", "post processing CVEs", "err", err)
sentry.CaptureException(err)
}
}
level.Debug(logger).Log("loop", "done")
}
}

View File

@ -47,7 +47,7 @@ Downloads (if needed) the data streams that can be used by the Fleet server to p
dbPath := path.Join(dir, "cpe.sqlite")
client := fleethttp.NewClient()
err = vulnerabilities.SyncCPEDatabase(client, dbPath, config.FleetConfig{})
err = vulnerabilities.SyncCPEDatabase(client, dbPath)
if err != nil {
return err
}

View File

@ -226,7 +226,7 @@ To intercept sent emails while running a Fleet development environment, first, i
Then, in the "SMTP options" section, enter any email address in the "Sender address" field, set the "SMTP server" to `localhost` on port `1025`, and set "Authentication type" to `None`. Note that you may use any active or inactive sender address.
Visit [locahost:8025](http://localhost:8025) to view Mailhog's admin interface which will display all emails sent using the simulated mail server.
Visit [localhost:8025](http://localhost:8025) to view Mailhog's admin interface which will display all emails sent using the simulated mail server.
## Development database management

9
go.mod
View File

@ -7,9 +7,12 @@ require (
github.com/AbGuthrie/goquery/v2 v2.0.1
github.com/DATA-DOG/go-sqlmock v1.5.0
github.com/OneOfOne/xxhash v1.2.8 // indirect
github.com/PuerkitoBio/goquery v1.8.0 // indirect
github.com/VividCortex/gohistogram v1.0.0 // indirect
github.com/VividCortex/mysqlerr v0.0.0-20170204212430-6c6b55f8796f
github.com/WatchBeam/clock v0.0.0-20170901150240-b08e6b4da7ea
github.com/antchfx/htmlquery v1.2.4 // indirect
github.com/antchfx/xmlquery v1.3.9 // indirect
github.com/aws/aws-sdk-go v1.40.34
github.com/beevik/etree v1.1.0
github.com/briandowns/spinner v1.13.0
@ -29,6 +32,7 @@ require (
github.com/ghodss/yaml v1.0.0
github.com/go-kit/kit v0.9.0
github.com/go-sql-driver/mysql v1.6.0
github.com/gocolly/colly v1.2.0
github.com/golang-jwt/jwt/v4 v4.0.0
github.com/gomodule/redigo v1.8.5
github.com/google/go-cmp v0.5.6
@ -45,6 +49,7 @@ require (
github.com/jinzhu/copier v0.3.2
github.com/jmoiron/sqlx v1.2.1-0.20190826204134-d7d95172beb5
github.com/jonboulle/clockwork v0.2.2 // indirect
github.com/kennygrant/sanitize v1.2.4 // indirect
github.com/kevinburke/go-bindata v3.22.0+incompatible
github.com/kolide/kit v0.0.0-20180421083548-36eb8dc43916
github.com/kolide/launcher v0.0.0-20180427153757-cb412b945cf7
@ -68,14 +73,18 @@ require (
github.com/rotisserie/eris v0.5.1
github.com/rs/zerolog v1.20.0
github.com/russellhaering/goxmldsig v1.1.0
github.com/saintfish/chardet v0.0.0-20120816061221-3af4cd4741ca // indirect
github.com/skratchdot/open-golang v0.0.0-20200116055534-eef842397966
github.com/spf13/cast v1.3.1
github.com/spf13/cobra v1.2.1
github.com/spf13/viper v1.8.1
github.com/stretchr/objx v0.3.0 // indirect
github.com/stretchr/testify v1.7.0
github.com/temoto/robotstxt v1.1.2 // indirect
github.com/theupdateframework/go-tuf v0.0.0-20220121203041-e3557e322879
github.com/throttled/throttled/v2 v2.8.0
github.com/tj/assert v0.0.3
github.com/ulikunitz/xz v0.5.10
github.com/urfave/cli/v2 v2.3.0
github.com/valyala/fasthttp v1.31.0
golang.org/x/crypto v0.0.0-20220112180741-5e0467b6c7ce

22
go.sum
View File

@ -164,6 +164,8 @@ github.com/ProtonMail/go-mime v0.0.0-20190923161245-9b5a4261663a h1:W6RrgN/sTxg1
github.com/ProtonMail/go-mime v0.0.0-20190923161245-9b5a4261663a/go.mod h1:NYt+V3/4rEeDuaev/zw1zCq8uqVEuPHzDPo3OZrlGJ4=
github.com/ProtonMail/gopenpgp/v2 v2.2.2 h1:u2m7xt+CZWj88qK1UUNBoXeJCFJwJCZ/Ff4ymGoxEXs=
github.com/ProtonMail/gopenpgp/v2 v2.2.2/go.mod h1:ajUlBGvxMH1UBZnaYO3d1FSVzjiC6kK9XlZYGiDCvpM=
github.com/PuerkitoBio/goquery v1.8.0 h1:PJTF7AmFCFKk1N6V6jmKfrNH9tV5pNE6lZMkG0gta/U=
github.com/PuerkitoBio/goquery v1.8.0/go.mod h1:ypIiRMtY7COPGk+I/YbZLbxsxn9g5ejnI2HSMtkjZvI=
github.com/Shopify/goreferrer v0.0.0-20181106222321-ec9c9a553398/go.mod h1:a1uqRtAwp2Xwc6WNPJEufxJ7fx3npB4UV/JOLmbu5I0=
github.com/VividCortex/gohistogram v1.0.0 h1:6+hBz+qvs0JOrrNhhmR7lFxo5sINxBCGXrdtl/UvroE=
github.com/VividCortex/gohistogram v1.0.0/go.mod h1:Pf5mBqqDxYaXu3hDrrU+w6nw50o/4+TcAqDqk/vUH7g=
@ -189,8 +191,16 @@ github.com/alecthomas/units v0.0.0-20190717042225-c3de453c63f4/go.mod h1:ybxpYRF
github.com/alecthomas/units v0.0.0-20190924025748-f65c72e2690d/go.mod h1:rBZYJk541a8SKzHPHnH3zbiI+7dagKZ0cgpgrD7Fyho=
github.com/andybalholm/brotli v1.0.2 h1:JKnhI/XQ75uFBTiuzXpzFrUriDPiZjlOSzh6wXogP0E=
github.com/andybalholm/brotli v1.0.2/go.mod h1:loMXtMfwqflxFJPmdbJO0a3KNoPuLBgiu3qAvBg8x/Y=
github.com/andybalholm/cascadia v1.3.1 h1:nhxRkql1kdYCc8Snf7D5/D3spOX+dBgjA6u8x004T2c=
github.com/andybalholm/cascadia v1.3.1/go.mod h1:R4bJ1UQfqADjvDa4P6HZHLh/3OxWWEqc0Sk8XGwHqvA=
github.com/anmitsu/go-shlex v0.0.0-20161002113705-648efa622239 h1:kFOfPq6dUM1hTo4JG6LR5AXSUEsOjtdm0kw0FtQtMJA=
github.com/anmitsu/go-shlex v0.0.0-20161002113705-648efa622239/go.mod h1:2FmKhYUyUczH0OGQWaF5ceTx0UBShxjsH6f8oGKYe2c=
github.com/antchfx/htmlquery v1.2.4 h1:qLteofCMe/KGovBI6SQgmou2QNyedFUW+pE+BpeZ494=
github.com/antchfx/htmlquery v1.2.4/go.mod h1:2xO6iu3EVWs7R2JYqBbp8YzG50gj/ofqs5/0VZoDZLc=
github.com/antchfx/xmlquery v1.3.9 h1:Y+zyMdiUZ4fasTQTkDb3DflOXP7+obcYEh80SISBmnQ=
github.com/antchfx/xmlquery v1.3.9/go.mod h1:wojC/BxjEkjJt6dPiAqUzoXO5nIMWtxHS8PD8TmN4ks=
github.com/antchfx/xpath v1.2.0 h1:mbwv7co+x0RwgeGAOHdrKy89GvHaGvxxBtPK0uF9Zr8=
github.com/antchfx/xpath v1.2.0/go.mod h1:i54GszH55fYfBmoZXapTHN8T8tkcHfRgLyVwwqzXNcs=
github.com/antihax/optional v1.0.0/go.mod h1:uupD/76wgC+ih3iEmQUL+0Ugr19nfwCT1kdvxnR2qWY=
github.com/apache/thrift v0.13.1-0.20200603211036-eac4d0c79a5f h1:33BV5v3u8I6dA2dEoPuXWCsAaHHOJfPtdxZhAMQV4uo=
github.com/apache/thrift v0.13.1-0.20200603211036-eac4d0c79a5f/go.mod h1:cp2SuWMxlEZw2r+iP2GNCdIi4C1qmUzdZFSVb+bacwQ=
@ -436,6 +446,8 @@ github.com/gobwas/glob v0.2.3/go.mod h1:d3Ez4x06l9bZtSvzIay5+Yzi0fmZzPgnTbPcKjJA
github.com/gobwas/httphead v0.0.0-20180130184737-2c6c146eadee/go.mod h1:L0fX3K22YWvt/FAX9NnzrNzcI4wNYi9Yku4O0LKYflo=
github.com/gobwas/pool v0.2.0/go.mod h1:q8bcK0KcYlCgd9e7WYLm9LpyS+YeLd8JVDW6WezmKEw=
github.com/gobwas/ws v1.0.2/go.mod h1:szmBTxLgaFppYjEmNtny/v3w89xOydFnnZMcgRRu/EM=
github.com/gocolly/colly v1.2.0 h1:qRz9YAn8FIH0qzgNUw+HT9UN7wm1oF9OBAilwEWpyrI=
github.com/gocolly/colly v1.2.0/go.mod h1:Hof5T3ZswNVsOHYmba1u03W65HDWgpV5HifSuueE0EA=
github.com/godbus/dbus/v5 v5.0.4/go.mod h1:xhWf0FNVPg57R7Z0UbKHbJfkEywrmjJnf7w5xrFpKfA=
github.com/gogo/protobuf v1.1.1/go.mod h1:r8qH/GZQm5c6nD/R0oafs1akxWv10x8SbQlK7atdtwQ=
github.com/gogo/protobuf v1.2.1/go.mod h1:hp+jE20tsWTFYpLwKvXlhS1hjn+gTNwPg2I6zVXpSg4=
@ -682,6 +694,8 @@ github.com/kataras/iris/v12 v12.1.8/go.mod h1:LMYy4VlP67TQ3Zgriz8RE2h2kMZV2SgMYb
github.com/kataras/neffos v0.0.14/go.mod h1:8lqADm8PnbeFfL7CLXh1WHw53dG27MC3pgi2R1rmoTE=
github.com/kataras/pio v0.0.2/go.mod h1:hAoW0t9UmXi4R5Oyq5Z4irTbaTsOemSrDGUtaTl7Dro=
github.com/kataras/sitemap v0.0.5/go.mod h1:KY2eugMKiPwsJgx7+U103YZehfvNGOXURubcGyk0Bz8=
github.com/kennygrant/sanitize v1.2.4 h1:gN25/otpP5vAsO2djbMhF/LQX6R7+O1TB4yv8NzpJ3o=
github.com/kennygrant/sanitize v1.2.4/go.mod h1:LGsjYYtgxbetdg5owWB2mpgUL6e2nfw2eObZ0u0qvak=
github.com/kevinburke/go-bindata v3.22.0+incompatible h1:/JmqEhIWQ7GRScV0WjX/0tqBrC5D21ALg0H0U/KZ/ts=
github.com/kevinburke/go-bindata v3.22.0+incompatible/go.mod h1:/pEEZ72flUW2p0yi30bslSp9YqD9pysLxunQDdb2CPM=
github.com/kevinburke/ssh_config v0.0.0-20190725054713-01f96b0aa0cd/go.mod h1:CT57kijsi8u/K/BOFA39wgDQJ9CxiF4nAY/ojJ6r6mM=
@ -914,6 +928,8 @@ github.com/russross/blackfriday/v2 v2.1.0 h1:JIOH55/0cWyOuilr9/qlrm0BSXldqnqwMsf
github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM=
github.com/ryanuber/columnize v0.0.0-20160712163229-9b3edd62028f/go.mod h1:sm1tb6uqfes/u+d4ooFouqFdy9/2g9QGwK3SQygK0Ts=
github.com/ryanuber/columnize v2.1.0+incompatible/go.mod h1:sm1tb6uqfes/u+d4ooFouqFdy9/2g9QGwK3SQygK0Ts=
github.com/saintfish/chardet v0.0.0-20120816061221-3af4cd4741ca h1:NugYot0LIVPxTvN8n+Kvkn6TrbMyxQiuvKdEwFdR9vI=
github.com/saintfish/chardet v0.0.0-20120816061221-3af4cd4741ca/go.mod h1:uugorj2VCxiV1x+LzaIdVa9b4S4qGAcH6cbhh4qVxOU=
github.com/schollz/closestmatch v2.1.0+incompatible/go.mod h1:RtP1ddjLong6gTkbtmuhtR2uUrrJOpYzYRvbcPAid+g=
github.com/sean-/seed v0.0.0-20170313163322-e2103e2c3529/go.mod h1:DxrIzT+xaE7yg65j358z/aeFdxmN0P9QXhEzd20vsDc=
github.com/sebdah/goldie v1.0.0 h1:9GNhIat69MSlz/ndaBg48vl9dF5fI+NBB6kfOxgfkMc=
@ -990,6 +1006,8 @@ github.com/subosito/gotenv v1.2.0/go.mod h1:N0PQaV/YGNqwC0u51sEeR/aUtSLEXKX9iv69
github.com/syndtr/goleveldb v1.0.0/go.mod h1:ZVVdQEZoIme9iO1Ch2Jdy24qqXrMMOU6lpPAyBWyWuQ=
github.com/technoweenie/multipartstreamer v1.0.1 h1:XRztA5MXiR1TIRHxH2uNxXxaIkKQDeX7m2XsSOlQEnM=
github.com/technoweenie/multipartstreamer v1.0.1/go.mod h1:jNVxdtShOxzAsukZwTSw6MDx5eUJoiEBsSvzDU9uzog=
github.com/temoto/robotstxt v1.1.2 h1:W2pOjSJ6SWvldyEuiFXNxz3xZ8aiWX5LbfDiOFd7Fxg=
github.com/temoto/robotstxt v1.1.2/go.mod h1:+1AmkuG3IYkh1kv0d2qEB9Le88ehNO0zwOr3ujewlOo=
github.com/theupdateframework/go-tuf v0.0.0-20220121203041-e3557e322879 h1:UeDpdrX16scCvbdgdMsrztZsQLDofld/Zo+WGDe/PBE=
github.com/theupdateframework/go-tuf v0.0.0-20220121203041-e3557e322879/go.mod h1:I0Gs4Tev4hYQ5wiNqN8VJ7qS0gw7KOZNQuckC624RmE=
github.com/throttled/throttled/v2 v2.8.0 h1:B5VfdM8BE+ClI2Ji238SbNOTWfYcocvuAhgT27lvwrE=
@ -1052,7 +1070,6 @@ github.com/yuin/goldmark v1.3.5/go.mod h1:mwnBkeHKe2W/ZEtQ+71ViKU8L12m81fl3OWwC1
github.com/zclconf/go-cty v1.1.0 h1:uJwc9HiBOCpoKIObTQaLR+tsEXx1HBHnOsOOpcdhZgw=
github.com/zclconf/go-cty v1.1.0/go.mod h1:xnAOWiHeOqg2nWS62VtQ7pbOu17FtxJNW8RLEih+O3s=
github.com/zenazn/goji v0.9.0/go.mod h1:7S9M489iMyHBNxwZnk9/EHS098H4/F6TATF2mIxtB1Q=
github.com/ziutek/mymysql v1.5.4 h1:GB0qdRGsTwQSBVYuVShFBKaXSnSnYYC2d9knnE1LHFs=
github.com/ziutek/mymysql v1.5.4/go.mod h1:LMSpPZ6DbqWFxNCHW77HeMg9I646SAhApZ/wKdgO/C0=
github.com/zwass/kit v0.0.0-20210625184505-ec5b5c5cce9c h1:TWQ2UvXPkhPxI2KmApKBOCaV6yD2N4mlvqFQ/DlPtpQ=
github.com/zwass/kit v0.0.0-20210625184505-ec5b5c5cce9c/go.mod h1:OYYulo9tUqRadRLwB0+LE914sa1ui2yL7OrcU3Q/1XY=
@ -1183,6 +1200,7 @@ golang.org/x/net v0.0.0-20200222125558-5a598a2470a0/go.mod h1:z5CRVTTTmAJ677TzLL
golang.org/x/net v0.0.0-20200226121028-0de0cce0169b/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
golang.org/x/net v0.0.0-20200301022130-244492dfa37a/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
golang.org/x/net v0.0.0-20200324143707-d3edc9973b7e/go.mod h1:qpuaurCH72eLCgpAm/N6yyVIVM9cpaDIP3A8BGJEC5A=
golang.org/x/net v0.0.0-20200421231249-e086a090c8fd/go.mod h1:qpuaurCH72eLCgpAm/N6yyVIVM9cpaDIP3A8BGJEC5A=
golang.org/x/net v0.0.0-20200501053045-e0ff5e5a1de5/go.mod h1:qpuaurCH72eLCgpAm/N6yyVIVM9cpaDIP3A8BGJEC5A=
golang.org/x/net v0.0.0-20200506145744-7e3656a0809f/go.mod h1:qpuaurCH72eLCgpAm/N6yyVIVM9cpaDIP3A8BGJEC5A=
golang.org/x/net v0.0.0-20200513185701-a91f0712d120/go.mod h1:qpuaurCH72eLCgpAm/N6yyVIVM9cpaDIP3A8BGJEC5A=
@ -1190,6 +1208,7 @@ golang.org/x/net v0.0.0-20200520004742-59133d7f0dd7/go.mod h1:qpuaurCH72eLCgpAm/
golang.org/x/net v0.0.0-20200520182314-0ba52f642ac2/go.mod h1:qpuaurCH72eLCgpAm/N6yyVIVM9cpaDIP3A8BGJEC5A=
golang.org/x/net v0.0.0-20200625001655-4c5254603344/go.mod h1:/O7V0waA8r7cgGh81Ro3o1hOxt32SMVPicZroKQ2sZA=
golang.org/x/net v0.0.0-20200707034311-ab3426394381/go.mod h1:/O7V0waA8r7cgGh81Ro3o1hOxt32SMVPicZroKQ2sZA=
golang.org/x/net v0.0.0-20200813134508-3edf25e44fcc/go.mod h1:/O7V0waA8r7cgGh81Ro3o1hOxt32SMVPicZroKQ2sZA=
golang.org/x/net v0.0.0-20200822124328-c89045814202/go.mod h1:/O7V0waA8r7cgGh81Ro3o1hOxt32SMVPicZroKQ2sZA=
golang.org/x/net v0.0.0-20200927032502-5d4f70055728/go.mod h1:/O7V0waA8r7cgGh81Ro3o1hOxt32SMVPicZroKQ2sZA=
golang.org/x/net v0.0.0-20201021035429-f5854403a974/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU=
@ -1209,6 +1228,7 @@ golang.org/x/net v0.0.0-20210525063256-abc453219eb5/go.mod h1:9nx3DQGgdP8bBQD5qx
golang.org/x/net v0.0.0-20210614182718-04defd469f4e/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y=
golang.org/x/net v0.0.0-20210726213435-c6fcb2dbf985/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y=
golang.org/x/net v0.0.0-20210825183410-e898025ed96a/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y=
golang.org/x/net v0.0.0-20210916014120-12bc252f5db8/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y=
golang.org/x/net v0.0.0-20211007125505-59d4e928ea9d/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y=
golang.org/x/net v0.0.0-20211008194852-3b03d305991f/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y=
golang.org/x/net v0.0.0-20211112202133-69e39bad7dc2 h1:CIJ76btIcR3eFI5EgSo6k1qKw9KJexJuRLI9G7Hp5wE=

View File

@ -1,3 +1,3 @@
# pkg directory
This top-level `pkg` directory contains packages that may be shared between `fleet` and `orbit`.
This top-level `pkg` directory contains packages that may be shared between all `fleet` backend components.

70
pkg/download/download.go Normal file
View File

@ -0,0 +1,70 @@
// Package download has utilities to download resources from URLs.
package download
import (
"compress/bzip2"
"compress/gzip"
"fmt"
"io"
"io/ioutil"
"net/http"
"net/url"
"os"
"path/filepath"
"strings"
"github.com/ulikunitz/xz"
)
// Decompressed downloads and decompresses a file from a URL to a local path.
//
// It supports gz, bz2 and gz compressed files.
func Decompressed(client *http.Client, u url.URL, path string) error {
if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
return err
}
tmpFile, err := ioutil.TempFile("", fmt.Sprintf("%s*", filepath.Base(path)))
if err != nil {
return err
}
defer tmpFile.Close()
req, err := http.NewRequest(http.MethodGet, u.String(), nil)
if err != nil {
return err
}
resp, err := client.Do(req)
if err != nil {
return err
}
defer resp.Body.Close()
var decompressor io.Reader
switch {
case strings.HasSuffix(u.Path, "gz"):
decompressor, err = gzip.NewReader(resp.Body)
if err != nil {
return err
}
case strings.HasSuffix(u.Path, "bz2"):
decompressor = bzip2.NewReader(resp.Body)
case strings.HasSuffix(u.Path, "xz"):
decompressor, err = xz.NewReader(resp.Body)
if err != nil {
return err
}
default:
return fmt.Errorf("unknown extension: %s", u.Path)
}
if _, err := io.Copy(tmpFile, decompressor); err != nil {
return err
}
if err := tmpFile.Close(); err != nil {
return err
}
if err := os.Rename(tmpFile.Name(), path); err != nil {
return err
}
return nil
}

View File

@ -0,0 +1,66 @@
package tables
import (
"database/sql"
"fmt"
"github.com/pkg/errors"
)
func init() {
MigrationClient.AddMigration(Up_20220208144831, Down_20220208144831)
}
func Up_20220208144831(tx *sql.Tx) error {
// NOTE(lucas): I'm using short lengths for the new varchar columns
// due to constraints on the size of the key added below. Using 255
// for the three new fields would fail with:
// "Error 1071: Specified key was too long; max key length is 3072 bytes".
//
// We need to use "NOT NULL" because these new columns are to be included in the KEY.
if _, err := tx.Exec("ALTER TABLE software " +
"ADD COLUMN `release` VARCHAR(64) NOT NULL DEFAULT '', " +
"ADD COLUMN vendor VARCHAR(32) NOT NULL DEFAULT '', " +
"ADD COLUMN arch VARCHAR(16) NOT NULL DEFAULT ''"); err != nil {
return errors.Wrap(err, "add new software columns")
}
// Delete current identifier for a software.
currIndexName, err := indexNameByColumnName(tx, "software", "name")
if err != nil {
return errors.Wrap(err, "fetch current software index")
}
if _, err := tx.Exec(fmt.Sprintf("ALTER TABLE software DROP KEY %s", currIndexName)); err != nil {
return errors.Wrap(err, "add new software columns")
}
// A software piece was originally identified by name, version and source.
//
// We now add "vendor", "release" and "arch":
// - release is the version of the OS this software was released on (e.g. "30.el7" for a CentOS package).
// - vendor is the supplier of the software (e.g. "CentOS").
// - arch is the target architecture of the software (e.g. "x86_64").
if _, err := tx.Exec("ALTER TABLE software ADD UNIQUE KEY (name, version, source, `release`, vendor, arch)"); err != nil {
return errors.Wrap(err, "add new index")
}
// Remove all software with source rpm_packages, as we will be ingesting them with new osquery
// fields.
//
// Due to foreign keys, the following statement also deletes the corresponding
// entries in `software_cpe` and `software_cve`.
if _, err := tx.Exec("DELETE FROM software WHERE source = 'rpm_packages'"); err != nil {
return errors.Wrap(err, "delete existing software for rpm_packages")
}
// Adding index to optimize software listing by source and vendor for vulnerability post-processing.
if _, err := tx.Exec("CREATE INDEX software_source_vendor_idx ON software (source, vendor)"); err != nil {
return errors.Wrap(err, "creating source+vendor index")
}
return nil
}
func Down_20220208144831(tx *sql.Tx) error {
return nil
}

View File

@ -0,0 +1,35 @@
package tables
import (
"testing"
"github.com/stretchr/testify/require"
)
func TestUp_20220208144831(t *testing.T) {
db := applyUpToPrev(t)
_, err := db.Exec(`INSERT INTO software (name, version, source) VALUES ("authconfig", "6.2.8", "rpm_packages")`)
require.NoError(t, err)
_, err = db.Exec(`INSERT INTO software (name, version, source, bundle_identifier) VALUES ("iTerm.app", "3.4.14", "apps", "com.googlecode.iterm2")`)
require.NoError(t, err)
applyNext(t, db)
// Check migration removes rpm packages.
row := db.QueryRow(`SELECT COUNT(*) FROM software WHERE source = "rpm_packages"`)
var count int
require.NoError(t, row.Scan(&count))
require.Zero(t, count)
row = db.QueryRow(`SELECT COUNT(*) FROM software WHERE source = "apps"`)
require.NoError(t, row.Scan(&count))
require.Equal(t, 1, count)
// Check we can INSERT software with the new columns empty.
_, err = db.Exec(`INSERT INTO software (name, version, source, bundle_identifier) VALUES ("iCloud.app", "1.0", "apps", "com.apple.CloudKit.ShareBear")`)
require.NoError(t, err)
// Check we can INSERT software with the new columns set.
_, err = db.Exec(`INSERT INTO software (name, version, source, ` + "`release`" + `, vendor, arch) VALUES ("authconfig", "6.2.8", "rpm_packages", "30.el7", "CentOS", "x86_64")`)
require.NoError(t, err)
}

View File

@ -0,0 +1,86 @@
// Package tables holds fleet table migrations.
//
// Migrations can be tested with tests following the following format:
//
// $ cat 20220208144831_AddSoftwareReleaseArchVendorColumns_test.go
//
// [...]
// func TestUp_20220208144831(t *testing.T) {
// // Apply all migrations up to 20220208144831 (name of test), not included.
// db := applyUpToPrev(t)
//
// // insert testing data, etc.
//
// // The following will apply migration 20220208144831.
// applyNext(t, db)
//
// // insert testing data, verify migration.
// }
package tables
import (
"fmt"
"strconv"
"strings"
"testing"
"github.com/jmoiron/sqlx"
"github.com/stretchr/testify/require"
)
// TODO(lucas): I'm copy pasting some of the mysql functionality methods here
// otherwise we have import cycle errors.
//
// We need to decouple the server/datastore/mysql package,
// it contains both the implementation of the fleet.Datastore and
// MySQL functionality, and MySQL test functionality.
const (
testUsername = "root"
testPassword = "toor"
testAddress = "localhost:3307"
)
func newDBConnForTests(t *testing.T) *sqlx.DB {
db, err := sqlx.Open(
"mysql",
fmt.Sprintf("%s:%s@tcp(%s)/?multiStatements=true", testUsername, testPassword, testAddress),
)
require.NoError(t, err)
_, err = db.Exec(fmt.Sprintf("DROP DATABASE IF EXISTS %s; CREATE DATABASE %s; USE %s;", t.Name(), t.Name(), t.Name()))
require.NoError(t, err)
return db
}
func getMigrationVersion(t *testing.T) int64 {
v, err := strconv.Atoi(strings.TrimPrefix(t.Name(), "TestUp_"))
require.NoError(t, err)
return int64(v)
}
// applyUpToPrev will allocate a testing DB connection and apply
// migrations up to, not including, the migration specified in the test name.
//
// It returns the database connection to perform additional queries and migrations.
func applyUpToPrev(t *testing.T) *sqlx.DB {
db := newDBConnForTests(t)
v := getMigrationVersion(t)
for {
current, err := MigrationClient.GetDBVersion(db.DB)
require.NoError(t, err)
next, err := MigrationClient.Migrations.Next(current)
require.NoError(t, err)
if next.Version == v {
return db
}
applyNext(t, db)
}
}
// applyNext performs the next migration in the chain.
func applyNext(t *testing.T, db *sqlx.DB) {
// gooseNoDir is the value to not parse local files and instead use
// the migrations that were added manually via Add().
const gooseNoDir = ""
err := MigrationClient.UpByOne(db.DB, gooseNoDir)
require.NoError(t, err)
}

File diff suppressed because one or more lines are too long

View File

@ -19,6 +19,10 @@ const (
maxSoftwareVersionLen = 255
maxSoftwareSourceLen = 64
maxSoftwareBundleIdentifierLen = 255
maxSoftwareReleaseLen = 64
maxSoftwareVendorLen = 32
maxSoftwareArchLen = 16
)
func truncateString(str string, length int) string {
@ -29,16 +33,36 @@ func truncateString(str string, length int) string {
}
func softwareToUniqueString(s fleet.Software) string {
return strings.Join([]string{s.Name, s.Version, s.Source, s.BundleIdentifier}, "\u0000")
ss := []string{s.Name, s.Version, s.Source, s.BundleIdentifier}
// Release, Vendor and Arch fields were added on a migration,
// thus we only include them in the string if at least one of them is defined.
if s.Release != "" || s.Vendor != "" || s.Arch != "" {
ss = append(ss, s.Release, s.Vendor, s.Arch)
}
return strings.Join(ss, "\u0000")
}
func uniqueStringToSoftware(s string) fleet.Software {
parts := strings.Split(s, "\u0000")
// Release, Vendor and Arch fields were added on a migration,
// If one of them is defined, then they are included in the string.
var release, vendor, arch string
if len(parts) > 4 {
release = truncateString(parts[4], maxSoftwareReleaseLen)
vendor = truncateString(parts[5], maxSoftwareVendorLen)
arch = truncateString(parts[6], maxSoftwareArchLen)
}
return fleet.Software{
Name: truncateString(parts[0], maxSoftwareNameLen),
Version: truncateString(parts[1], maxSoftwareVersionLen),
Source: truncateString(parts[2], maxSoftwareSourceLen),
BundleIdentifier: truncateString(parts[3], maxSoftwareBundleIdentifierLen),
Release: release,
Vendor: vendor,
Arch: arch,
}
}
@ -151,8 +175,10 @@ func getOrGenerateSoftwareIdDB(ctx context.Context, tx sqlx.ExtContext, s fleet.
var existingId []int64
if err := sqlx.SelectContext(ctx, tx,
&existingId,
`SELECT id FROM software WHERE name = ? AND version = ? AND source = ? AND bundle_identifier = ?`,
s.Name, s.Version, s.Source, s.BundleIdentifier,
"SELECT id FROM software "+
"WHERE name = ? AND version = ? AND source = ? AND `release` = ? AND "+
"vendor = ? AND arch = ? AND bundle_identifier = ?",
s.Name, s.Version, s.Source, s.Release, s.Vendor, s.Arch, s.BundleIdentifier,
); err != nil {
return 0, ctxerr.Wrap(ctx, err, "get software")
}
@ -161,9 +187,11 @@ func getOrGenerateSoftwareIdDB(ctx context.Context, tx sqlx.ExtContext, s fleet.
}
result, err := tx.ExecContext(ctx,
`INSERT INTO software (name, version, source, bundle_identifier) VALUES (?, ?, ?, ?)
ON DUPLICATE KEY UPDATE bundle_identifier=VALUES(bundle_identifier)`,
s.Name, s.Version, s.Source, s.BundleIdentifier,
"INSERT INTO software "+
"(name, version, source, `release`, vendor, arch, bundle_identifier) "+
"VALUES (?, ?, ?, ?, ?, ?, ?) "+
"ON DUPLICATE KEY UPDATE bundle_identifier=VALUES(bundle_identifier)",
s.Name, s.Version, s.Source, s.Release, s.Vendor, s.Arch, s.BundleIdentifier,
)
if err != nil {
return 0, ctxerr.Wrap(ctx, err, "insert software")
@ -503,6 +531,59 @@ func (ds *Datastore) CountSoftware(ctx context.Context, opt fleet.SoftwareListOp
return countSoftwareDB(ctx, ds.reader, nil, opt)
}
// ListVulnerableSoftwareBySource lists all the vulnerable software that matches the given source.
func (ds *Datastore) ListVulnerableSoftwareBySource(ctx context.Context, source string) ([]fleet.SoftwareWithCPE, error) {
var softwareCVEs []struct {
fleet.Software
CPE uint `db:"cpe_id"`
CVEs string `db:"cves"`
}
if err := sqlx.SelectContext(ctx, ds.reader, &softwareCVEs, `
SELECT s.*, scv.cpe_id, GROUP_CONCAT(scv.cve SEPARATOR ',') as cves
FROM software s
JOIN software_cpe scp ON (s.id=scp.software_id)
JOIN software_cve scv ON (scp.id=scv.cpe_id)
WHERE s.source = ?
GROUP BY scv.cpe_id
`, source); err != nil {
return nil, ctxerr.Wrapf(ctx, err, "listing vulnerable software by source")
}
software := make([]fleet.SoftwareWithCPE, 0, len(softwareCVEs))
for _, sc := range softwareCVEs {
for _, cve := range strings.Split(sc.CVEs, ",") {
sc.Software.Vulnerabilities = append(sc.Software.Vulnerabilities, fleet.SoftwareCVE{
CVE: cve,
DetailsLink: fmt.Sprintf("https://nvd.nist.gov/vuln/detail/%s", cve),
})
}
software = append(software, fleet.SoftwareWithCPE{
Software: sc.Software,
CPEID: sc.CPE,
})
}
return software, nil
}
// DeleteVulnerabilitiesByCPECVE deletes the given list of vulnerabilities identified by CPE+CVE.
func (ds *Datastore) DeleteVulnerabilitiesByCPECVE(ctx context.Context, vulnerabilities []fleet.SoftwareVulnerability) error {
if len(vulnerabilities) == 0 {
return nil
}
sql := fmt.Sprintf(
`DELETE FROM software_cve WHERE (cpe_id, cve) IN (%s)`,
strings.TrimSuffix(strings.Repeat("(?,?),", len(vulnerabilities)), ","),
)
var args []interface{}
for _, vulnerability := range vulnerabilities {
args = append(args, vulnerability.CPEID, vulnerability.CVE)
}
if _, err := ds.writer.ExecContext(ctx, sql, args...); err != nil {
return ctxerr.Wrapf(ctx, err, "deleting vulnerable software")
}
return nil
}
func (ds *Datastore) SoftwareByID(ctx context.Context, id uint) (*fleet.Software, error) {
software := fleet.Software{}
err := sqlx.GetContext(ctx, ds.reader, &software, `SELECT * FROM software WHERE id=?`, id)
@ -545,6 +626,9 @@ func (ds *Datastore) SoftwareByID(ctx context.Context, id uint) (*fleet.Software
// CalculateHostsPerSoftware calculates the number of hosts having each
// software installed and stores that information in the software_host_counts
// table.
//
// After aggregation, it cleans up unused software (e.g. software installed
// on removed hosts, software uninstalled on hosts, etc.)
func (ds *Datastore) CalculateHostsPerSoftware(ctx context.Context, updatedAt time.Time) error {
resetStmt := `
UPDATE software_host_counts
@ -614,8 +698,6 @@ func (ds *Datastore) CalculateHostsPerSoftware(ctx context.Context, updatedAt ti
return ctxerr.Wrap(ctx, err, "iterate over host_software counts")
}
// delete any unused software from the software table (any that
// isn't in that list with a host count > 0).
cleanupStmt := `
DELETE FROM
software
@ -630,7 +712,6 @@ func (ds *Datastore) CalculateHostsPerSoftware(ctx context.Context, updatedAt ti
if _, err := ds.writer.ExecContext(ctx, cleanupStmt); err != nil {
return ctxerr.Wrap(ctx, err, "delete unused software")
}
return nil
}

View File

@ -33,6 +33,8 @@ func TestSoftware(t *testing.T) {
{"LoadSupportsTonsOfCVEs", testSoftwareLoadSupportsTonsOfCVEs},
{"List", testSoftwareList},
{"CalculateHostsPerSoftware", testSoftwareCalculateHostsPerSoftware},
{"ListVulnerableSoftwareBySource", testListVulnerableSoftwareBySource},
{"DeleteVulnerabilitiesByCPECVE", testDeleteVulnerabilitiesByCPECVE},
}
for _, c := range cases {
t.Run(c.name, func(t *testing.T) {
@ -426,7 +428,15 @@ func testSoftwareList(t *testing.T, ds *Datastore) {
t.Run("paginates", func(t *testing.T) {
software := listSoftwareCheckCount(t, ds, 1, 4, fleet.SoftwareListOptions{ListOptions: fleet.ListOptions{Page: 1, PerPage: 1, OrderKey: "version"}}, true)
expected := []fleet.Software{foo003}
require.Len(t, software, 1)
var expected []fleet.Software
// Both foo003 and bar003 have the same version, thus we check which one the database picked
// for the second page.
if software[0].Name == "foo" {
expected = []fleet.Software{foo003}
} else {
expected = []fleet.Software{bar003}
}
test.ElementsMatchSkipID(t, software, expected)
})
@ -626,3 +636,145 @@ func testSoftwareCalculateHostsPerSoftware(t *testing.T, ds *Datastore) {
}
cmpNameVersionCount(want, allSw)
}
func insertVulnSoftwareForTest(t *testing.T, ds *Datastore) {
host1 := test.NewHost(t, ds, "host1", "", "host1key", "host1uuid", time.Now())
host2 := test.NewHost(t, ds, "host2", "", "host2key", "host2uuid", time.Now())
software1 := []fleet.Software{
{
Name: "foo.rpm", Version: "0.0.1", Source: "rpm_packages", GenerateCPE: "cpe_foo_rpm",
},
{
Name: "foo.chrome", Version: "0.0.3", Source: "chrome_extensions", GenerateCPE: "cpe_foo_chrome",
},
}
software2 := []fleet.Software{
{
Name: "foo.chrome", Version: "v0.0.2", Source: "chrome_extensions", GenerateCPE: "cpe_foo_chrome2",
},
{
Name: "foo.chrome", Version: "0.0.3", Source: "chrome_extensions", GenerateCPE: "cpe_foo_chrome_3",
Vulnerabilities: fleet.VulnerabilitiesSlice{
{CVE: "cve-123-456-789", DetailsLink: "https://nvd.nist.gov/vuln/detail/cve-123-456-789"},
},
},
{
Name: "bar.rpm", Version: "0.0.3", Source: "rpm_packages", GenerateCPE: "cpe_bar_rpm",
Vulnerabilities: fleet.VulnerabilitiesSlice{
{CVE: "cve-321-432-543", DetailsLink: "https://nvd.nist.gov/vuln/detail/cve-321-432-543"},
{CVE: "cve-333-444-555", DetailsLink: "https://nvd.nist.gov/vuln/detail/cve-333-444-555"},
},
},
}
require.NoError(t, ds.UpdateHostSoftware(context.Background(), host1.ID, software1))
require.NoError(t, ds.UpdateHostSoftware(context.Background(), host2.ID, software2))
require.NoError(t, ds.LoadHostSoftware(context.Background(), host1))
require.NoError(t, ds.LoadHostSoftware(context.Background(), host2))
sort.Slice(host1.Software, func(i, j int) bool {
return host1.Software[i].Name+host1.Software[i].Version < host1.Software[j].Name+host1.Software[j].Version
})
sort.Slice(host2.Software, func(i, j int) bool {
return host2.Software[i].Name+host2.Software[i].Version < host2.Software[j].Name+host2.Software[j].Version
})
require.NoError(t, ds.AddCPEForSoftware(context.Background(), host1.Software[0], "cpe_foo_chrome"))
require.NoError(t, ds.AddCPEForSoftware(context.Background(), host1.Software[1], "cpe_foo_rpm"))
require.NoError(t, ds.AddCPEForSoftware(context.Background(), host2.Software[0], "cpe_bar_rpm"))
require.NoError(t, ds.AddCPEForSoftware(context.Background(), host2.Software[1], "cpe_foo_chrome_3"))
require.NoError(t, ds.AddCPEForSoftware(context.Background(), host2.Software[2], "cpe_foo_chrome_2"))
_, err := ds.InsertCVEForCPE(context.Background(), "cve-123-456-789", []string{"cpe_foo_chrome_3"})
require.NoError(t, err)
_, err = ds.InsertCVEForCPE(context.Background(), "cve-321-432-543", []string{"cpe_bar_rpm"})
require.NoError(t, err)
_, err = ds.InsertCVEForCPE(context.Background(), "cve-333-444-555", []string{"cpe_bar_rpm"})
require.NoError(t, err)
}
func testListVulnerableSoftwareBySource(t *testing.T, ds *Datastore) {
ctx := context.Background()
insertVulnSoftwareForTest(t, ds)
vulnerable, err := ds.ListVulnerableSoftwareBySource(ctx, "apps")
require.NoError(t, err)
require.Empty(t, vulnerable)
vulnerable, err = ds.ListVulnerableSoftwareBySource(ctx, "rpm_packages")
require.NoError(t, err)
require.Len(t, vulnerable, 1)
require.Equal(t, vulnerable[0].Name, "bar.rpm")
require.Len(t, vulnerable[0].Vulnerabilities, 2)
sort.Slice(vulnerable[0].Vulnerabilities, func(i, j int) bool {
return vulnerable[0].Vulnerabilities[i].CVE < vulnerable[0].Vulnerabilities[j].CVE
})
require.Equal(t, "cve-321-432-543", vulnerable[0].Vulnerabilities[0].CVE)
require.Equal(t, "cve-333-444-555", vulnerable[0].Vulnerabilities[1].CVE)
}
func testDeleteVulnerabilitiesByCPECVE(t *testing.T, ds *Datastore) {
ctx := context.Background()
err := ds.DeleteVulnerabilitiesByCPECVE(ctx, nil)
require.NoError(t, err)
insertVulnSoftwareForTest(t, ds)
err = ds.DeleteVulnerabilitiesByCPECVE(ctx, []fleet.SoftwareVulnerability{
{
CPEID: 999, // unknown CPE
CVE: "cve-333-444-555",
},
})
require.NoError(t, err)
software, err := ds.ListVulnerableSoftwareBySource(ctx, "rpm_packages")
require.NoError(t, err)
require.Len(t, software, 1)
barRPM := software[0]
require.Len(t, barRPM.Vulnerabilities, 2)
err = ds.DeleteVulnerabilitiesByCPECVE(ctx, []fleet.SoftwareVulnerability{
{
CPEID: barRPM.CPEID,
CVE: "unknown-cve",
},
})
require.NoError(t, err)
err = ds.DeleteVulnerabilitiesByCPECVE(ctx, []fleet.SoftwareVulnerability{
{
CPEID: barRPM.CPEID,
CVE: "cve-333-444-555",
},
})
require.NoError(t, err)
software, err = ds.ListVulnerableSoftwareBySource(ctx, "rpm_packages")
require.NoError(t, err)
require.Len(t, software, 1)
barRPM = software[0]
require.Len(t, barRPM.Vulnerabilities, 1)
err = ds.DeleteVulnerabilitiesByCPECVE(ctx, []fleet.SoftwareVulnerability{
{
CPEID: barRPM.CPEID,
CVE: "cve-321-432-543",
},
})
require.NoError(t, err)
software, err = ds.ListVulnerableSoftwareBySource(ctx, "rpm_packages")
require.NoError(t, err)
require.Len(t, software, 0)
software, err = ds.ListVulnerableSoftwareBySource(ctx, "chrome_extensions")
require.NoError(t, err)
require.Len(t, software, 1)
}

View File

@ -4,6 +4,7 @@ import (
"context"
"encoding/json"
"errors"
"fmt"
"time"
)
@ -330,6 +331,12 @@ type Datastore interface {
AllCPEs(ctx context.Context) ([]string, error)
InsertCVEForCPE(ctx context.Context, cve string, cpes []string) (int64, error)
SoftwareByID(ctx context.Context, id uint) (*Software, error)
// CalculateHostsPerSoftware calculates the number of hosts having each
// software installed and stores that information in the software_host_counts
// table.
//
// After aggregation, it cleans up unused software (e.g. software installed
// on removed hosts, software uninstalled on hosts, etc.)
CalculateHostsPerSoftware(ctx context.Context, updatedAt time.Time) error
HostsByCPEs(ctx context.Context, cpes []string) ([]*CPEHost, error)
@ -374,6 +381,10 @@ type Datastore interface {
ListSoftware(ctx context.Context, opt SoftwareListOptions) ([]Software, error)
CountSoftware(ctx context.Context, opt SoftwareListOptions) (int, error)
// ListVulnerableSoftwareBySource lists all the vulnerable software that matches the given source.
ListVulnerableSoftwareBySource(ctx context.Context, source string) ([]SoftwareWithCPE, error)
// DeleteVulnerabilities deletes the given list of vulnerabilities identified by CPE+CVE.
DeleteVulnerabilitiesByCPECVE(ctx context.Context, vulnerabilities []SoftwareVulnerability) error
///////////////////////////////////////////////////////////////////////////////
// Team Policies
@ -549,6 +560,26 @@ const (
UnknownMigrations
)
// SoftwareVulnerability identifies a vulnerability on a specific software (CPE).
type SoftwareVulnerability struct {
// CPEID is the ID of the software CPE in the system.
CPEID uint
CVE string
}
// String implements fmt.Stringer.
func (sv SoftwareVulnerability) String() string {
return fmt.Sprintf("{%d,%s}", sv.CPEID, sv.CVE)
}
// SoftwareWithCPE holds a software piece alongside its CPE ID.
type SoftwareWithCPE struct {
// Software holds the software data.
Software
// CPEID is the ID of the software CPE in the system.
CPEID uint
}
// NotFoundError is returned when the datastore resource cannot be found.
type NotFoundError interface {
error

View File

@ -19,6 +19,14 @@ type Software struct {
// Source is the source of the data (osquery table name).
Source string `json:"source" db:"source"`
// Release is the version of the OS this software was released on
// (e.g. "30.el7" for a CentOS package).
Release string `json:"release,omitempty" db:"release"`
// Vendor is the supplier of the software (e.g. "CentOS").
Vendor string `json:"vendor,omitempty" db:"vendor"`
// Arch is the architecture of the software (e.g. "x86_64").
Arch string `json:"arch,omitempty" db:"arch"`
// GenerateCPE is the CPE23 string that corresponds to the current software
GenerateCPE string `json:"generated_cpe" db:"generated_cpe"`
// Vulnerabilities lists all the found CVEs for the CPE

View File

@ -312,6 +312,10 @@ type ListSoftwareFunc func(ctx context.Context, opt fleet.SoftwareListOptions) (
type CountSoftwareFunc func(ctx context.Context, opt fleet.SoftwareListOptions) (int, error)
type ListVulnerableSoftwareBySourceFunc func(ctx context.Context, source string) ([]fleet.SoftwareWithCPE, error)
type DeleteVulnerabilitiesByCPECVEFunc func(ctx context.Context, vulnerabilities []fleet.SoftwareVulnerability) error
type NewTeamPolicyFunc func(ctx context.Context, teamID uint, authorID *uint, args fleet.PolicyPayload) (*fleet.Policy, error)
type ListTeamPoliciesFunc func(ctx context.Context, teamID uint) ([]*fleet.Policy, error)
@ -825,6 +829,12 @@ type DataStore struct {
CountSoftwareFunc CountSoftwareFunc
CountSoftwareFuncInvoked bool
ListVulnerableSoftwareBySourceFunc ListVulnerableSoftwareBySourceFunc
ListVulnerableSoftwareBySourceFuncInvoked bool
DeleteVulnerabilitiesByCPECVEFunc DeleteVulnerabilitiesByCPECVEFunc
DeleteVulnerabilitiesByCPECVEFuncInvoked bool
NewTeamPolicyFunc NewTeamPolicyFunc
NewTeamPolicyFuncInvoked bool
@ -1669,6 +1679,16 @@ func (s *DataStore) CountSoftware(ctx context.Context, opt fleet.SoftwareListOpt
return s.CountSoftwareFunc(ctx, opt)
}
func (s *DataStore) ListVulnerableSoftwareBySource(ctx context.Context, source string) ([]fleet.SoftwareWithCPE, error) {
s.ListVulnerableSoftwareBySourceFuncInvoked = true
return s.ListVulnerableSoftwareBySourceFunc(ctx, source)
}
func (s *DataStore) DeleteVulnerabilitiesByCPECVE(ctx context.Context, vulnerabilities []fleet.SoftwareVulnerability) error {
s.DeleteVulnerabilitiesByCPECVEFuncInvoked = true
return s.DeleteVulnerabilitiesByCPECVEFunc(ctx, vulnerabilities)
}
func (s *DataStore) NewTeamPolicy(ctx context.Context, teamID uint, authorID *uint, args fleet.PolicyPayload) (*fleet.Policy, error) {
s.NewTeamPolicyFuncInvoked = true
return s.NewTeamPolicyFunc(ctx, teamID, authorID, args)

View File

@ -11,6 +11,8 @@ func String(x string) *string {
return &x
}
// StringValueOrZero returns the string value.
// Returns empty string if x is nil.
func StringValueOrZero(x *string) string {
if x == nil {
return ""

View File

@ -84,6 +84,10 @@ func (s *integrationTestSuite) TearDownTest() {
_, err = s.ds.DeleteGlobalPolicies(ctx, globalPolicyIDs)
require.NoError(t, err)
}
// CalculateHostsPerSoftware performs a cleanup.
err = s.ds.CalculateHostsPerSoftware(ctx, time.Now())
require.NoError(t, err)
}
func TestIntegrations(t *testing.T) {
@ -1742,13 +1746,15 @@ func (s *integrationTestSuite) TestScheduledQueries() {
// batch-delete by id, 3 ids, only one exists
var delBatchResp deleteQueriesResponse
s.DoJSON("POST", "/api/v1/fleet/queries/delete", map[string]interface{}{
"ids": []uint{query.ID, query2.ID, query3.ID}}, http.StatusOK, &delBatchResp)
"ids": []uint{query.ID, query2.ID, query3.ID},
}, http.StatusOK, &delBatchResp)
assert.Equal(t, uint(1), delBatchResp.Deleted)
// batch-delete by id, none exist
delBatchResp.Deleted = 0
s.DoJSON("POST", "/api/v1/fleet/queries/delete", map[string]interface{}{
"ids": []uint{query.ID, query2.ID, query3.ID}}, http.StatusNotFound, &delBatchResp)
"ids": []uint{query.ID, query2.ID, query3.ID},
}, http.StatusNotFound, &delBatchResp)
assert.Equal(t, uint(0), delBatchResp.Deleted)
}
@ -2615,7 +2621,8 @@ func (s *integrationTestSuite) TestQuerySpecs() {
// delete all queries created
var delBatchResp deleteQueriesResponse
s.DoJSON("POST", "/api/v1/fleet/queries/delete", map[string]interface{}{
"ids": []uint{q1ID, q2ID, q3ID}}, http.StatusOK, &delBatchResp)
"ids": []uint{q1ID, q2ID, q3ID},
}, http.StatusOK, &delBatchResp)
assert.Equal(t, uint(3), delBatchResp.Deleted)
}

View File

@ -395,56 +395,80 @@ SELECT
name AS name,
version AS version,
'Package (deb)' AS type,
'deb_packages' AS source
'deb_packages' AS source,
'' AS release,
'' AS vendor,
'' AS arch
FROM deb_packages
UNION
SELECT
package AS name,
version AS version,
'Package (Portage)' AS type,
'portage_packages' AS source
'portage_packages' AS source,
'' AS release,
'' AS vendor,
'' AS arch
FROM portage_packages
UNION
SELECT
name AS name,
version AS version,
'Package (RPM)' AS type,
'rpm_packages' AS source
'rpm_packages' AS source,
release AS release,
vendor AS vendor,
arch AS arch
FROM rpm_packages
UNION
SELECT
name AS name,
version AS version,
'Package (NPM)' AS type,
'npm_packages' AS source
'npm_packages' AS source,
'' AS release,
'' AS vendor,
'' AS arch
FROM npm_packages
UNION
SELECT
name AS name,
version AS version,
'Browser plugin (Chrome)' AS type,
'chrome_extensions' AS source
'chrome_extensions' AS source,
'' AS release,
'' AS vendor,
'' AS arch
FROM cached_users CROSS JOIN chrome_extensions USING (uid)
UNION
SELECT
name AS name,
version AS version,
'Browser plugin (Firefox)' AS type,
'firefox_addons' AS source
'firefox_addons' AS source,
'' AS release,
'' AS vendor,
'' AS arch
FROM cached_users CROSS JOIN firefox_addons USING (uid)
UNION
SELECT
name AS name,
version AS version,
'Package (Atom)' AS type,
'atom_packages' AS source
'atom_packages' AS source,
'' AS release,
'' AS vendor,
'' AS arch
FROM cached_users CROSS JOIN atom_packages USING (uid)
UNION
SELECT
name AS name,
version AS version,
'Package (Python)' AS type,
'python_packages' AS source
'python_packages' AS source,
'' AS release,
'' AS vendor,
'' AS arch
FROM python_packages;
`,
Platforms: fleet.HostLinuxOSs,
@ -649,11 +673,16 @@ func directIngestSoftware(ctx context.Context, logger log.Logger, host *fleet.Ho
)
continue
}
s := fleet.Software{
Name: name,
Version: version,
Source: source,
BundleIdentifier: bundleIdentifier,
Release: row["release"],
Vendor: row["vendor"],
Arch: row["arch"],
}
software = append(software, s)
}

View File

@ -5,6 +5,7 @@ import (
"encoding/json"
"errors"
"fmt"
"regexp"
"strconv"
"strings"
"sync"
@ -17,7 +18,6 @@ import (
"github.com/fleetdm/fleet/v4/server/contexts/logging"
"github.com/fleetdm/fleet/v4/server/fleet"
"github.com/fleetdm/fleet/v4/server/ptr"
"github.com/fleetdm/fleet/v4/server/pubsub"
"github.com/fleetdm/fleet/v4/server/service/osquery_utils"
"github.com/go-kit/kit/log"
@ -761,6 +761,8 @@ func (svc *Service) directIngestDetailQuery(ctx context.Context, host *fleet.Hos
return false, nil
}
var noSuchTableRegexp = regexp.MustCompile(`^no such table: \S+$`)
func (svc *Service) SubmitDistributedQueryResults(
ctx context.Context,
results fleet.OsqueryDistributedQueryResults,
@ -787,6 +789,9 @@ func (svc *Service) SubmitDistributedQueryResults(
// osquery docs say any nonzero (string) value for status indicates a query error
status, ok := statuses[query]
failed := ok && status != fleet.StatusOK
if failed && messages[query] != "" && !noSuchTableRegexp.MatchString(messages[query]) {
level.Debug(svc.logger).Log("query", query, "message", messages[query])
}
var err error
switch {
case strings.HasPrefix(query, hostDetailQueryPrefix):

View File

@ -0,0 +1,92 @@
package vulnerabilities
import (
"context"
"database/sql"
"fmt"
"github.com/fleetdm/fleet/v4/server/config"
"github.com/fleetdm/fleet/v4/server/fleet"
"github.com/fleetdm/fleet/v4/server/vulnerabilities/vuln_centos"
kitlog "github.com/go-kit/kit/log"
"github.com/go-kit/kit/log/level"
)
// centosPostProcessing performs processing over the list of vulnerable rpm packages
// and removes the vulnerabilities where the CVEs are known to be fixed.
func centosPostProcessing(
ctx context.Context,
ds fleet.Datastore,
db *sql.DB,
logger kitlog.Logger,
config config.FleetConfig,
) error {
centOSPkgs, err := vuln_centos.LoadCentOSFixedCVEs(ctx, db, logger)
if err != nil {
return fmt.Errorf("failed to fetch CentOS packages: %w", err)
}
level.Info(logger).Log("centosPackages", len(centOSPkgs))
if len(centOSPkgs) == 0 {
return nil
}
rpmVulnerable, err := ds.ListVulnerableSoftwareBySource(ctx, "rpm_packages")
if err != nil {
return fmt.Errorf("failed to list vulnerable software: %w", err)
}
level.Info(logger).Log("vulnerable rpm_packages", len(rpmVulnerable))
if len(rpmVulnerable) == 0 {
return nil
}
var fixedCVEs []fleet.SoftwareVulnerability
var softwareCount int
for _, software := range rpmVulnerable {
if software.Vendor != "CentOS" {
continue
}
pkgFixedCVEs, ok := centOSPkgs[vuln_centos.CentOSPkg{
Name: software.Name,
Version: software.Version,
Release: software.Release,
Arch: software.Arch,
}]
if !ok {
continue
}
var cves []string
for _, vulnerability := range software.Vulnerabilities {
if _, ok := pkgFixedCVEs[vulnerability.CVE]; ok {
cves = append(cves, vulnerability.CVE)
fixedCVEs = append(fixedCVEs, fleet.SoftwareVulnerability{
CPEID: software.CPEID,
CVE: vulnerability.CVE,
})
}
}
if len(cves) > 0 {
softwareCount++
level.Debug(logger).Log(
"msg", "fixedCVEs",
"software", fmt.Sprintf(
"%s-%s-%s.%s",
software.Name, software.Version, software.Release, software.Arch,
),
"softwareCPE", software.CPEID,
"cves", fmt.Sprintf("%v", cves),
)
}
}
level.Info(logger).Log(
"msg", "CentOS fixed CVEs",
"fixedCVEsCount", len(fixedCVEs),
"distinctSoftwareCount", softwareCount,
)
if err := ds.DeleteVulnerabilitiesByCPECVE(ctx, fixedCVEs); err != nil {
return fmt.Errorf("failed to delete fixed vulnerabilities: %w", err)
}
return nil
}

View File

@ -0,0 +1,152 @@
package vulnerabilities
import (
"context"
"database/sql"
"testing"
"github.com/fleetdm/fleet/v4/server/config"
"github.com/fleetdm/fleet/v4/server/fleet"
"github.com/fleetdm/fleet/v4/server/mock"
"github.com/fleetdm/fleet/v4/server/vulnerabilities/vuln_centos"
"github.com/go-kit/kit/log"
_ "github.com/mattn/go-sqlite3"
"github.com/stretchr/testify/require"
)
func TestCentOSPostProcessing(t *testing.T) {
ctx := context.Background()
ds := new(mock.Store)
db, err := sql.Open("sqlite3", ":memory:")
require.NoError(t, err)
pkgs := make(vuln_centos.CentOSPkgSet)
authConfigPkg := vuln_centos.CentOSPkg{
Name: "authconfig",
Version: "6.2.8",
Release: "30.el7",
Arch: "x86_64",
}
pkgs.Add(authConfigPkg, "CVE-2017-7488")
sqlitePkg := vuln_centos.CentOSPkg{
Name: "sqlite",
Version: "3.7.17",
Release: "8.el7_7",
Arch: "x86_64",
}
pkgs.Add(sqlitePkg, "CVE-2015-3415", "CVE-2015-3416", "CVE-2015-3414")
err = vuln_centos.GenCentOSSqlite(db, pkgs)
require.NoError(t, err)
vulnSoftware := []fleet.SoftwareWithCPE{
{
Software: fleet.Software{
Name: "authconfig",
Version: "6.2.8",
Release: "30.el7",
Arch: "x86_64",
Vendor: "CentOS",
Vulnerabilities: fleet.VulnerabilitiesSlice{
{
CVE: "CVE-2017-7488",
},
},
},
CPEID: 1,
},
{
Software: fleet.Software{
Name: "sqlite",
Version: "3.7.17",
Release: "8.el7_7",
Arch: "x86_64",
Vendor: "CentOS",
Vulnerabilities: fleet.VulnerabilitiesSlice{
{
CVE: "CVE-2015-3415",
},
{
CVE: "CVE-2015-3416",
},
{
CVE: "CVE-2022-9999",
},
},
},
CPEID: 2,
},
{
Software: fleet.Software{
Name: "ghostscript",
Version: "9.25",
Release: "5.el7",
Arch: "x86_64",
Vendor: "CentOS",
Vulnerabilities: fleet.VulnerabilitiesSlice{
{
CVE: "CVE-2019-3835",
},
},
},
CPEID: 3,
},
{
Software: fleet.Software{
Name: "gnutls",
Version: "3.3.29",
Release: "9.el7",
Arch: "x86_64",
Vendor: "",
Vulnerabilities: fleet.VulnerabilitiesSlice{
{
CVE: "CVE-8888-9999",
},
},
},
CPEID: 4,
},
}
ds.ListVulnerableSoftwareBySourceFunc = func(ctx context.Context, source string) ([]fleet.SoftwareWithCPE, error) {
return vulnSoftware, nil
}
ds.DeleteVulnerabilitiesByCPECVEFunc = func(ctx context.Context, vulnerabilities []fleet.SoftwareVulnerability) error {
require.Equal(t, []fleet.SoftwareVulnerability{
{
CPEID: 1,
CVE: "CVE-2017-7488",
},
{
CPEID: 2,
CVE: "CVE-2015-3415",
},
{
CPEID: 2,
CVE: "CVE-2015-3416",
},
}, vulnerabilities)
return nil
}
err = centosPostProcessing(ctx, ds, db, log.NewNopLogger(), config.FleetConfig{})
require.NoError(t, err)
require.True(t, ds.ListVulnerableSoftwareBySourceFuncInvoked)
require.True(t, ds.DeleteVulnerabilitiesByCPECVEFuncInvoked)
}
func TestCentOSPostProcessingNoPkgs(t *testing.T) {
ctx := context.Background()
ds := new(mock.Store)
ds.ListVulnerableSoftwareBySourceFunc = func(ctx context.Context, source string) ([]fleet.SoftwareWithCPE, error) {
t.Error("this method shouldn't be called if there are no pkgs in the CentOS table")
return nil, nil
}
db, err := sql.Open("sqlite3", ":memory:")
require.NoError(t, err)
err = centosPostProcessing(ctx, ds, db, log.NewNopLogger(), config.FleetConfig{})
require.Error(t, err)
}

View File

@ -1,18 +1,18 @@
package vulnerabilities
import (
"compress/gzip"
"context"
"errors"
"fmt"
"io"
"net/http"
"net/url"
"os"
"path"
"regexp"
"strings"
"time"
"github.com/fleetdm/fleet/v4/pkg/download"
"github.com/fleetdm/fleet/v4/pkg/fleethttp"
"github.com/fleetdm/fleet/v4/server/config"
"github.com/fleetdm/fleet/v4/server/contexts/ctxerr"
@ -69,22 +69,39 @@ func GetLatestNVDRelease(client *http.Client) (*NVDRelease, error) {
}, nil
}
type syncOpts struct {
url string
}
type CPESyncOption func(*syncOpts)
func WithCPEURL(url string) CPESyncOption {
return func(o *syncOpts) {
o.url = url
}
}
// SyncCPEDatabase (by default) downloads the CPE database from the
// latest release of github.com/fleetdm/nvd to the given dbPath.
// An alternative URL can be set via the WithCPEURL option.
//
// It won't sync the database at dbPath has an mtime that happened after the
// available database release date.
func SyncCPEDatabase(
client *http.Client,
dbPath string,
config config.FleetConfig,
opts ...CPESyncOption,
) error {
if config.Vulnerabilities.DisableDataSync {
return nil
var o syncOpts
for _, fn := range opts {
fn(&o)
}
url := config.Vulnerabilities.CPEDatabaseURL
if url == "" {
if o.url == "" {
nvdRelease, err := GetLatestNVDRelease(client)
if err != nil {
return err
}
stat, err := os.Stat(dbPath)
if err != nil {
if !errors.Is(err, os.ErrNotExist) {
@ -93,34 +110,14 @@ func SyncCPEDatabase(
} else if !nvdRelease.CreatedAt.After(stat.ModTime()) {
return nil
}
url = nvdRelease.CPEURL
o.url = nvdRelease.CPEURL
}
req, err := http.NewRequest(http.MethodGet, url, nil)
u, err := url.Parse(o.url)
if err != nil {
return err
}
resp, err := client.Do(req)
if err != nil {
return err
}
defer resp.Body.Close()
gr, err := gzip.NewReader(resp.Body)
if err != nil {
return err
}
defer gr.Close()
dbFile, err := os.Create(dbPath)
if err != nil {
return err
}
defer dbFile.Close()
_, err = io.Copy(dbFile, gr)
if err != nil {
if err := download.Decompressed(client, *u, dbPath); err != nil {
return err
}
@ -227,10 +224,12 @@ func TranslateSoftwareToCPE(
) error {
dbPath := path.Join(vulnPath, "cpe.sqlite")
if !config.Vulnerabilities.DisableDataSync {
client := fleethttp.NewClient()
if err := SyncCPEDatabase(client, dbPath, config); err != nil {
if err := SyncCPEDatabase(client, dbPath, WithCPEURL(config.Vulnerabilities.CPEDatabaseURL)); err != nil {
return ctxerr.Wrap(ctx, err, "sync cpe db")
}
}
iterator, err := ds.AllSoftwareWithoutCPEIterator(ctx)
if err != nil {

View File

@ -75,7 +75,7 @@ func TestSyncCPEDatabase(t *testing.T) {
}
// first time, db doesn't exist, so it downloads
err = SyncCPEDatabase(client, dbPath, config.FleetConfig{})
err = SyncCPEDatabase(client, dbPath)
require.NoError(t, err)
db, err := sqliteDB(dbPath)
@ -107,7 +107,7 @@ func TestSyncCPEDatabase(t *testing.T) {
require.NoError(t, err)
// then it will download
err = SyncCPEDatabase(client, dbPath, config.FleetConfig{})
err = SyncCPEDatabase(client, dbPath)
require.NoError(t, err)
// let's register the mtime for the db
@ -128,9 +128,8 @@ func TestSyncCPEDatabase(t *testing.T) {
time.Sleep(2 * time.Second)
// let's check it doesn't download because it's new enough
err = SyncCPEDatabase(client, dbPath, config.FleetConfig{})
err = SyncCPEDatabase(client, dbPath)
require.NoError(t, err)
stat, err = os.Stat(dbPath)
require.NoError(t, err)
require.Equal(t, mtime, stat.ModTime())
@ -226,27 +225,10 @@ func TestSyncsCPEFromURL(t *testing.T) {
dbPath := path.Join(tempDir, "cpe.sqlite")
err := SyncCPEDatabase(
client, dbPath, config.FleetConfig{Vulnerabilities: config.VulnerabilitiesConfig{CPEDatabaseURL: ts.URL}})
client, dbPath, WithCPEURL(ts.URL+"/hello-world.gz"))
require.NoError(t, err)
stored, err := ioutil.ReadFile(dbPath)
require.NoError(t, err)
assert.Equal(t, "Hello world!", string(stored))
}
func TestSyncsCPESkipsIfDisableSync(t *testing.T) {
client := fleethttp.NewClient()
tempDir := t.TempDir()
dbPath := path.Join(tempDir, "cpe.sqlite")
fleetConfig := config.FleetConfig{
Vulnerabilities: config.VulnerabilitiesConfig{
DisableDataSync: true,
},
}
err := SyncCPEDatabase(client, dbPath, fleetConfig)
require.NoError(t, err)
_, err = os.Stat(dbPath)
require.ErrorIs(t, err, os.ErrNotExist)
}

View File

@ -2,9 +2,11 @@ package vulnerabilities
import (
"context"
"database/sql"
"fmt"
"net/url"
"os"
"path"
"path/filepath"
"regexp"
"runtime"
@ -243,3 +245,25 @@ func checkCVEs(ctx context.Context, ds fleet.Datastore, logger kitlog.Logger,
wg.Wait()
return nil
}
// PostProcess performs additional processing over the results of
// the main vulnerability processing run (TranslateSoftwareToCPE+TranslateCPEToCVE).
func PostProcess(
ctx context.Context,
ds fleet.Datastore,
vulnPath string,
logger kitlog.Logger,
config config.FleetConfig,
) error {
dbPath := path.Join(vulnPath, "cpe.sqlite")
db, err := sql.Open("sqlite3", dbPath)
if err != nil {
return fmt.Errorf("failed to open cpe database: %w", err)
}
defer db.Close()
if err := centosPostProcessing(ctx, ds, db, logger, config); err != nil {
return err
}
return nil
}

View File

@ -0,0 +1,451 @@
// Package vuln_centos contains a ParseCentOSRepository method to parse the CentOS repository
// to look out for CentOS releases that patch CVEs. It parses the changelogs from the metadata.
//
// It also contains a LoadCentOSFixedCVEs to load the results of the parsing.
//
// Both the parsing and loading of results use sqlite3 as backend storage.
package vuln_centos
import (
"context"
"database/sql"
"encoding/xml"
"errors"
"fmt"
"io/fs"
"io/ioutil"
"net/http"
"net/url"
"os"
"path"
"path/filepath"
"regexp"
"sort"
"strings"
"github.com/fleetdm/fleet/v4/pkg/download"
"github.com/fleetdm/fleet/v4/pkg/fleethttp"
kitlog "github.com/go-kit/kit/log"
"github.com/gocolly/colly"
_ "github.com/mattn/go-sqlite3"
)
// CentOSPkg holds data to identify a CentOS package.
type CentOSPkg struct {
Name string
Version string
Release string
Arch string
}
// String implements fmt.Stringer.
func (p CentOSPkg) String() string {
return p.Name + "-" + p.Version + "-" + p.Release + "." + p.Arch
}
// FixedCVESet is a set of fixed CVEs.
type FixedCVESet map[string]struct{}
// CentOSPkgSet is a set of CentOS packages and their fixed CVEs.
type CentOSPkgSet map[CentOSPkg]FixedCVESet
// Add adds the given package and CVE/s to the set.
func (p CentOSPkgSet) Add(pkg CentOSPkg, fixedCVEs ...string) {
s := p[pkg]
if s == nil {
s = make(FixedCVESet)
}
for _, fixedCVE := range fixedCVEs {
s[fixedCVE] = struct{}{}
}
p[pkg] = s
}
const centOSPkgsCVEsTable = "centos_pkgs_fixed_cves"
// LoadCentOSFixedCVEs loads the CentOS packages with known fixed CVEs from the given sqlite3 db.
func LoadCentOSFixedCVEs(ctx context.Context, db *sql.DB, logger kitlog.Logger) (CentOSPkgSet, error) {
rows, err := db.QueryContext(ctx, fmt.Sprintf(`SELECT name, version, release, arch, cves FROM %s`, centOSPkgsCVEsTable))
if err != nil {
return nil, fmt.Errorf("failed to fetch packages: %w", err)
}
defer rows.Close()
pkgs := make(CentOSPkgSet)
for rows.Next() {
var pkg CentOSPkg
var cves string
if err := rows.Scan(&pkg.Name, &pkg.Version, &pkg.Release, &pkg.Arch, &cves); err != nil {
return nil, err
}
for _, cve := range strings.Split(cves, ",") {
pkgs.Add(pkg, "CVE-"+cve)
}
}
if err := rows.Err(); err != nil {
return nil, fmt.Errorf("failed to traverse packages: %w", err)
}
return pkgs, nil
}
type centOSOpts struct {
noCrawl bool
verbose bool
localDir string
root string
}
type CentOSOption func(*centOSOpts)
func WithLocalDir(dir string) CentOSOption {
return func(o *centOSOpts) {
o.localDir = dir
}
}
func NoCrawl() CentOSOption {
return func(o *centOSOpts) {
o.noCrawl = true
}
}
func WithVerbose(v bool) CentOSOption {
return func(o *centOSOpts) {
o.verbose = v
}
}
func WithRoot(root string) CentOSOption {
return func(o *centOSOpts) {
o.root = root
}
}
const (
repositoryDomain = "mirror.centos.org"
repositoryURL = "http://" + repositoryDomain
defaultRoot = "/centos/"
)
var (
// Only parse the repository metadata for CentOS 6, 7 and 8.
//
// CentOS 6 maintenance updates ended in 2020-11-30, but we will still
// fetch metadata for CentOS 6 because it's considered as of 2022-02-02
// a "recent" release.
//
// See https://en.wikipedia.org/wiki/CentOS#CentOS_releases.
recentCentOSPathRegex = regexp.MustCompile(`/centos/[678]\S*`)
// nonReleasePathRegex is used to skip non-package centos directories/files.
nonReleasePathRegex = regexp.MustCompile(`/centos/[^0-9]`)
)
// ParseCentOSRepository performs the following operations:
// - Crawls the CentOS repository website. To find all the sqlite3 files with
// the packages metadata.
// - Processes all the found sqlite3 files to find all fixed CVEs in each package version.
// It parses the changelogs for each package release and looks for the "CVE-" string.
//
// It writes progress messages to stdout.
func ParseCentOSRepository(opts ...CentOSOption) (CentOSPkgSet, error) {
var opts_ centOSOpts
for _, fn := range opts {
fn(&opts_)
}
if opts_.localDir == "" && opts_.noCrawl {
return nil, errors.New("invalid options: if no crawl is set, local dir must be set")
}
if opts_.localDir == "" {
localDir, err := os.MkdirTemp("", "centos*")
if err != nil {
return nil, err
}
opts_.localDir = localDir
}
fmt.Printf("Using local directory: %s\n", opts_.localDir)
if !opts_.noCrawl {
if err := crawl(opts_.root, opts_.localDir, opts_.verbose); err != nil {
return nil, err
}
}
pkgs, err := parse(opts_.localDir)
if err != nil {
return nil, err
}
if opts_.verbose {
for pkg, cves := range pkgs {
var cveList []string
for cve := range cves {
cveList = append(cveList, cve)
}
if opts_.verbose {
fmt.Printf("%s: %v\n", pkg, cveList)
}
}
}
return pkgs, nil
}
func crawl(root string, localDir string, verbose bool) error {
fmt.Println("Crawling CentOS repository...")
c := colly.NewCollector()
if err := os.MkdirAll(localDir, 0o755); err != nil {
return err
}
var repoMDs []url.URL
c.OnHTML("#indexlist .indexcolname a[href]", func(e *colly.HTMLElement) {
href := e.Attr("href")
// Skip going to parent directory.
if strings.HasPrefix(root, href) {
return
}
if nonReleasePathRegex.MatchString(path.Join(e.Request.URL.Path, href)) {
return
}
if !recentCentOSPathRegex.MatchString(path.Join(e.Request.URL.Path, href)) {
if verbose {
fmt.Printf("Ignoring old release: %s\n", path.Join(e.Request.URL.Path, href))
}
return
}
if href == "repomd.xml" {
u := *e.Request.URL
u.Path = path.Join(u.Path, href)
repoMDs = append(repoMDs, u)
if verbose {
fmt.Printf("%s\n", u.Path)
}
return
}
if !strings.Contains(href, "/") {
return
}
e.Request.Visit(href)
})
c.AllowedDomains = append(c.AllowedDomains, repositoryDomain)
if root == "" {
root = defaultRoot
}
if err := c.Visit(repositoryURL + root); err != nil {
return err
}
for _, u := range repoMDs {
if err := processRepoMD(u, localDir, verbose); err != nil {
return err
}
}
return nil
}
type dbs struct {
primary, other string
}
func parse(localDir string) (CentOSPkgSet, error) {
fmt.Println("Processing sqlite files...")
dbPaths := make(map[string]dbs)
if err := filepath.WalkDir(localDir, func(path string, d fs.DirEntry, err error) error {
if err != nil {
return err
}
if d.IsDir() {
return nil
}
if !strings.HasSuffix(path, ".sqlite") {
return nil
}
dbp := dbPaths[filepath.Dir(path)]
if strings.HasSuffix(path, "-primary.sqlite") {
dbp.primary = path
} else if strings.HasSuffix(path, "-other.sqlite") {
dbp.other = path
}
dbPaths[filepath.Dir(path)] = dbp
return nil
}); err != nil {
return nil, err
}
allPkgs := make(CentOSPkgSet)
for _, db := range dbPaths {
pkgs, err := processSqlites(db)
if err != nil {
return nil, err
}
for pkg, cves := range pkgs {
for cve := range cves {
allPkgs.Add(pkg, cve)
}
}
}
return allPkgs, nil
}
func processRepoMD(mdURL url.URL, localDir string, verbose bool) error {
resp, err := http.Get(mdURL.String())
if err != nil {
return err
}
defer resp.Body.Close()
b, err := ioutil.ReadAll(resp.Body)
if err != nil {
return err
}
type location struct {
Href string `xml:"href,attr"`
}
type repoDataItem struct {
Type string `xml:"type,attr"`
Location location `xml:"location"`
}
type repoMetadata struct {
XMLName xml.Name `xml:"repomd"`
Datas []repoDataItem `xml:"data"`
}
var md repoMetadata
if err := xml.Unmarshal(b, &md); err != nil {
return err
}
for _, data := range md.Datas {
if data.Type != "primary_db" && data.Type != "other_db" {
continue
}
sqliteURL := mdURL
sqliteURL.Path = strings.TrimSuffix(sqliteURL.Path, "repomd.xml") + strings.TrimPrefix(data.Location.Href, "repodata/")
if verbose {
fmt.Printf("%s\n", sqliteURL.Path)
}
filePath := filePathfromURL(localDir, sqliteURL)
_, err := os.Stat(filePath)
switch {
case err == nil:
// File already exists, nothing to do.
case errors.Is(err, os.ErrNotExist):
if err := download.Decompressed(fleethttp.NewClient(), sqliteURL, filePath); err != nil {
return err
}
default:
return err
}
}
return nil
}
func filePathfromURL(dir string, url url.URL) string {
filePath := filepath.Join(dir, url.Path)
filePath = strings.TrimSuffix(filePath, ".bz2")
filePath = strings.TrimSuffix(filePath, ".xz")
filePath = strings.TrimSuffix(filePath, ".gz")
return filePath
}
func processSqlites(dbPaths dbs) (CentOSPkgSet, error) {
db, err := sql.Open("sqlite3", dbPaths.primary)
if err != nil {
return nil, err
}
defer db.Close()
if _, err := db.Exec(fmt.Sprintf("ATTACH DATABASE '%s' as other;", dbPaths.other)); err != nil {
return nil, err
}
db.SetMaxOpenConns(1)
rows, err := db.Query(`SELECT
p.name, p.version, p.release, p.arch, c.changelog
FROM packages p
JOIN other.changelog c ON (p.pkgKey=c.pkgKey)
WHERE c.changelog LIKE '%CVE-%-%';`)
if err != nil {
return nil, err
}
defer rows.Close()
pkgs := make(CentOSPkgSet)
for rows.Next() {
var p CentOSPkg
var changelog string
if err := rows.Scan(&p.Name, &p.Version, &p.Release, &p.Arch, &changelog); err != nil {
return nil, err
}
cves := parseCVEs(changelog)
for _, cve := range cves {
pkgs.Add(p, cve)
}
}
if err := rows.Err(); err != nil {
return nil, err
}
return pkgs, nil
}
var cveRegex = regexp.MustCompile(`CVE\-[0-9]+\-[0-9]+`)
func parseCVEs(changelog string) []string {
return cveRegex.FindAllString(changelog, -1)
}
// GenCentOSSqlite will store the CentOS package set in the given sqlite handle.
func GenCentOSSqlite(db *sql.DB, pkgs CentOSPkgSet) error {
if err := createTable(db); err != nil {
return err
}
type pkgWithCVEs struct {
pkg CentOSPkg
cves string
}
var pkgsWithCVEs []pkgWithCVEs
for pkg, cves := range pkgs {
var cveList []string
for cve := range cves {
cveList = append(cveList, strings.TrimPrefix(cve, "CVE-"))
}
sort.Slice(cveList, func(i, j int) bool {
return cveList[i] < cveList[j]
})
pkgsWithCVEs = append(pkgsWithCVEs, pkgWithCVEs{
pkg: pkg,
cves: strings.Join(cveList, ","),
})
}
for _, pkgWithCVEs := range pkgsWithCVEs {
if _, err := db.Exec(
fmt.Sprintf("REPLACE INTO %s (name, version, release, arch, cves) VALUES (?, ?, ?, ?, ?)", centOSPkgsCVEsTable),
pkgWithCVEs.pkg.Name,
pkgWithCVEs.pkg.Version,
pkgWithCVEs.pkg.Release,
pkgWithCVEs.pkg.Arch,
pkgWithCVEs.cves,
); err != nil {
return err
}
}
return nil
}
func createTable(db *sql.DB) error {
_, err := db.Exec(fmt.Sprintf(`CREATE TABLE IF NOT EXISTS %s (
name TEXT,
version TEXT,
release TEXT,
arch TEXT,
cves TEXT,
UNIQUE (name, version, release, arch)
);`, centOSPkgsCVEsTable))
return err
}

View File

@ -0,0 +1,94 @@
package vuln_centos
import (
"context"
"database/sql"
"os"
"testing"
"github.com/go-kit/kit/log"
_ "github.com/mattn/go-sqlite3"
"github.com/stretchr/testify/require"
)
func TestCentOSPkgSetAdd(t *testing.T) {
pkgSet := make(CentOSPkgSet)
authConfig := CentOSPkg{
Name: "authconfig",
Version: "6.2.8",
Release: "30.el7",
Arch: "x86_64",
}
cve1 := "CVE-2017-7488"
pkgSet.Add(authConfig, cve1)
cve2 := "CVE-2017-7489"
pkgSet.Add(authConfig, cve2)
curl := CentOSPkg{
Name: "curl",
Version: "4.2",
Release: "30.el7",
Arch: "x86_64",
}
cve3 := "CVE-2017-7490"
pkgSet.Add(curl, cve1)
pkgSet.Add(curl, cve3)
require.Len(t, pkgSet, 2)
require.Len(t, pkgSet[authConfig], 2)
require.Contains(t, pkgSet[authConfig], cve1)
require.Contains(t, pkgSet[authConfig], cve2)
require.NotContains(t, pkgSet[authConfig], cve3)
require.Len(t, pkgSet[curl], 2)
require.Contains(t, pkgSet[curl], cve1)
require.NotContains(t, pkgSet[curl], cve2)
require.Contains(t, pkgSet[curl], cve3)
}
func TestParseCentOSRepository(t *testing.T) {
if os.Getenv("NETWORK_TEST") == "" {
t.Skip("set environment variable NETWORK_TEST=1 to run")
}
// Parse a subset of the CentOS repository.
pkgs, err := ParseCentOSRepository(WithRoot("/centos/7/os/x86_64/repodata/"))
require.NoError(t, err)
db, err := sql.Open("sqlite3", ":memory:")
require.NoError(t, err)
t.Cleanup(func() {
db.Close()
})
err = GenCentOSSqlite(db, pkgs)
require.NoError(t, err)
pkgSet, err := LoadCentOSFixedCVEs(context.Background(), db, log.NewNopLogger())
require.NoError(t, err)
// Shouldn't get _lower_ than what was reported during the development of this test (2221),
// as these are already published releases.
require.GreaterOrEqual(t, len(pkgSet), 2221)
for pkg, cveSet := range pkgSet {
require.NotEmpty(t, pkg.Name)
require.NotEmpty(t, pkg.Version)
require.NotEmpty(t, pkg.Release)
require.NotEmpty(t, pkg.Arch)
require.NotEmpty(t, cveSet)
}
// Check a known vulnerability fixed on a CentOS release.
authConfig := CentOSPkg{
Name: "authconfig",
Version: "6.2.8",
Release: "30.el7",
Arch: "x86_64",
}
cve := "CVE-2017-7488"
require.Contains(t, pkgSet, authConfig)
require.Len(t, pkgSet[authConfig], 1)
require.Contains(t, pkgSet[authConfig], cve)
}