Initial commit
This commit is contained in:
parent
f6c2eafc54
commit
a6377b805d
10 changed files with 799 additions and 2 deletions
5
.gitignore
vendored
5
.gitignore
vendored
|
@ -19,3 +19,8 @@
|
||||||
|
|
||||||
# Go workspace file
|
# Go workspace file
|
||||||
go.work
|
go.work
|
||||||
|
|
||||||
|
data
|
||||||
|
config
|
||||||
|
*.bak
|
||||||
|
wid-notifier
|
||||||
|
|
164
README.md
164
README.md
|
@ -1,2 +1,162 @@
|
||||||
# wid-notifier
|
# WID Notifier
|
||||||
A tool that sends configurable email notifications for wid.cert-bund.de and wid.lsi.bayern.de
|
|
||||||
|
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
148
datastore.go
Normal 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
56
filter.go
Normal 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
3
go.mod
Normal file
|
@ -0,0 +1,3 @@
|
||||||
|
module github.com/ChaoticByte/wid-notifier
|
||||||
|
|
||||||
|
go 1.21.1
|
91
mail.go
Normal file
91
mail.go
Normal 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
83
main.go
Normal 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
42
notice.go
Normal 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
71
template.go
Normal 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
138
widapi.go
Normal 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
|
||||||
|
}
|
Loading…
Add table
Add a link
Reference in a new issue