Compare commits
14 commits
Author | SHA1 | Date | |
---|---|---|---|
c1b3d106a0 | |||
a4fd43e03d | |||
30bf793598 | |||
9b00959fdb | |||
295ceec3de | |||
ef24503214 | |||
2e1aee0313 | |||
b3290c357d | |||
9f31bf557d | |||
e9f39d25b4 | |||
368445e3a5 | |||
e5cc0ccbf1 | |||
8c427cc571 | |||
676454972c |
16 changed files with 418 additions and 279 deletions
3
.gitignore
vendored
3
.gitignore
vendored
|
@ -24,3 +24,6 @@ dist/
|
||||||
data
|
data
|
||||||
config
|
config
|
||||||
*.bak
|
*.bak
|
||||||
|
|
||||||
|
# Config/data files
|
||||||
|
*.json
|
||||||
|
|
41
README.md
41
README.md
|
@ -1,4 +1,4 @@
|
||||||
# WID Notifier
|
# WidNotifier
|
||||||
|
|
||||||
The German [BSI](https://www.bsi.bund.de/) and [LSI Bavaria](https://lsi.bayern.de/) each have a page listing current security notices.
|
The German [BSI](https://www.bsi.bund.de/) and [LSI Bavaria](https://lsi.bayern.de/) each have a page listing current security notices.
|
||||||
This software queries the APIs of these services for new security notices and sends configurable email notifications.
|
This software queries the APIs of these services for new security notices and sends configurable email notifications.
|
||||||
|
@ -17,7 +17,7 @@ This Software only supports Linux.
|
||||||
# Build
|
# Build
|
||||||
|
|
||||||
To cross-compile the software for `i386`, `amd64`, `arm` and `arm64`, run `build.sh`.
|
To cross-compile the software for `i386`, `amd64`, `arm` and `arm64`, run `build.sh`.
|
||||||
You need a go version >= 1.21 and git.
|
You need Go 1.22.x and git.
|
||||||
|
|
||||||
# Usage
|
# Usage
|
||||||
|
|
||||||
|
@ -34,27 +34,28 @@ Example:
|
||||||
```json
|
```json
|
||||||
{
|
{
|
||||||
"api_fetch_interval": 600,
|
"api_fetch_interval": 600,
|
||||||
"datafile": "data",
|
|
||||||
"enabled_api_endpoints": [
|
"enabled_api_endpoints": [
|
||||||
"bay",
|
"bay",
|
||||||
"bund"
|
"bund"
|
||||||
],
|
],
|
||||||
|
"datafile": "data.json",
|
||||||
"loglevel": 2,
|
"loglevel": 2,
|
||||||
"recipients": [
|
"lists": [
|
||||||
{
|
{
|
||||||
"address": "guenther@example.org",
|
"name": "Example List",
|
||||||
"include": [
|
"recipients": ["someone@example.org"],
|
||||||
{"classification": "kritisch"},
|
"filter": [
|
||||||
{"title_contains": "jQuery"}
|
{"classification": "hoch", "title_contains": "Microsoft"},
|
||||||
|
{"classification": "kritisch"}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"smtp": {
|
"smtp": {
|
||||||
"from": "from@example.org",
|
"from": "user@localhost",
|
||||||
"host": "example.org",
|
"host": "127.0.0.1",
|
||||||
"port": 587,
|
"port": 587,
|
||||||
"user": "from@example.org",
|
"user": "user@localhost",
|
||||||
"password": "SiEhAbEnMiChInSgEsIcHtGeFiLmTdAsDüRfEnSiEnIcHt"
|
"password": "change me :)"
|
||||||
},
|
},
|
||||||
"template": {
|
"template": {
|
||||||
"subject": "",
|
"subject": "",
|
||||||
|
@ -67,9 +68,9 @@ To show debug messages, set the `loglevel` to `3`.
|
||||||
|
|
||||||
## Filters
|
## Filters
|
||||||
|
|
||||||
You define filters for notices to be sent per recipient. Multiple filters can be set per recipient and multiple criteria can be used per filter. The configuration field for those filters is `include`. See [Configuration](#configuration) for an example.
|
You define filters for notices to be sent (per recipient). Multiple filters can be set per recipient and multiple criteria can be used per filter. The configuration field for those filters is `include`. See [Configuration](#configuration) for an example.
|
||||||
|
|
||||||
It is determined by the following logic, if a notice is included:
|
If a notice is included is determined by the following logic:
|
||||||
|
|
||||||
```
|
```
|
||||||
{criteria, criteria, ... ALL APPLY}
|
{criteria, criteria, ... ALL APPLY}
|
||||||
|
@ -77,7 +78,7 @@ OR {criteria, criteria, ... ALL APPLY}
|
||||||
OR ...
|
OR ...
|
||||||
```
|
```
|
||||||
|
|
||||||
The following criteria are available. 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 criteria in filters.
|
The following criteria are available. Criteria marked with * are 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 criteria in filters.
|
||||||
|
|
||||||
```json
|
```json
|
||||||
"include": [
|
"include": [
|
||||||
|
@ -122,7 +123,7 @@ Classification can be `"kritisch"`, `"hoch"`, `"mittel"` or `"niedrig"`.
|
||||||
```
|
```
|
||||||
If set to `""`, this criteria will be ignored.
|
If set to `""`, this criteria will be ignored.
|
||||||
|
|
||||||
### min_basescore `*`
|
### min_basescore *
|
||||||
|
|
||||||
Include notices whose basescore (`0` - `100`) is >= `min_basescore`.
|
Include notices whose basescore (`0` - `100`) is >= `min_basescore`.
|
||||||
|
|
||||||
|
@ -131,7 +132,7 @@ Include notices whose basescore (`0` - `100`) is >= `min_basescore`.
|
||||||
```
|
```
|
||||||
This criteria will be ignored if set to `0`.
|
This criteria will be ignored if set to `0`.
|
||||||
|
|
||||||
### status `*`
|
### status *
|
||||||
|
|
||||||
Include notices with this status. This is usually either `NEU` or `UPDATE`.
|
Include notices with this status. This is usually either `NEU` or `UPDATE`.
|
||||||
|
|
||||||
|
@ -140,7 +141,7 @@ Include notices with this status. This is usually either `NEU` or `UPDATE`.
|
||||||
```
|
```
|
||||||
If set to `""`, this criteria will be ignored.
|
If set to `""`, this criteria will be ignored.
|
||||||
|
|
||||||
### products_contain `*`
|
### products_contain *
|
||||||
|
|
||||||
Include notices whose product list contains this text.
|
Include notices whose product list contains this text.
|
||||||
|
|
||||||
|
@ -149,7 +150,7 @@ Include notices whose product list contains this text.
|
||||||
```
|
```
|
||||||
If set to `""`, this criteria will be ignored.
|
If set to `""`, this criteria will be ignored.
|
||||||
|
|
||||||
### no_patch `*`
|
### no_patch *
|
||||||
|
|
||||||
If set to `"true"`, notices where no patch is available will be included.
|
If set to `"true"`, notices where no patch is available will be included.
|
||||||
|
|
||||||
|
@ -202,4 +203,6 @@ type WidNotice struct {
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
|
Additionally, the field `WidNotifierVersion` holds the version of the software.
|
||||||
|
|
||||||
For an example, take a look at `DEFAULT_SUBJECT_TEMPLATE` and `DEFAULT_BODY_TEMPLATE` in [template.go](./template.go).
|
For an example, take a look at `DEFAULT_SUBJECT_TEMPLATE` and `DEFAULT_BODY_TEMPLATE` in [template.go](./template.go).
|
||||||
|
|
19
build.sh
19
build.sh
|
@ -2,14 +2,13 @@
|
||||||
|
|
||||||
VERSION=$(git describe --tags)
|
VERSION=$(git describe --tags)
|
||||||
|
|
||||||
# i386
|
function build() {
|
||||||
GOOS=linux GOARCH=386 go build -o dist/wid-notifier_${VERSION}_linux_i386
|
printf "Compiling version ${VERSION} for ${1}/${2}\t"
|
||||||
|
GOOS=${1} GOARCH=${2} go build -ldflags "-X 'main.Version=${VERSION}'" -o dist/wid-notifier_${VERSION}_${1}_${3}
|
||||||
|
echo "✅"
|
||||||
|
}
|
||||||
|
|
||||||
# amd64
|
build linux "386" i386
|
||||||
GOOS=linux GOARCH=amd64 go build -o dist/wid-notifier_${VERSION}_linux_amd64
|
build linux amd64 amd64
|
||||||
|
build linux arm arm
|
||||||
# arm
|
build linux arm64 arm64
|
||||||
GOOS=linux GOARCH=arm go build -o dist/wid-notifier_${VERSION}_linux_arm
|
|
||||||
|
|
||||||
# arm64
|
|
||||||
GOOS=linux GOARCH=arm64 go build -o dist/wid-notifier_${VERSION}_linux_arm64
|
|
||||||
|
|
66
config.go
Normal file
66
config.go
Normal file
|
@ -0,0 +1,66 @@
|
||||||
|
// Copyright (c) 2024 Julian Müller (ChaoticByte)
|
||||||
|
|
||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"errors"
|
||||||
|
)
|
||||||
|
|
||||||
|
type Config struct {
|
||||||
|
ApiFetchInterval int `json:"api_fetch_interval"` // in seconds
|
||||||
|
EnabledApiEndpoints []string `json:"enabled_api_endpoints"`
|
||||||
|
PersistentDataFilePath string `json:"datafile"`
|
||||||
|
LogLevel int `json:"loglevel"`
|
||||||
|
Lists *[]NotifyList `json:"lists"`
|
||||||
|
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.json",
|
||||||
|
LogLevel: 2,
|
||||||
|
Lists: &[]NotifyList{
|
||||||
|
{ Name: "Example List",
|
||||||
|
Recipients: []string{},
|
||||||
|
Filter: []Filter{},},
|
||||||
|
},
|
||||||
|
SmtpConfiguration: SmtpSettings{
|
||||||
|
From: "user@localhost",
|
||||||
|
User: "user@localhost",
|
||||||
|
Password: "change me :)",
|
||||||
|
ServerHost: "127.0.0.1",
|
||||||
|
ServerPort: 587},
|
||||||
|
Template: MailTemplateConfig{
|
||||||
|
SubjectTemplate: "",
|
||||||
|
BodyTemplate: "",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
return c
|
||||||
|
}
|
||||||
|
|
||||||
|
func checkConfig(config Config) {
|
||||||
|
if len(*config.Lists) < 1 {
|
||||||
|
logger.error("Configuration is incomplete")
|
||||||
|
panic(errors.New("no lists are configured"))
|
||||||
|
}
|
||||||
|
for _, l := range *config.Lists {
|
||||||
|
if len(l.Filter) < 1 {
|
||||||
|
logger.error("Configuration is incomplete")
|
||||||
|
panic(errors.New("list " + l.Name + " has no filter defined - at least [{'any': true/false}] should be configured"))
|
||||||
|
}
|
||||||
|
for _, r := range l.Recipients {
|
||||||
|
if !mailAddressIsValid(r) {
|
||||||
|
logger.error("Configuration includes invalid data")
|
||||||
|
panic(errors.New("'" + r + "' is not a valid e-mail address"))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if !mailAddressIsValid(config.SmtpConfiguration.From) {
|
||||||
|
logger.error("Configuration includes invalid data")
|
||||||
|
panic(errors.New("'" + config.SmtpConfiguration.From + "' is not a valid e-mail address"))
|
||||||
|
}
|
||||||
|
}
|
85
datastore.go
85
datastore.go
|
@ -4,79 +4,10 @@ package main
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"errors"
|
|
||||||
"io/fs"
|
"io/fs"
|
||||||
"os"
|
"os"
|
||||||
"time"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
type Config struct {
|
|
||||||
ApiFetchInterval int `json:"api_fetch_interval"` // in seconds
|
|
||||||
EnabledApiEndpoints []string `json:"enabled_api_endpoints"`
|
|
||||||
PersistentDataFilePath string `json:"datafile"`
|
|
||||||
LogLevel int `json:"loglevel"`
|
|
||||||
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",
|
|
||||||
LogLevel: 2,
|
|
||||||
Recipients: []Recipient{},
|
|
||||||
SmtpConfiguration: SmtpSettings{
|
|
||||||
From: "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 {
|
|
||||||
logger.error("Configuration is incomplete")
|
|
||||||
panic(errors.New("no recipients are configured"))
|
|
||||||
}
|
|
||||||
for _, r := range config.Recipients {
|
|
||||||
if !mailAddressIsValid(r.Address) {
|
|
||||||
logger.error("Configuration includes invalid data")
|
|
||||||
panic(errors.New("'" + r.Address + "' is not a valid e-mail address"))
|
|
||||||
}
|
|
||||||
if len(r.Filters) < 1 {
|
|
||||||
logger.error("Configuration 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) {
|
|
||||||
logger.error("Configuration 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 {
|
type DataStore struct {
|
||||||
filepath string
|
filepath string
|
||||||
prettyJSON bool
|
prettyJSON bool
|
||||||
|
@ -92,32 +23,24 @@ func (ds *DataStore) save() error {
|
||||||
} else {
|
} else {
|
||||||
data, err = json.Marshal(ds.data)
|
data, err = json.Marshal(ds.data)
|
||||||
}
|
}
|
||||||
if err != nil {
|
if err != nil { return err }
|
||||||
return err
|
|
||||||
}
|
|
||||||
err = os.WriteFile(ds.filepath, data, ds.fileMode)
|
err = os.WriteFile(ds.filepath, data, ds.fileMode)
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
func (ds *DataStore) load() error {
|
func (ds *DataStore) load() error {
|
||||||
data, err := os.ReadFile(ds.filepath)
|
data, err := os.ReadFile(ds.filepath)
|
||||||
if err != nil {
|
if err != nil { return err }
|
||||||
return err
|
|
||||||
}
|
|
||||||
switch ds.data.(type) {
|
switch ds.data.(type) {
|
||||||
case Config:
|
case Config:
|
||||||
d, _ := ds.data.(Config);
|
d, _ := ds.data.(Config);
|
||||||
err = json.Unmarshal(data, &d)
|
err = json.Unmarshal(data, &d)
|
||||||
if err != nil {
|
if err != nil { return err }
|
||||||
return err
|
|
||||||
}
|
|
||||||
ds.data = d
|
ds.data = d
|
||||||
case PersistentData:
|
case PersistentData:
|
||||||
d, _ := ds.data.(PersistentData);
|
d, _ := ds.data.(PersistentData);
|
||||||
err = json.Unmarshal(data, &d)
|
err = json.Unmarshal(data, &d)
|
||||||
if err != nil {
|
if err != nil { return err }
|
||||||
return err
|
|
||||||
}
|
|
||||||
ds.data = d
|
ds.data = d
|
||||||
}
|
}
|
||||||
return err
|
return err
|
||||||
|
|
2
go.mod
2
go.mod
|
@ -1,3 +1,3 @@
|
||||||
module github.com/ChaoticByte/wid-notifier
|
module github.com/ChaoticByte/wid-notifier
|
||||||
|
|
||||||
go 1.21
|
go 1.24
|
||||||
|
|
108
mail.go
108
mail.go
|
@ -1,108 +0,0 @@
|
||||||
// Copyright (c) 2023 Julian Müller (ChaoticByte)
|
|
||||||
|
|
||||||
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, cache *map[string][]byte) 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)
|
|
||||||
logger.debug(fmt.Sprintf("Including %v of %v notices for recipient %v", len(filteredNotices), len(notices), r.Address))
|
|
||||||
logger.debug("Generating and sending mails to " + r.Address + " ...")
|
|
||||||
cacheHits := 0
|
|
||||||
cacheMisses := 0
|
|
||||||
for _, n := range filteredNotices {
|
|
||||||
var data []byte
|
|
||||||
cacheResult := (*cache)[n.Uuid]
|
|
||||||
if len(cacheResult) > 0 {
|
|
||||||
cacheHits++
|
|
||||||
data = cacheResult
|
|
||||||
} else {
|
|
||||||
cacheMisses++
|
|
||||||
mailContent, err := template.generate(n)
|
|
||||||
if err != nil {
|
|
||||||
logger.error("Could not create mail from template")
|
|
||||||
logger.error(err)
|
|
||||||
}
|
|
||||||
// serialize & send mail
|
|
||||||
data = mailContent.serializeValidMail(smtpConfig.From, r.Address)
|
|
||||||
// add to cache
|
|
||||||
(*cache)[n.Uuid] = data
|
|
||||||
}
|
|
||||||
err := smtp.SendMail(
|
|
||||||
fmt.Sprintf("%v:%v", smtpConfig.ServerHost, smtpConfig.ServerPort),
|
|
||||||
auth,
|
|
||||||
smtpConfig.From,
|
|
||||||
[]string{r.Address},
|
|
||||||
data,
|
|
||||||
)
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
}
|
|
||||||
logger.debug(fmt.Sprintf("%v mail cache hits, %v misses", cacheHits, cacheMisses))
|
|
||||||
logger.debug("Successfully sent all mails to " + r.Address)
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func mailAddressIsValid(address string) bool {
|
|
||||||
_, err := mail.ParseAddress(address);
|
|
||||||
return err == nil
|
|
||||||
}
|
|
90
mail_common.go
Normal file
90
mail_common.go
Normal file
|
@ -0,0 +1,90 @@
|
||||||
|
// Copyright (c) 2023 Julian Müller (ChaoticByte)
|
||||||
|
|
||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"mime"
|
||||||
|
"mime/quotedprintable"
|
||||||
|
"net/mail"
|
||||||
|
"net/smtp"
|
||||||
|
"strings"
|
||||||
|
)
|
||||||
|
|
||||||
|
type MailContent struct {
|
||||||
|
Subject string
|
||||||
|
Body string
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c MailContent) serializeValidMail(from string, to string) []byte {
|
||||||
|
// format subject using Q Encoding from RFC2047
|
||||||
|
subjectEncoded := mime.QEncoding.Encode("utf-8", c.Subject)
|
||||||
|
// format body using Quoted-Printable Encoding from RFC2045
|
||||||
|
var bodyEncoded strings.Builder
|
||||||
|
bew := quotedprintable.NewWriter(&bodyEncoded)
|
||||||
|
bew.Write([]byte(c.Body))
|
||||||
|
bew.Close()
|
||||||
|
// glue it all together
|
||||||
|
data := fmt.Appendf(nil,
|
||||||
|
"Content-Type: text/plain; charset=\"utf-8\"\r\nContent-Transfer-Encoding: Quoted-Printable\r\nFrom: %v\r\nTo: %v\r\nSubject: %v\r\n\r\n%v",
|
||||||
|
from, to, subjectEncoded, bodyEncoded.String(),
|
||||||
|
)
|
||||||
|
return data
|
||||||
|
}
|
||||||
|
|
||||||
|
type NotifyList struct {
|
||||||
|
Name string `json:"name"`
|
||||||
|
Recipients []string `json:"recipients"`
|
||||||
|
// Must be a configured filter id
|
||||||
|
Filter []Filter `json:"filter"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type SmtpSettings struct {
|
||||||
|
From string `json:"from"`
|
||||||
|
ServerHost string `json:"host"`
|
||||||
|
ServerPort int `json:"port"`
|
||||||
|
User string `json:"user"`
|
||||||
|
Password string `json:"password"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func sendNotices(recipient string, notices []*WidNotice, template MailTemplate, auth smtp.Auth, smtpConfig SmtpSettings, mailContentCache *map[string]*MailContent) error {
|
||||||
|
logger.debug("Generating and sending mails for recipient " + recipient + " ...")
|
||||||
|
cacheHits := 0
|
||||||
|
cacheMisses := 0
|
||||||
|
mails := []*MailContent{}
|
||||||
|
for _, n := range notices {
|
||||||
|
var mc *MailContent
|
||||||
|
cacheResult := (*mailContentCache)[n.Uuid]
|
||||||
|
if cacheResult != nil {
|
||||||
|
cacheHits++
|
||||||
|
mc = cacheResult
|
||||||
|
} else {
|
||||||
|
cacheMisses++
|
||||||
|
mc_, err := template.generate(TemplateData{n, Version})
|
||||||
|
if err != nil {
|
||||||
|
logger.error("Could not create mail from template")
|
||||||
|
logger.error(err)
|
||||||
|
} else {
|
||||||
|
mc = &mc_
|
||||||
|
// add to cache
|
||||||
|
(*mailContentCache)[n.Uuid] = mc
|
||||||
|
}
|
||||||
|
}
|
||||||
|
mails = append(mails, mc)
|
||||||
|
}
|
||||||
|
logger.debug(fmt.Sprintf("%v mail cache hits, %v misses", cacheHits, cacheMisses))
|
||||||
|
err := sendMails(
|
||||||
|
smtpConfig,
|
||||||
|
auth,
|
||||||
|
recipient,
|
||||||
|
mails,
|
||||||
|
)
|
||||||
|
if err != nil { return err }
|
||||||
|
logger.debug("Successfully sent all mails to " + recipient)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func mailAddressIsValid(address string) bool {
|
||||||
|
_, err := mail.ParseAddress(address);
|
||||||
|
return err == nil
|
||||||
|
}
|
57
mail_transfer.go
Normal file
57
mail_transfer.go
Normal file
|
@ -0,0 +1,57 @@
|
||||||
|
// +build !debug_mail_transfer
|
||||||
|
// Copyright (c) 2023 Julian Müller (ChaoticByte)
|
||||||
|
|
||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"crypto/tls"
|
||||||
|
"fmt"
|
||||||
|
"net/smtp"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
func sendMails(smtpConf SmtpSettings, auth smtp.Auth, to string, mails []*MailContent) error {
|
||||||
|
addr := fmt.Sprintf("%v:%v", smtpConf.ServerHost, smtpConf.ServerPort)
|
||||||
|
logger.debug("Connecting to mail server at " + addr + " ...")
|
||||||
|
connection, err := smtp.Dial(addr)
|
||||||
|
if err != nil { return err }
|
||||||
|
defer connection.Close()
|
||||||
|
// can leave out connection.Hello
|
||||||
|
hasTlsExt, _ := connection.Extension("starttls")
|
||||||
|
if hasTlsExt {
|
||||||
|
err = connection.StartTLS(&tls.Config{ServerName: smtpConf.ServerHost})
|
||||||
|
if err != nil { return err }
|
||||||
|
logger.debug("Mail Server supports StartTLS")
|
||||||
|
} else {
|
||||||
|
logger.debug("Mail Server doesn't support StartTLS")
|
||||||
|
}
|
||||||
|
logger.debug("Authenticating to mail server ...")
|
||||||
|
err = connection.Auth(auth)
|
||||||
|
if err != nil { return err }
|
||||||
|
if logger.LogLevel >= 3 {
|
||||||
|
fmt.Printf("DEBUG %v Sending mails to server ", time.Now().Format("2006/01/02 15:04:05.000000"))
|
||||||
|
}
|
||||||
|
for _, mc := range mails {
|
||||||
|
// serialize mail
|
||||||
|
d := mc.serializeValidMail(smtpConf.From, to)
|
||||||
|
// send mail
|
||||||
|
err = connection.Mail(smtpConf.From)
|
||||||
|
if err != nil { return err }
|
||||||
|
err = connection.Rcpt(to)
|
||||||
|
if err != nil { return err }
|
||||||
|
writer, err := connection.Data()
|
||||||
|
if err != nil { return err }
|
||||||
|
_, err = writer.Write(d)
|
||||||
|
if err != nil { return err }
|
||||||
|
err = writer.Close()
|
||||||
|
if err != nil { return err }
|
||||||
|
if logger.LogLevel >= 3 {
|
||||||
|
print(".")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if logger.LogLevel >= 3 {
|
||||||
|
print("\n")
|
||||||
|
}
|
||||||
|
return connection.Quit()
|
||||||
|
}
|
26
mail_transfer_debug.go
Normal file
26
mail_transfer_debug.go
Normal file
|
@ -0,0 +1,26 @@
|
||||||
|
// +build debug_mail_transfer
|
||||||
|
// Copyright (c) 2023 Julian Müller (ChaoticByte)
|
||||||
|
|
||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"net/smtp"
|
||||||
|
)
|
||||||
|
|
||||||
|
func sendMails(smtpConf SmtpSettings, auth smtp.Auth, to string, mails []*MailContent) error {
|
||||||
|
logger.warn("Mail Transfer Debugging is active. Not connecting.")
|
||||||
|
logger.info("MAIL TRANSFER: \n\n")
|
||||||
|
for _, mc := range mails {
|
||||||
|
// serialize mail
|
||||||
|
d := mc.serializeValidMail(smtpConf.From, to)
|
||||||
|
// output mail
|
||||||
|
fmt.Println("MAIL FROM:" + smtpConf.From)
|
||||||
|
fmt.Println("RCPT TO:" + to)
|
||||||
|
fmt.Println("DATA")
|
||||||
|
fmt.Println(string(d))
|
||||||
|
fmt.Println(".")
|
||||||
|
}
|
||||||
|
fmt.Print("\n\n")
|
||||||
|
return nil
|
||||||
|
}
|
92
main.go
92
main.go
|
@ -6,35 +6,55 @@ import (
|
||||||
"fmt"
|
"fmt"
|
||||||
"net/smtp"
|
"net/smtp"
|
||||||
"os"
|
"os"
|
||||||
|
"slices"
|
||||||
"time"
|
"time"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
var executableName string
|
||||||
var logger Logger
|
var logger Logger
|
||||||
|
|
||||||
|
|
||||||
|
func showVersion() {
|
||||||
|
fmt.Printf("wid-notifier %s\n", Version)
|
||||||
|
}
|
||||||
|
|
||||||
|
func showHelp() {
|
||||||
|
fmt.Printf("Usage: %v <configfile>\n\nIf the config file doesn't exist, an incomplete \n" +
|
||||||
|
"configuration with default values is created.\n\n",
|
||||||
|
executableName)
|
||||||
|
showVersion()
|
||||||
|
}
|
||||||
|
|
||||||
func main() {
|
func main() {
|
||||||
// get cli arguments
|
// get cli arguments
|
||||||
args := os.Args
|
args := os.Args
|
||||||
|
executableName = args[0]
|
||||||
if len(args) < 2 {
|
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])
|
showHelp()
|
||||||
os.Exit(1)
|
os.Exit(1)
|
||||||
}
|
}
|
||||||
configFilePath := os.Args[1]
|
for _, arg := range args {
|
||||||
|
if arg == "-h" || arg == "--help" {
|
||||||
|
showHelp()
|
||||||
|
os.Exit(0)
|
||||||
|
} else if arg == "--version" {
|
||||||
|
showVersion()
|
||||||
|
os.Exit(0)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
configFilePath := args[1]
|
||||||
// create logger
|
// create logger
|
||||||
logger = NewLogger(2)
|
logger = NewLogger(2)
|
||||||
// init
|
// init
|
||||||
logger.info("Initializing ...")
|
logger.info("Initializing ...")
|
||||||
defer logger.info("Exiting ...")
|
defer logger.info("Exiting ...")
|
||||||
|
// open & check config
|
||||||
config := NewDataStore(
|
config := NewDataStore(
|
||||||
configFilePath,
|
configFilePath,
|
||||||
NewConfig(),
|
NewConfig(),
|
||||||
true,
|
true,
|
||||||
0600,
|
0600,
|
||||||
).data.(Config)
|
).data.(Config)
|
||||||
persistent := NewDataStore(
|
|
||||||
config.PersistentDataFilePath,
|
|
||||||
NewPersistentData(config),
|
|
||||||
false,
|
|
||||||
0640)
|
|
||||||
logger.LogLevel = config.LogLevel
|
logger.LogLevel = config.LogLevel
|
||||||
logger.debug("Checking configuration file ...")
|
logger.debug("Checking configuration file ...")
|
||||||
checkConfig(config)
|
checkConfig(config)
|
||||||
|
@ -66,17 +86,24 @@ func main() {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
// open data file
|
||||||
|
persistent := NewDataStore(
|
||||||
|
config.PersistentDataFilePath,
|
||||||
|
NewPersistentData(config),
|
||||||
|
false,
|
||||||
|
0640)
|
||||||
// main loop
|
// main loop
|
||||||
logger.debug("Entering main loop ...")
|
logger.debug("Entering main loop ...")
|
||||||
for {
|
for {
|
||||||
t1 := time.Now().UnixMilli()
|
t1 := time.Now().UnixMilli()
|
||||||
newNotices := []WidNotice{}
|
newNotices := []WidNotice{}
|
||||||
cache := map[string][]byte{}
|
lastPublished := map[string]time.Time{} // endpoint id : last published timestamp
|
||||||
|
cache := map[string]*MailContent{} // cache generated emails for reuse
|
||||||
for _, a := range enabledApiEndpoints {
|
for _, a := range enabledApiEndpoints {
|
||||||
logger.info("Querying endpoint '" + a.Id + "' for new notices ...")
|
logger.info("Querying endpoint '" + a.Id + "' for new notices ...")
|
||||||
n, t, err := a.getNotices(persistent.data.(PersistentData).LastPublished[a.Id])
|
n, t, err := a.getNotices(persistent.data.(PersistentData).LastPublished[a.Id])
|
||||||
if err != nil {
|
if err != nil {
|
||||||
// retry
|
// retry (once)
|
||||||
logger.warn("Couldn't query notices from API endpoint '" + a.Id + "'. Retrying ...")
|
logger.warn("Couldn't query notices from API endpoint '" + a.Id + "'. Retrying ...")
|
||||||
logger.warn(err)
|
logger.warn(err)
|
||||||
n, t, err = a.getNotices(persistent.data.(PersistentData).LastPublished[a.Id])
|
n, t, err = a.getNotices(persistent.data.(PersistentData).LastPublished[a.Id])
|
||||||
|
@ -87,26 +114,59 @@ func main() {
|
||||||
logger.error(err)
|
logger.error(err)
|
||||||
} else if len(n) > 0 {
|
} else if len(n) > 0 {
|
||||||
newNotices = append(newNotices, n...)
|
newNotices = append(newNotices, n...)
|
||||||
persistent.data.(PersistentData).LastPublished[a.Id] = t
|
lastPublished[a.Id] = t
|
||||||
persistent.save()
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
logger.debug(fmt.Sprintf("Got %v new notices", len(newNotices)))
|
logger.debug(fmt.Sprintf("Got %v new notices", len(newNotices)))
|
||||||
if len(newNotices) > 0 {
|
if len(newNotices) > 0 {
|
||||||
logger.info("Sending email notifications ...")
|
logger.info("Sending email notifications ...")
|
||||||
|
// mail recipient : pointer to slice of wid notices to be sent
|
||||||
|
noticesToBeSent := map[string][]*WidNotice{}
|
||||||
recipientsNotified := 0
|
recipientsNotified := 0
|
||||||
for _, r := range config.Recipients {
|
var err error
|
||||||
err := r.filterAndSendNotices(newNotices, mailTemplate, mailAuth, config.SmtpConfiguration, &cache)
|
for _, l := range *config.Lists {
|
||||||
|
// Filter notices for this list
|
||||||
|
for _, f := range l.Filter {
|
||||||
|
for _, n := range f.filter(newNotices) {
|
||||||
|
np := &n
|
||||||
|
for _, r := range l.Recipients {
|
||||||
|
if !noticeSliceContains(noticesToBeSent[r], np) {
|
||||||
|
noticesToBeSent[r] = append(noticesToBeSent[r], np)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for r, notices := range noticesToBeSent {
|
||||||
|
// sort by publish date
|
||||||
|
slices.SortFunc(notices, func(a *WidNotice, b *WidNotice) int {
|
||||||
|
if a.Published == b.Published {
|
||||||
|
return 0
|
||||||
|
} else if a.Published.After(b.Published) {
|
||||||
|
return 1
|
||||||
|
} else {
|
||||||
|
return -1
|
||||||
|
}
|
||||||
|
})
|
||||||
|
// send
|
||||||
|
err = sendNotices(r, notices, mailTemplate, mailAuth, config.SmtpConfiguration, &cache)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
logger.error(err)
|
logger.error(err)
|
||||||
} else {
|
} else {
|
||||||
recipientsNotified++
|
recipientsNotified++
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
logger.info(fmt.Sprintf("Email notifications sent to %v of %v recipients", recipientsNotified, len(config.Recipients)))
|
if recipientsNotified < 1 && err != nil {
|
||||||
|
logger.error("Couldn't send any mail notification!")
|
||||||
|
} else {
|
||||||
|
for id, t := range lastPublished {
|
||||||
|
persistent.data.(PersistentData).LastPublished[id] = t
|
||||||
|
persistent.save()
|
||||||
|
}
|
||||||
|
logger.info(fmt.Sprintf("Email notifications sent to %v of %v recipients", recipientsNotified, len(noticesToBeSent)))
|
||||||
|
}
|
||||||
}
|
}
|
||||||
t2 := time.Now().UnixMilli()
|
dt := int(time.Now().UnixMilli() - t1)
|
||||||
dt := int(t2 - t1)
|
|
||||||
time.Sleep(time.Millisecond * time.Duration((config.ApiFetchInterval * 1000) - dt))
|
time.Sleep(time.Millisecond * time.Duration((config.ApiFetchInterval * 1000) - dt))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
12
notice.go
12
notice.go
|
@ -25,17 +25,7 @@ type WidNotice struct {
|
||||||
PortalUrl string
|
PortalUrl string
|
||||||
}
|
}
|
||||||
|
|
||||||
// func (n WidNotice) serialized() ([]byte, error) {
|
func noticeSliceContains(notices []*WidNotice, notice *WidNotice) bool {
|
||||||
// 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 {
|
for _, x := range notices {
|
||||||
if x.Uuid == notice.Uuid {
|
if x.Uuid == notice.Uuid {
|
||||||
return true
|
return true
|
||||||
|
|
21
persistent_data.go
Normal file
21
persistent_data.go
Normal file
|
@ -0,0 +1,21 @@
|
||||||
|
// Copyright (c) 2024 Julian Müller (ChaoticByte)
|
||||||
|
|
||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
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
|
||||||
|
}
|
58
template.go
58
template.go
|
@ -7,23 +7,35 @@ import (
|
||||||
"text/template"
|
"text/template"
|
||||||
)
|
)
|
||||||
|
|
||||||
const DEFAULT_SUBJECT_TEMPLATE = "{{ if .Status }}{{.Status}} {{ end }}[{{.Classification}}] {{.Title}}"
|
const DEFAULT_SUBJECT_TEMPLATE = "[{{ .Classification }}] {{ .Title }}"
|
||||||
const DEFAULT_BODY_TEMPLATE = `{{.Name}}
|
const DEFAULT_BODY_TEMPLATE = `{{ if .Status }}[{{ .Status }}] {{ end }}{{ .Name }}
|
||||||
{{.PortalUrl}}
|
-> {{ .PortalUrl }}
|
||||||
|
{{- if eq .NoPatch "true" }}
|
||||||
|
|
||||||
Published: {{.Published}}
|
No patch available!
|
||||||
{{ if gt .Basescore -1 }}Basescore: {{.Basescore}}
|
{{- end }}
|
||||||
{{ end -}}
|
{{ if gt .Basescore -1 }}
|
||||||
{{ if eq .NoPatch "true" }}There is no patch available at the moment!
|
Basescore: {{ .Basescore }}{{- end }}
|
||||||
{{ end }}
|
Published: {{ .Published }}
|
||||||
Affected Products:
|
{{- if .ProductNames }}
|
||||||
{{ range $product := .ProductNames }} - {{ $product }}
|
|
||||||
{{ else }} unknown
|
Affected Products:{{ range $product := .ProductNames }}
|
||||||
{{ end }}
|
- {{ $product }}
|
||||||
Assigned CVEs:
|
{{- end }}{{ end }}
|
||||||
{{ range $cve := .Cves }} - {{ $cve }}
|
{{- if .Cves }}
|
||||||
{{ else }} unknown
|
|
||||||
{{ end }}`
|
Assigned CVEs:{{ range $cve := .Cves }}
|
||||||
|
- {{ $cve }} -> https://www.cve.org/CVERecord?id={{ $cve }}
|
||||||
|
{{- end }}{{ end }}
|
||||||
|
|
||||||
|
|
||||||
|
Sent by WidNotifier {{ .WidNotifierVersion }}
|
||||||
|
`
|
||||||
|
|
||||||
|
type TemplateData struct {
|
||||||
|
*WidNotice
|
||||||
|
WidNotifierVersion string
|
||||||
|
}
|
||||||
|
|
||||||
type MailTemplateConfig struct {
|
type MailTemplateConfig struct {
|
||||||
SubjectTemplate string `json:"subject"`
|
SubjectTemplate string `json:"subject"`
|
||||||
|
@ -35,19 +47,15 @@ type MailTemplate struct {
|
||||||
BodyTemplate template.Template
|
BodyTemplate template.Template
|
||||||
}
|
}
|
||||||
|
|
||||||
func (t MailTemplate) generate(notice WidNotice) (MailContent, error) {
|
func (t MailTemplate) generate(data TemplateData) (MailContent, error) {
|
||||||
c := MailContent{}
|
c := MailContent{}
|
||||||
buffer := &bytes.Buffer{}
|
buffer := &bytes.Buffer{}
|
||||||
err := t.SubjectTemplate.Execute(buffer, notice)
|
err := t.SubjectTemplate.Execute(buffer, data)
|
||||||
if err != nil {
|
if err != nil { return c, err }
|
||||||
return c, err
|
|
||||||
}
|
|
||||||
c.Subject = buffer.String()
|
c.Subject = buffer.String()
|
||||||
buffer.Truncate(0) // we can recycle our buffer
|
buffer.Truncate(0) // we can recycle our buffer
|
||||||
err = t.BodyTemplate.Execute(buffer, notice)
|
err = t.BodyTemplate.Execute(buffer, data)
|
||||||
if err != nil {
|
if err != nil { return c, err }
|
||||||
return c, err
|
|
||||||
}
|
|
||||||
c.Body = buffer.String()
|
c.Body = buffer.String()
|
||||||
return c, nil
|
return c, nil
|
||||||
}
|
}
|
||||||
|
|
3
version.go
Normal file
3
version.go
Normal file
|
@ -0,0 +1,3 @@
|
||||||
|
package main
|
||||||
|
|
||||||
|
var Version = "devel"
|
14
widapi.go
14
widapi.go
|
@ -13,16 +13,16 @@ import (
|
||||||
|
|
||||||
// known API endpoints
|
// known API endpoints
|
||||||
var apiEndpoints []ApiEndpoint = []ApiEndpoint{
|
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",
|
Id: "bay",
|
||||||
EndpointUrl: "https://wid.lsi.bayern.de/content/public/securityAdvisory",
|
EndpointUrl: "https://wid.lsi.bayern.de/content/public/securityAdvisory",
|
||||||
PortalUrl: "https://wid.lsi.bayern.de/portal/wid/securityadvisory",
|
PortalUrl: "https://wid.lsi.bayern.de/portal/wid/securityadvisory",
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
Id: "bund",
|
||||||
|
EndpointUrl: "https://wid.cert-bund.de/content/public/securityAdvisory",
|
||||||
|
PortalUrl: "https://wid.cert-bund.de/portal/wid/securityadvisory",
|
||||||
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
const PUBLISHED_TIME_FORMAT = "2006-01-02T15:04:05.999-07:00"
|
const PUBLISHED_TIME_FORMAT = "2006-01-02T15:04:05.999-07:00"
|
||||||
|
@ -57,9 +57,7 @@ func (e ApiEndpoint) getNotices(since time.Time) ([]WidNotice, time.Time, error)
|
||||||
if err == nil {
|
if err == nil {
|
||||||
if res.StatusCode == 200 {
|
if res.StatusCode == 200 {
|
||||||
resBody, err := io.ReadAll(res.Body)
|
resBody, err := io.ReadAll(res.Body)
|
||||||
if err != nil {
|
if err != nil { return []WidNotice{}, since, err }
|
||||||
return []WidNotice{}, since, err
|
|
||||||
}
|
|
||||||
var decodedData map[string]interface{}
|
var decodedData map[string]interface{}
|
||||||
if err = json.Unmarshal(resBody, &decodedData); err != nil {
|
if err = json.Unmarshal(resBody, &decodedData); err != nil {
|
||||||
return []WidNotice{}, since, err
|
return []WidNotice{}, since, err
|
||||||
|
|
Loading…
Add table
Add a link
Reference in a new issue