feat: new helpers and masking/encrypting attributes

New helper functions were added to make call-site less likely to need
to pull `go.opentelemetry.io/otel/attribute` as a dependency.
Additionally `Encrypted` and `Masked` were added to add a possibility of
logging sensitive data in a more secure manner.
This commit is contained in:
2024-09-13 15:48:16 +02:00
parent 2004e1b2f5
commit 9a1b41b1ad
4 changed files with 201 additions and 32 deletions

View File

@ -1,18 +1,27 @@
package attr
import (
"crypto/aes"
"crypto/cipher"
"crypto/rand"
"encoding/base64"
"encoding/hex"
"encoding/json"
"errors"
"io"
"os"
"runtime"
"runtime/debug"
"time"
"git.ma-al.com/maal-libraries/observer/pkg/level"
"github.com/gofrs/uuid"
"go.opentelemetry.io/otel/attribute"
"go.opentelemetry.io/otel/semconv/v1.25.0"
semconv "go.opentelemetry.io/otel/semconv/v1.25.0"
"go.opentelemetry.io/otel/trace"
)
type KV = attribute.KeyValue
type KeyValue = attribute.KeyValue
type Key = attribute.Key
type Value = attribute.Value
@ -72,11 +81,192 @@ const (
LayerUtil = "util"
)
type secretKey struct {
Key []byte
Cipher cipher.Block
Nonce []byte
}
// Interprets a string as AES secret key (32 bytes) first decoding it with base64
// It can be used to set the variable `EncryptSecretKey` which is responsible
// for encrypting the `Encrypted` attributes.
func NewSecretKey(key string) (secretKey, error) {
keyBytes, err := base64.RawStdEncoding.DecodeString(key)
if err != nil {
return secretKey{}, err
}
if len(keyBytes) != 32 {
return secretKey{}, errors.New("wrong length of encryption key, should be 32 bits")
}
cipher, err := aes.NewCipher(keyBytes)
if err != nil {
return secretKey{}, err
}
nonce := make([]byte, 12)
if _, err := io.ReadFull(rand.Reader, nonce); err != nil {
return secretKey{}, err
}
return secretKey{
Key: keyBytes,
Cipher: cipher,
Nonce: nonce,
}, nil
}
// **Unless set, it will default to a random key that cannot be later retrievied!**
//
// The variable is used to encrypt values provided to the `Encrypted` attribute.
var EncryptSecretKey secretKey = func() secretKey {
key := make([]byte, 32)
rand.Read(key)
cipher, err := aes.NewCipher(key)
if err != nil {
panic(err)
}
nonce := make([]byte, 12)
if _, err := io.ReadFull(rand.Reader, nonce); err != nil {
panic(err)
}
return secretKey{
Key: key,
Cipher: cipher,
Nonce: nonce,
}
}()
// Build an attribute with a value formatted as json
func JsonAttr(key string, jsonEl map[string]interface{}) attribute.KeyValue {
jsonStr, _ := json.Marshal(jsonEl)
return attribute.KeyValue{Key: attribute.Key(key), Value: attribute.StringValue(string(jsonStr))}
}
// Create an arbitrary attribute with value marshalled to json.
// In case of marshalling error, it is returned in place of value.
func Json[M json.Marshaler](key string, val M) attribute.KeyValue {
data, err := json.Marshal(val)
if err != nil {
return attribute.KeyValue{Key: attribute.Key(key), Value: attribute.StringValue(err.Error())}
} else {
return attribute.KeyValue{Key: attribute.Key(key), Value: attribute.StringValue(string(data))}
}
}
// Create an arbitrary attribute with a `string` value.
func String(key string, val string) attribute.KeyValue {
return attribute.KeyValue{Key: attribute.Key(key), Value: attribute.StringValue(val)}
}
// Create an arbitrary attribute with a `[]string` value.
func StringSlice(key string, val []string) attribute.KeyValue {
return attribute.KeyValue{Key: attribute.Key(key), Value: attribute.StringSliceValue(val)}
}
// Create an arbitrary attribute with an `int` value.
func Int(key string, val int) attribute.KeyValue {
return attribute.KeyValue{Key: attribute.Key(key), Value: attribute.IntValue(val)}
}
// Create an arbitrary attribute with an `int64` value.
func Int64(key string, val int64) attribute.KeyValue {
return attribute.KeyValue{Key: attribute.Key(key), Value: attribute.Int64Value(val)}
}
// Cast value to an `int` to create a new attribute.
func Uint(key string, val uint) attribute.KeyValue {
return attribute.KeyValue{Key: attribute.Key(key), Value: attribute.IntValue(int(val))}
}
// Cast value to an `int` to create a new attribute.
func Uint8(key string, val uint8) attribute.KeyValue {
return attribute.KeyValue{Key: attribute.Key(key), Value: attribute.IntValue(int(val))}
}
// Create an arbitrary attribute using an `uuid.UUID` from `github.com/gofrs/uuid` as value.
func Uuid(key string, val uuid.UUID) attribute.KeyValue {
return attribute.KeyValue{Key: attribute.Key(key), Value: attribute.StringValue(val.String())}
}
// Create an arbitrary attribute using standard library's `time.Time` as value. It will be formatted using RFC3339.
func Time(key string, val time.Time) attribute.KeyValue {
return attribute.KeyValue{Key: attribute.Key(key), Value: attribute.StringValue(val.Format(time.RFC3339))}
}
// Create an arbitrary attribute with bytes encoded to base64 (RFC 4648) as value.
func BytesB64(key string, val []byte) attribute.KeyValue {
res := base64.StdEncoding.EncodeToString(val)
return attribute.KeyValue{Key: attribute.Key(key), Value: attribute.StringValue(res)}
}
// Create an arbitrary attribute with bytes encoded to hexadecimal format as value.
func BytesHex(key string, val []byte) attribute.KeyValue {
res := hex.EncodeToString(val)
return attribute.KeyValue{Key: attribute.Key(key), Value: attribute.StringValue(res)}
}
// Create an arbitrary attribute with value encrypted using `secretKey` which should be set on
// global variable `EncryptSecretKey` using `NewSecretKey`. The result will be encoded using
// base64.
//
// This approach is an alternative to logs tokenization. It is using AES symmetric encryption
// that is suspectible to brute force attacks. It is a computionally expensive attribute to
// generate.
//
// In most cases, for very sensitive data it would be a better approach to use masking instead.
// Encrypting the fields of the logs/traces can provide an extra protection while they are being
// transported to a log collector and when the collector does not encrypt logs at rest (but most
// should implement this feature). This will mostly protect the logs from developers working
// with them provided that they do not have access to the key. The key should be set from an
// environment variable defined on application deployment. Alternatively it could be set from
// a secure vault, a software for storing private keys.
func Encrypted(key string, val string) attribute.KeyValue {
aesGcm, err := cipher.NewGCM(EncryptSecretKey.Cipher)
if err != nil {
return attribute.KeyValue{Key: attribute.Key(key), Value: attribute.StringValue(err.Error())}
}
resBytes := aesGcm.Seal(nil, EncryptSecretKey.Nonce, []byte(val), nil)
res := base64.StdEncoding.EncodeToString(resBytes)
return attribute.KeyValue{Key: attribute.Key(key), Value: attribute.StringValue(res)}
}
// Creates an arbitrary attribute with masked value. It will leave only last 4 (or less) characters
// unmasked by 'X' characters.
//
// It is not a good idea to use it for logging passwords as it preserves the lenght of the input.
//
// Masking is a good idea for very sensitive data like official identity numbers, or addresses.
// Storing such data in logs is usually too much of a risk even when it is encrypted.
// However, for the purpose of debugging it might be convenient to be able to distinguish one record
// from another.
func Masked(key string, val string) attribute.KeyValue {
lenght := len(val)
var unmasked int
if lenght <= 4 {
unmasked = 1
} else {
if lenght <= 8 {
unmasked = 2
} else {
if lenght <= 12 {
unmasked = 3
} else {
unmasked = 4
}
}
}
masked := lenght - unmasked
resBytes := make([]byte, lenght)
i := 0
for ; i < masked; i++ {
resBytes[i] = byte('X')
}
for ; i < lenght; i++ {
resBytes[i] = byte(val[i])
}
return attribute.KeyValue{Key: attribute.Key(key), Value: attribute.StringValue(string(resBytes))}
}
// An attribute informing about the severity or importance of an event using our own standard of log levels that