blob: e6ae822b8724ce37647b9b30325ce13bf8a68c51 [file] [log] [blame]
// Copyright 2023 The Go Authors. All rights reserved.
// Use of this source code is governed by a BSD-style
// license that can be found in the LICENSE file.
// Package vulndbreqs supports recording the daily count of requests to the
// Vulnerability Database.
package vulndbreqs
import (
"context"
"crypto/hmac"
"crypto/sha256"
"encoding/hex"
"encoding/json"
"errors"
"fmt"
"io"
"os"
"time"
"cloud.google.com/go/civil"
"cloud.google.com/go/logging/logadmin"
"cloud.google.com/go/storage"
"golang.org/x/pkgsite-metrics/internal/bigquery"
"golang.org/x/pkgsite-metrics/internal/derrors"
"golang.org/x/pkgsite-metrics/internal/log"
"google.golang.org/api/iterator"
)
var (
// First date for which we want log data.
startDate = civil.Date{Year: 2023, Month: time.January, Day: 1}
// First date for which the GCS logs bucket has a full day of data. (There
// is a directory for the day before, but doesn't include a whole day's
// data.)
gcsStartDate = civil.Date{Year: 2023, Month: time.May, Day: 31}
)
// ComputeAndStore computes Vuln DB request counts from the last date we have
// data for, and writes them to BigQuery.
func ComputeAndStore(ctx context.Context, vulndbBucketProjectID string, client *bigquery.Client, hmacKey []byte) error {
rcs, err := ReadRequestCountsFromBigQuery(ctx, client)
if err != nil {
return err
}
have := map[civil.Date]bool{}
for _, rc := range rcs {
have[rc.Date] = true
}
today := civil.DateOf(time.Now())
// Compute requests for every day that we don't have, up until yesterday.
// Since today is not yet over, the request count for it will be short.
// Compute one day at a time, so if it fails after a few days we at least make some progress.
for d := startDate; d.Before(today); d = d.AddDays(1) {
if !have[d] {
if err := ComputeAndStoreDate(ctx, vulndbBucketProjectID, client, hmacKey, d); err != nil {
return err
}
}
}
return nil
}
// ComputeAndStoreDate computes the request counts for the given date and writes them to BigQuery.
// It does so even if there is already stored information for that date.
func ComputeAndStoreDate(ctx context.Context, vulndbBucketProjectID string, client *bigquery.Client, hmacKey []byte, date civil.Date) error {
ircs, err := Compute(ctx, vulndbBucketProjectID, date, hmacKey)
if err != nil {
return err
}
if len(ircs) == 0 {
ircs = []*IPRequestCount{{Date: date, IP: "NONE", Count: 0}}
}
count := 0
for _, rc := range ircs {
count += rc.Count
}
log.Infof(ctx, "writing request count %d for %s; %d distinct IPs", count, date, len(ircs))
return writeToBigQuery(ctx, client, []*RequestCount{{Date: date, Count: count}}, ircs)
}
func sumRequestCounts(ircs []*IPRequestCount) []*RequestCount {
counts := map[civil.Date]int{}
for _, irc := range ircs {
counts[irc.Date] += irc.Count
}
var rcs []*RequestCount
for date, count := range counts {
rcs = append(rcs, &RequestCount{Date: date, Count: count})
}
return rcs
}
// Compute computes counts for all vuln DB requests on the given date.
// It returns request counts grouped by obfuscated IP address.
func Compute(ctx context.Context, vulndbBucketProjectID string, date civil.Date, hmacKey []byte) ([]*IPRequestCount, error) {
if date.Before(gcsStartDate) {
return computeFromLogs(ctx, vulndbBucketProjectID, date, hmacKey, 0)
}
return computeFromStorage(ctx, date, hmacKey, 0)
}
// computeFromLogs queries the vulndb load balancer logs for all vuln DB
// requests on the given date. It returns request counts for the date.
// If limit is positive, it reads no more than limit entries from the log (for testing only).
func computeFromLogs(ctx context.Context, vulndbBucketProjectID string, date civil.Date, hmacKey []byte, limit int) ([]*IPRequestCount, error) {
if len(hmacKey) < 16 {
return nil, errors.New("HMAC secret must be at least 16 bytes")
}
log.Infof(ctx, "computing request counts for %s from logs", date)
client, err := logadmin.NewClient(ctx, vulndbBucketProjectID)
if err != nil {
return nil, err
}
defer client.Close()
counts := map[string]int{} // key is obfuscated IP address
it := newEntryIterator(ctx, client,
// This filter has three sections, marked with blank lines. It is more
// efficient to do as much filtering as possible in the logging API
// query, rather than in code.
//
// The first section of the filter selects the log of interest and
// filters on general properties like severity.
//
// The second section filters on URL. Its first line makes sure
// we're looking at a vulnDB URL. The other lines filter out
// URLs we don't care about. We only want URLs that refer to
// modules, but we can't write that directly; instead, we have to
// exclude some URLs. (The syntax `-FIELD=VALUE` means "FIELD
// does not equal VALUE; a colon instead of an `=` means substring.)
//
// The third section selects the time of interest, based on the argument
// times. It formats the times as dates like "2022-08-10". We want
// the filter to be exclusive on both ends, so we use "<" for the end date,
// and add one day to the start date.
`
resource.type=http_load_balancer
resource.labels.forwarding_rule_name=go-vulndb-lb-forwarding-rule
resource.labels.url_map_name=go-vulndb-lb
severity=INFO
httpRequest.requestMethod=GET
httpRequest.requestUrl:"https://vuln.go.dev/"
-httpRequest.requestUrl="https://vuln.go.dev/"
-httpRequest.requestUrl="https://vuln.go.dev/index.json"
-httpRequest.requestUrl:"https://vuln.go.dev/ID/"
timestamp>=`+date.String()+`
timestamp<`+date.AddDays(1).String())
// Count each log entry we see, bucketing by date.
// The timestamps are in order from oldest to newest
// (https://cloud.google.com/logging/docs/reference/v2/rpc/google.logging.v2#google.logging.v2.ListLogEntriesRequest).
var logErr error
n := 1
for {
entry, err := it.Next()
if err != nil {
if err != iterator.Done {
logErr = err
}
break
}
ip := "NONE"
if r := entry.HTTPRequest; r != nil {
ip = obfuscate(r.RemoteIP, hmacKey)
}
counts[ip]++
n++
if limit > 0 && n > limit {
break
}
}
if logErr != nil {
log.Errorf(ctx, logErr, "when reading load balancer logs, no progress")
return nil, logErr
}
return mapToCountSlice(counts, date), nil
}
// computeFromStorage counts requests for the given date from the files in the
// vulndb logs bucket.
// If maxFiles is positive, only that many files are read (for testing).
func computeFromStorage(ctx context.Context, date civil.Date, hmacKey []byte, maxFiles int) (_ []*IPRequestCount, err error) {
defer derrors.Wrap(&err, "computeFromStorage(%s)", date)
log.Infof(ctx, "computing request counts for %s from storage bucket", date)
client, err := storage.NewClient(ctx)
if err != nil {
return nil, err
}
defer client.Close()
bucketName := os.Getenv("GOOGLE_CLOUD_PROJECT") + bucketSuffix
bucket := client.Bucket(bucketName)
names, err := objectNamesForDate(ctx, bucket, logPrefix, date)
if err != nil {
return nil, err
}
if maxFiles > 0 && len(names) > maxFiles {
names = names[:maxFiles]
}
byDate, byIP, err := countLogsForObjects(ctx, bucket, names, hmacKey)
if err != nil {
return nil, err
}
if len(byDate) != 1 {
return nil, fmt.Errorf("got %d dates, want 1", len(byDate))
}
if _, present := byDate[date]; !present {
return nil, fmt.Errorf("no data for %s", date)
}
return mapToCountSlice(byIP, date), nil
}
// mapToCountSlice Converts the map to a slice of IPRequestCounts.
func mapToCountSlice(countsByIP map[string]int, date civil.Date) []*IPRequestCount {
var ircs []*IPRequestCount
for ip, count := range countsByIP {
ircs = append(ircs, &IPRequestCount{Date: date, IP: ip, Count: count})
}
return ircs
}
// countLogsForObjects reads the JSON log files given by objNames from the bucket
// and sums their entries by date and obfuscated IP.
func countLogsForObjects(ctx context.Context, bucket *storage.BucketHandle, objNames []string, hmacKey []byte) (
byDate map[civil.Date]int, byIP map[string]int, err error) {
if len(objNames) == 0 {
return nil, nil, nil
}
defer derrors.Wrap(&err, "countLogsForObjects(%q, ...)", objNames[0])
byDate = map[civil.Date]int{}
byIP = map[string]int{}
for _, name := range objNames {
r, err := bucket.Object(name).NewReader(ctx)
if err != nil {
return nil, nil, err
}
defer r.Close()
err = readJSONLogEntries(r, hmacKey, func(e *logEntry) error {
byDate[civil.DateOf(e.Timestamp)]++
byIP[e.HTTPRequest.RemoteIP]++
return nil
})
if err != nil {
return nil, nil, err
}
}
return byDate, byIP, nil
}
// Suffix to append to project name to get the name of the logs bucket.
const bucketSuffix = "-vulndb-logs"
// Start of object names for vulndb request logs.
const logPrefix = "requests"
// objectNamesForDate returns the names of all objects in the bucket
// corresponding to the logPrefix and date.
// It assumes that the bucket is organized like a Cloud Logging storage sink for
// vulndb requests, with all files for a date in the directory
// logPrefix/YYYY/MM/DD.
func objectNamesForDate(ctx context.Context, bucket *storage.BucketHandle, logPrefix string, date civil.Date) (names []string, err error) {
defer derrors.Wrap(&err, "objectNamesForDate(%q, %s)", logPrefix, date)
q := &storage.Query{Prefix: fmt.Sprintf("%s/%04d/%02d/%02d/", logPrefix, date.Year, date.Month, date.Day)}
q.SetAttrSelection([]string{"Name"}) // Retrieve only the name of the JSON file.
iter := bucket.Objects(ctx, q)
for {
attrs, err := iter.Next()
if err == iterator.Done {
break
}
if err != nil {
return nil, err
}
names = append(names, attrs.Name)
}
return names, nil
}
type logEntry struct {
Timestamp time.Time `json:"timestamp"`
HTTPRequest struct {
RemoteIP string `json:"remoteIp"`
} `json:"httpRequest"`
}
// readJSONLogEntries reads the contents of r, which must consist of a sequence
// of JSON objects each of which has the fields of a logEntry.
// For each entry, after obfuscating the IP using hmacKey, it calls fn on the entry.
func readJSONLogEntries(r io.Reader, hmacKey []byte, fn func(e *logEntry) error) (err error) {
defer derrors.Wrap(&err, "readJSONLogEntries")
dec := json.NewDecoder(r)
for dec.More() {
var e logEntry
if err := dec.Decode(&e); err != nil {
return err
}
e.HTTPRequest.RemoteIP = obfuscate(e.HTTPRequest.RemoteIP, hmacKey)
if err := fn(&e); err != nil {
return err
}
}
return nil
}
func obfuscate(ip string, hmacKey []byte) string {
mac := hmac.New(sha256.New, hmacKey)
io.WriteString(mac, ip)
return hex.EncodeToString(mac.Sum(nil))
}