Initial commit

This commit is contained in:
ChaoticByte 2023-10-11 22:14:01 +02:00
parent f6c2eafc54
commit a6377b805d
10 changed files with 799 additions and 2 deletions

5
.gitignore vendored
View file

@ -19,3 +19,8 @@
# Go workspace file
go.work
data
config
*.bak
wid-notifier

164
README.md
View file

@ -1,2 +1,162 @@
# wid-notifier
A tool that sends configurable email notifications for wid.cert-bund.de and wid.lsi.bayern.de
# WID Notifier
A tool that sends configurable email notifications for
- https://wid.cert-bund.de/portal/wid/kurzinformationen
- https://wid.lsi.bayern.de/portal/wid/warnmeldungen
## Supported Platforms
This Software only supports Linux.
# Config
Example:
```json
{
"api_fetch_interval": 600,
"enabled_api_endpoints": [
"bay",
"bund"
],
"datafile": "data",
"recipients": [
{
"address": "guenther@example.org",,
"include": [
{"classification": "kritisch"},
{"title_contains": "jQuery"}
]
}
],
"smtp": {
"from": "WID Notifier \u003cfrom@example.org\u003e",
"host": "example.org",
"port": 587,
"user": "from@example.org",
"password": "SiEhAbEnMiChInSgEsIcHtGeFiLmTdAsDüRfEnSiEnIcHt"
},
"template": {
"subject": "",
"body": ""
}
}
```
## Filters
You must filter the notices to be sent per user. Multiple filters can be set per user and multiple criteria can be defined per filter.
```json
"include": [
{
"any": false,
"title_contains": "",
"classification": "",
"min_basescore": 0,
"status": "",
"products_contain": "",
"no_patch": ""
},
...
]
```
The following filter criteria are supported. Criteria marked with `*` are for optional fields that are not supported by every API endpoint (e.g. https://wid.lsi.bayern.de) - notices from those endpoints will therefore not be included when using those filters.
### any
Includes all notices if set to `true`.
```json
{"any": true}
```
### title_contains
Include notices whose title contains this text.
```json
{"title_contains": "Denial Of Service"}
```
If set to `""`, this criteria will be ignored.
### classification
Include notices whose classification is in this list.
Classification can be `"kritisch"`, `"hoch"`, `"mittel"` or `"niedrig"`.
```json
{"classification": "hoch"}
```
If set to `""`, this criteria will be ignored.
### min_basescore `*`
Include notices whose basescore (`0` - `100`) is >= `min_basescore`.
```json
{"min_basescore": 40}
```
This criteria will be ignored if set to `0`.
### status `*`
Include notices with this status. This is usually either `NEU` or `UPDATE`.
```json
{"status": "NEU"}
```
If set to `""`, this criteria will be ignored.
### products_contain `*`
Include notices whose product list contains this text.
```json
{"products_contain": "Debian Linux"}
```
If set to `""`, this criteria will be ignored.
### no_patch `*`
If set to `"true"`, notices where no patch is available will be included.
```json
{"no_patch": "true"}
```
If set to `"false"`, notices where no patch is available will be included.
```json
{"no_patch": "false"}
```
If set to `""`, this criteria will be ignored.
## Templates
The syntax for the mail templates is described [here](https://pkg.go.dev/text/template).
All fields from the WidNotice struct can be used.
```go
type WidNotice struct {
Uuid string
Name string
Title string
Published time.Time
Classification string
// optional fields (only fully supported by cert-bund)
Basescore int // -1 = unknown
Status string // "" = unknown
ProductNames []string // empty = unknown
Cves []string // empty = unknown
NoPatch string // "" = unknown
// metadata
PortalUrl string
}
```
For an example, take a look at `DEFAULT_SUBJECT_TEMPLATE` and `DEFAULT_BODY_TEMPLATE` in [template.go](./template.go).

148
datastore.go Normal file
View file

@ -0,0 +1,148 @@
package main
import (
"encoding/json"
"errors"
"fmt"
"io/fs"
"os"
"time"
)
type Config struct {
ApiFetchInterval int `json:"api_fetch_interval"` // in seconds
EnabledApiEndpoints []string `json:"enabled_api_endpoints"`
PersistentDataFilePath string `json:"datafile"`
Recipients []Recipient `json:"recipients"`
SmtpConfiguration SmtpSettings `json:"smtp"`
Template MailTemplateConfig `json:"template"`
}
func NewConfig() Config {
// Initial config
c := Config{
ApiFetchInterval: 60 * 10, // every 10 minutes,
EnabledApiEndpoints: []string{"bay", "bund"},
PersistentDataFilePath: "data",
Recipients: []Recipient{},
SmtpConfiguration: SmtpSettings{
From: "WID Notifier <from@example.org>",
User: "from@example.org",
Password: "SiEhAbEnMiChInSgEsIcHtGeFiLmTdAsDüRfEnSiEnIcHt",
ServerHost: "example.org",
ServerPort: 587},
Template: MailTemplateConfig{
SubjectTemplate: "",
BodyTemplate: "",
},
}
return c
}
func checkConfig(config Config) {
if len(config.Recipients) < 1 {
fmt.Println("ERROR\tConfiguration is incomplete.")
panic(errors.New("no recipients are configured"))
}
for _, r := range config.Recipients {
if !mailAddressIsValid(r.Address) {
fmt.Println("ERROR\tConfiguration includes invalid data.")
panic(errors.New("'" + r.Address + "' is not a valid e-mail address"))
}
if len(r.Filters) < 1 {
fmt.Println("ERROR\tConfiguration is incomplete.")
panic(errors.New("recipient " + r.Address + " has no filter defined - at least {'any': true/false} should be configured"))
}
}
if !mailAddressIsValid(config.SmtpConfiguration.From) {
fmt.Println("ERROR\tConfiguration includes invalid data.")
panic(errors.New("'" + config.SmtpConfiguration.From + "' is not a valid e-mail address"))
}
}
type PersistentData struct {
// {endpoint id 1: time last published, endpoint id 2: ..., ...}
LastPublished map[string]time.Time `json:"last_published"`
}
func NewPersistentData(c Config) PersistentData {
// Initial persistent data
d := PersistentData{LastPublished: map[string]time.Time{}}
for _, e := range apiEndpoints {
d.LastPublished[e.Id] = time.Now().Add(-time.Hour * 24) // a day ago
}
return d
}
type DataStore struct {
filepath string
prettyJSON bool
data any
fileMode fs.FileMode
}
func (ds *DataStore) save() error {
var err error
var data []byte
if ds.prettyJSON {
data, err = json.MarshalIndent(ds.data, "", " ")
} else {
data, err = json.Marshal(ds.data)
}
if err != nil {
return err
}
err = os.WriteFile(ds.filepath, data, ds.fileMode)
return err
}
func (ds *DataStore) load() error {
data, err := os.ReadFile(ds.filepath)
if err != nil {
return err
}
switch ds.data.(type) {
case Config:
d, _ := ds.data.(Config);
err = json.Unmarshal(data, &d)
if err != nil {
return err
}
ds.data = d
case PersistentData:
d, _ := ds.data.(PersistentData);
err = json.Unmarshal(data, &d)
if err != nil {
return err
}
ds.data = d
}
return err
}
func (ds *DataStore) init() error {
err := ds.load()
if err != nil {
if !os.IsNotExist(err) {
return err
} else {
// Write initial data
err = ds.save()
}
}
return err
}
func NewDataStore(savePath string, data any, prettyJSON bool, fileMode fs.FileMode) DataStore {
// Initial data
ds := DataStore{}
ds.filepath = savePath
ds.data = data
ds.prettyJSON = prettyJSON
ds.fileMode = fileMode
if err := ds.init(); err != nil {
// We don't like that, we panic
panic(err)
}
return ds
}

56
filter.go Normal file
View file

@ -0,0 +1,56 @@
package main
import (
"strings"
)
type Filter struct {
Any bool `json:"any"`
TitleContains string `json:"title_contains"`
Classification string `json:"classification"`
MinBaseScore int `json:"min_basescore"`
Status string `json:"status"`
ProductsContain string `json:"products_contain"`
NoPatch string `json:"no_patch"`
}
func (f Filter) filter(notices []WidNotice) []WidNotice {
filteredNotices := []WidNotice{}
for _, n := range notices {
matches := []bool{}
if f.Any {
matches = append(matches, true)
} else
{
if f.TitleContains != "" {
matches = append(matches, strings.Contains(n.Title, f.TitleContains))
}
if f.Classification != "" {
matches = append(matches, f.Classification == n.Classification)
}
if f.MinBaseScore > 0 {
matches = append(matches, f.MinBaseScore <= n.Basescore)
}
if f.Status != "" {
matches = append(matches, f.Status == n.Status)
}
if f.ProductsContain != "" {
matches = append(matches, len(n.ProductNames) > 0)
}
if f.NoPatch != "" {
matches = append(matches, f.NoPatch == n.NoPatch)
}
}
allMatch := len(matches) > 0
for _, m := range matches {
if !m {
allMatch = false
break
}
}
if allMatch {
filteredNotices = append(filteredNotices, n)
}
}
return filteredNotices
}

3
go.mod Normal file
View file

@ -0,0 +1,3 @@
module github.com/ChaoticByte/wid-notifier
go 1.21.1

91
mail.go Normal file
View file

@ -0,0 +1,91 @@
package main
import (
"encoding/base64"
"fmt"
"net/mail"
"net/smtp"
"slices"
"strings"
)
const MAIL_LINE_SEP = "\r\n"
type MailContent struct {
Subject string
Body string
}
func (c MailContent) serializeValidMail(from string, to string) []byte {
// We'll send base64 encoded Subject & Body, because we Dschörmäns have umlauts
// and I'm too lazy to encode ä into =E4 and so on
subjectEncoded := base64.StdEncoding.EncodeToString([]byte(c.Subject))
bodyEncoded := base64.StdEncoding.EncodeToString(
[]byte( // ensure that all lines end with CRLF
strings.ReplaceAll(
strings.ReplaceAll(c.Body, "\n", MAIL_LINE_SEP), "\r\r", "\r",
),
),
)
data := []byte(fmt.Sprintf(
"Content-Type: text/plain; charset=\"utf-8\"\r\nContent-Transfer-Encoding: base64\r\nFrom: %v%vTo: %v%vSubject: =?utf-8?b?%v?=%v%v%v",
from, MAIL_LINE_SEP,
to, MAIL_LINE_SEP,
subjectEncoded, MAIL_LINE_SEP,
MAIL_LINE_SEP,
bodyEncoded))
// done, I guess
return data
}
type Recipient struct {
Address string `json:"address"`
// Must be a configured filter id
Filters []Filter `json:"include"`
}
type SmtpSettings struct {
From string `json:"from"`
ServerHost string `json:"host"`
ServerPort int `json:"port"`
User string `json:"user"`
Password string `json:"password"`
}
func (r Recipient) filterAndSendNotices(notices []WidNotice, template MailTemplate, auth smtp.Auth, smtpConfig SmtpSettings) error {
filteredNotices := []WidNotice{}
for _, f := range r.Filters {
for _, n := range f.filter(notices) {
if !noticeSliceContains(filteredNotices, n) {
filteredNotices = append(filteredNotices, n)
}
}
}
slices.Reverse(filteredNotices)
for _, n := range filteredNotices {
mailContent, err := template.generate(n)
if err != nil {
fmt.Println("ERROR\tCould not create mail from template.")
fmt.Println(err)
}
// serialize & send mail
data := mailContent.serializeValidMail(smtpConfig.From, r.Address)
err = smtp.SendMail(
fmt.Sprintf("%v:%v", smtpConfig.ServerHost, smtpConfig.ServerPort),
auth,
smtpConfig.From,
[]string{r.Address},
data,
)
if err != nil {
return err
}
// fmt.Printf("%v", strings.ReplaceAll(string(data), "\r", "\\r"))
}
return nil
}
func mailAddressIsValid(address string) bool {
_, err := mail.ParseAddress(address);
return err == nil
}

83
main.go Normal file
View file

@ -0,0 +1,83 @@
package main
import (
"fmt"
"net/smtp"
"os"
"time"
)
func main() {
// get cli arguments
args := os.Args
if len(args) < 2 {
fmt.Printf("Usage: %v <configfile>\nIf the config file doesn't exist, a incomplete configuration with default values is created.\n", args[0])
os.Exit(1)
}
configFilePath := os.Args[1]
// init
println("INFO\tInitializing ...")
config := NewDataStore(
configFilePath,
NewConfig(),
true,
0600,
).data.(Config)
persistent := NewDataStore(
config.PersistentDataFilePath,
NewPersistentData(config),
false,
0640)
// exit handler
defer println("INFO\tExiting ...")
// check config
checkConfig(config)
// create mail template from mail template config
if config.Template.SubjectTemplate == "" {
config.Template.SubjectTemplate = DEFAULT_SUBJECT_TEMPLATE
}
if config.Template.BodyTemplate == "" {
config.Template.BodyTemplate = DEFAULT_BODY_TEMPLATE
}
mailTemplate := NewTemplateFromTemplateConfig(config.Template)
// mail authentication from config
mailAuth := smtp.PlainAuth(
"",
config.SmtpConfiguration.User,
config.SmtpConfiguration.Password,
config.SmtpConfiguration.ServerHost,
)
// filter out disabled api endpoints
enabledApiEndpoints := []ApiEndpoint{}
for _, a := range apiEndpoints {
for _, b := range config.EnabledApiEndpoints {
if a.Id == b {
enabledApiEndpoints = append(enabledApiEndpoints, a)
}
}
}
// main loop
for {
newNotices := []WidNotice{}
for _, a := range enabledApiEndpoints {
n, t, err := a.getNotices(persistent.data.(PersistentData).LastPublished[a.Id])
if err != nil {
// retry
n, t, err = a.getNotices(persistent.data.(PersistentData).LastPublished[a.Id])
}
if err != nil {
// ok then...
fmt.Print("ERROR\t", err)
} else {
newNotices = append(newNotices, n...)
persistent.data.(PersistentData).LastPublished[a.Id] = t
persistent.save()
}
}
// fmt.Println(newNotices)
for _, r := range config.Recipients {
r.filterAndSendNotices(newNotices, mailTemplate, mailAuth, config.SmtpConfiguration)
}
time.Sleep(time.Second * time.Duration(config.ApiFetchInterval))
}
}

42
notice.go Normal file
View file

@ -0,0 +1,42 @@
package main
import (
// "encoding/json"
"time"
)
type WidNotice struct {
// obligatory
Uuid string `json:"uuid"`
Name string `json:"name"`
Title string `json:"title"`
Published time.Time `json:"published"`
Classification string `json:"classification"`
// optional fields (only fully supported by cert-bund)
Basescore int `json:"basescore"` // -1 = unknown
Status string `json:"status"` // "" = unknown
ProductNames []string `json:"productNames"` // empty = unknown
Cves []string `json:"cves"` // empty = unknown
NoPatch string `json:"noPatch"` // "" = unknown
// metadata
PortalUrl string
}
// func (n WidNotice) serialized() ([]byte, error) {
// return json.Marshal(n)
// }
// func NewWidNoticeFromJSON(data []byte) (WidNotice, error) {
// n := WidNotice{}
// err := json.Unmarshal(data, &n)
// return n, err
// }
func noticeSliceContains(notices []WidNotice, notice WidNotice) bool {
for _, x := range notices {
if x.Uuid == notice.Uuid {
return true
}
}
return false
}

71
template.go Normal file
View file

@ -0,0 +1,71 @@
package main
import (
"bytes"
"fmt"
"text/template"
)
const DEFAULT_SUBJECT_TEMPLATE = "{{ if .Status }}{{.Status}} {{ end }}[{{.Classification}}] {{.Title}}"
const DEFAULT_BODY_TEMPLATE = `{{.Name}} - "{{.Title}}" with classification '{{.Classification}}'
Link: {{.PortalUrl}}
Published: {{.Published}}
{{ if .Status }}Status: {{.Status}}
{{ end -}}
{{ if gt .Basescore -1 }}Basescore: {{.Basescore}}
{{ end -}}
{{ if eq .NoPatch "true" }}There is no patch available at the moment!
{{ end }}
Affected Products:
{{ range $product := .ProductNames }} - {{ $product }}
{{ else }} unknown
{{ end }}
Assigned CVEs:
{{ range $cve := .Cves }} - {{ $cve }}
{{ else }} unknown
{{ end }}`
type MailTemplateConfig struct {
SubjectTemplate string `json:"subject"`
BodyTemplate string `json:"body"`
}
type MailTemplate struct {
SubjectTemplate template.Template
BodyTemplate template.Template
}
func (t MailTemplate) generate(notice WidNotice) (MailContent, error) {
c := MailContent{}
buffer := &bytes.Buffer{}
err := t.SubjectTemplate.Execute(buffer, notice)
if err != nil {
return c, err
}
c.Subject = buffer.String()
buffer.Truncate(0) // we can recycle our buffer
err = t.BodyTemplate.Execute(buffer, notice)
if err != nil {
return c, err
}
c.Body = buffer.String()
return c, nil
}
func NewTemplateFromTemplateConfig(tc MailTemplateConfig) MailTemplate {
subjectTemplate, err := template.New("subject").Parse(tc.SubjectTemplate)
if err != nil {
fmt.Println("ERROR\tCould not parse template.")
panic(err)
}
bodyTemplate, err := template.New("body").Parse(tc.BodyTemplate)
if err != nil {
fmt.Println("ERROR\tCould not parse template.")
panic(err)
}
return MailTemplate{
SubjectTemplate: *subjectTemplate,
BodyTemplate: *bodyTemplate,
}
}

138
widapi.go Normal file
View file

@ -0,0 +1,138 @@
package main
import (
"encoding/json"
"fmt"
"io"
"net/http"
"strings"
"time"
)
// known API endpoints
var apiEndpoints []ApiEndpoint = []ApiEndpoint{
{
Id: "bund",
EndpointUrl: "https://wid.cert-bund.de/content/public/securityAdvisory",
PortalUrl: "https://wid.cert-bund.de/portal/wid/securityadvisory",
},
{
Id: "bay",
EndpointUrl: "https://wid.lsi.bayern.de/content/public/securityAdvisory",
PortalUrl: "https://wid.lsi.bayern.de/portal/wid/securityadvisory",
},
}
const PUBLISHED_TIME_FORMAT = "2006-01-02T15:04:05.999-07:00"
const USER_AGENT = "Mozilla/5.0 (X11; Linux x86_64; rv:109.0) Gecko/20100101 Firefox/118.0"
var defaultParams = []string{
"size=1000", // max backlog
"sort=published,desc",
"aboFilter=false",
}
type ApiEndpoint struct {
Id string
EndpointUrl string
PortalUrl string
}
func (e ApiEndpoint) getNotices(since time.Time) ([]WidNotice, time.Time, error) {
// returns a slice of WidNotice and the 'published' field of the last notice
var notices []WidNotice = []WidNotice{}
var err error
params := defaultParams
// params = append(params, "publishedFromFilter=" + publishedFrom.Format(PUBLISHED_FROM_FILTER_TIME_FORMAT))
// ^ looks like the API is f***ed, 'publishedFromFilter=...' does only factor in the day (-2h because of the
// timezone), not the time of the day - echte Deutsche Wertarbeit mal wieder am Start
// -> we have to filter by hand (see below)
url := e.EndpointUrl + "?" + strings.Join(params, "&")
req, _ := http.NewRequest(http.MethodGet, url, nil)
req.Header.Set("User-Agent", USER_AGENT)
client := http.Client{}
res, err := client.Do(req)
if err == nil {
if res.StatusCode == 200 {
resBody, err := io.ReadAll(res.Body)
if err != nil {
return nil, time.Time{}, err
}
var decodedData map[string]interface{}
if err = json.Unmarshal(resBody, &decodedData); err != nil {
return nil, time.Time{}, err
}
notices = parseApiResponse(decodedData, e)
} else {
fmt.Printf("ERROR\tGet \"%v\": %v\n", url, res.Status)
return nil, time.Time{}, err
}
} else {
return nil, time.Time{}, err
}
if len(notices) > 0 {
// And here the filtering begins. yay -.-
noticesFiltered := []WidNotice{}
lastPublished := since
for _, n := range notices {
if n.Published.After(since) {
noticesFiltered = append(noticesFiltered, n)
// while we are at it, we can also find lastPublished
if n.Published.After(lastPublished) {
lastPublished = n.Published
}
}
}
return noticesFiltered, lastPublished, nil
} else {
return nil, time.Time{}, nil
}
}
func parseApiResponse(data map[string]interface{}, apiEndpoint ApiEndpoint) []WidNotice {
var notices []WidNotice = []WidNotice{}
for _, d := range data["content"].([]interface{}) {
d := d.(map[string]interface{})
notice := WidNotice{
Uuid: d["uuid"].(string),
Name: d["name"].(string),
Title: d["title"].(string),
Classification: d["classification"].(string),
}
published, err := time.Parse(PUBLISHED_TIME_FORMAT, d["published"].(string))
if err != nil {
fmt.Println("ERROR\t", err)
}
notice.Published = published
// optional fields
if v, ok := d["basescore"]; ok {
notice.Basescore = int(v.(float64))
} else {
notice.Basescore = -1
}
if v, ok := d["status"]; ok {
notice.Status = v.(string)
}
if v, ok := d["productNames"]; ok {
for _, n := range v.([]interface{}) {
notice.ProductNames = append(notice.ProductNames, n.(string))
}
}
if v, ok := d["cves"]; ok {
for _, c := range v.([]interface{}) {
notice.Cves = append(notice.Cves, c.(string))
}
}
if v, ok := d["noPatch"]; ok {
if v.(bool) {
notice.NoPatch = "true"
} else {
notice.NoPatch = "false"
}
}
// metadata
notice.PortalUrl = apiEndpoint.PortalUrl + "?name=" + notice.Name
notices = append(notices, notice)
}
return notices
}