feat: Hide secrets on api and channels
This commit is contained in:
@@ -64,6 +64,15 @@ Posts the alert as message content.
|
||||
|
||||
Port `465` uses implicit TLS; anything else uses STARTTLS.
|
||||
|
||||
### Credentials are never read back
|
||||
|
||||
The SMTP `password`, the Telegram `token` and the webhook, Slack and Discord
|
||||
`url`s come back from `GET /api/channels` as `••••••••` — a webhook URL is the
|
||||
authorisation to post to that channel, so it is treated as a credential like
|
||||
the rest. Writing that value back unchanged keeps the stored one, which is what
|
||||
lets you rename a channel without retyping its password. Anything else you send
|
||||
is written as given, so clearing the field clears the credential.
|
||||
|
||||
Alert emails look like the rest of the mail Vantage sends you.
|
||||
|
||||
## The message
|
||||
|
||||
@@ -34,7 +34,14 @@ func listChannels(c *gin.Context) {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, channels)
|
||||
// Redacted here rather than in the service: the dispatchers read the same
|
||||
// documents and need the real credentials, so the masking belongs to the
|
||||
// boundary that hands them to a client.
|
||||
out := make([]models.NotificationChannel, 0, len(channels))
|
||||
for _, ch := range channels {
|
||||
out = append(out, ch.Redacted())
|
||||
}
|
||||
c.JSON(http.StatusOK, out)
|
||||
}
|
||||
|
||||
// createChannel godoc
|
||||
@@ -69,7 +76,7 @@ func createChannel(c *gin.Context) {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusCreated, created)
|
||||
c.JSON(http.StatusCreated, created.Redacted())
|
||||
}
|
||||
|
||||
// updateChannel godoc
|
||||
|
||||
@@ -14,6 +14,28 @@ const (
|
||||
ChannelTelegram = "telegram"
|
||||
)
|
||||
|
||||
// RedactedSecret is what a channel's secret config values read as over the API.
|
||||
// It is a sentinel and not merely a mask: a client may write it straight back,
|
||||
// and the value it stood for is preserved. See NotificationChannel.Redacted.
|
||||
const RedactedSecret = "••••••••"
|
||||
|
||||
// channelSecretKeys names, per channel type, the config entries that are
|
||||
// credentials rather than settings. A Slack or Discord webhook URL is on this
|
||||
// list because possession of the URL *is* the authorisation to post to that
|
||||
// channel — there is nothing else to steal.
|
||||
var channelSecretKeys = map[string][]string{
|
||||
ChannelWebhook: {"url"},
|
||||
ChannelSlack: {"url"},
|
||||
ChannelDiscord: {"url"},
|
||||
ChannelTelegram: {"token"},
|
||||
ChannelSMTP: {"password"},
|
||||
}
|
||||
|
||||
// ChannelSecretKeys reports which config keys of a channel type are secret.
|
||||
func ChannelSecretKeys(channelType string) []string {
|
||||
return channelSecretKeys[channelType]
|
||||
}
|
||||
|
||||
type NotificationChannel struct {
|
||||
ID bson.ObjectID `bson:"_id,omitempty" json:"_id,omitempty"`
|
||||
InstanceID string `bson:"instance_id" json:"instance_id"`
|
||||
@@ -24,3 +46,25 @@ type NotificationChannel struct {
|
||||
Enabled bool `bson:"enabled" json:"enabled"`
|
||||
CreatedAt time.Time `bson:"created_at" json:"created_at"`
|
||||
}
|
||||
|
||||
// Redacted returns a copy with every secret config value replaced by
|
||||
// RedactedSecret, for handing to a client. Nothing internal uses it: the
|
||||
// dispatchers read the stored document through GetChannel/GetChannels, so the
|
||||
// redaction is a property of the API boundary and cannot break delivery.
|
||||
//
|
||||
// A set-but-secret key keeps its key, so a caller can still tell configured
|
||||
// from absent; an empty value is left empty rather than being dressed up as a
|
||||
// credential that is not there.
|
||||
func (c NotificationChannel) Redacted() NotificationChannel {
|
||||
out := c
|
||||
out.Config = make(map[string]string, len(c.Config))
|
||||
for k, v := range c.Config {
|
||||
out.Config[k] = v
|
||||
}
|
||||
for _, k := range ChannelSecretKeys(c.Type) {
|
||||
if out.Config[k] != "" {
|
||||
out.Config[k] = RedactedSecret
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
@@ -90,12 +90,57 @@ func CreateChannel(instanceID string, ch *models.NotificationChannel) (*models.N
|
||||
}
|
||||
|
||||
func UpdateChannel(instanceID, channelID string, upd bson.M) error {
|
||||
if cfg, ok := upd["config"].(map[string]string); ok {
|
||||
merged, err := mergeChannelSecrets(instanceID, channelID, upd, cfg)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
upd["config"] = merged
|
||||
}
|
||||
ctx, cancel := monCtx()
|
||||
defer cancel()
|
||||
_, err := db.Col("notification_channels").UpdateOne(ctx, bson.M{"channel_id": channelID, "instance_id": instanceID}, bson.M{"$set": upd})
|
||||
return err
|
||||
}
|
||||
|
||||
// mergeChannelSecrets resolves models.RedactedSecret back to what it stood for.
|
||||
//
|
||||
// The API hands out a sentinel rather than the credential, and the UI's edit
|
||||
// form round-trips whatever it was given, so an ordinary "rename this channel"
|
||||
// save arrives carrying the sentinel in place of the password. Writing it
|
||||
// through would replace the credential with eight bullet characters and break
|
||||
// delivery on the next alert. A value that is not the sentinel is written
|
||||
// verbatim — including the empty string, which is how a credential is cleared.
|
||||
func mergeChannelSecrets(instanceID, channelID string, upd bson.M, cfg map[string]string) (map[string]string, error) {
|
||||
stored, err := GetChannel(instanceID, channelID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if stored == nil {
|
||||
return cfg, nil
|
||||
}
|
||||
// The secret keys are the ones of the type being saved, which the same
|
||||
// request may be changing.
|
||||
channelType := stored.Type
|
||||
if t, ok := upd["type"].(string); ok && t != "" {
|
||||
channelType = t
|
||||
}
|
||||
out := make(map[string]string, len(cfg))
|
||||
for k, v := range cfg {
|
||||
out[k] = v
|
||||
}
|
||||
for _, k := range models.ChannelSecretKeys(channelType) {
|
||||
if out[k] == models.RedactedSecret {
|
||||
if prev, ok := stored.Config[k]; ok {
|
||||
out[k] = prev
|
||||
} else {
|
||||
delete(out, k)
|
||||
}
|
||||
}
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func DeleteChannel(instanceID, channelID string) error {
|
||||
ctx, cancel := monCtx()
|
||||
defer cancel()
|
||||
|
||||
@@ -3,7 +3,14 @@
|
||||
import { useState } from "react";
|
||||
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
|
||||
import Link from "next/link";
|
||||
import { api, ChannelInput, ChannelType, NotificationChannel } from "@/lib/api";
|
||||
import {
|
||||
api,
|
||||
CHANNEL_SECRET_FIELDS,
|
||||
ChannelInput,
|
||||
ChannelType,
|
||||
NotificationChannel,
|
||||
REDACTED_SECRET,
|
||||
} from "@/lib/api";
|
||||
import { Badge, Button, Card, ConfirmDialog, friendlyMessage, useToast } from "@/components/ui";
|
||||
import { VulnAlertRulesCard } from "@/components/vulnerabilities/VulnAlertRulesCard";
|
||||
|
||||
@@ -67,17 +74,28 @@ function ChannelForm({ initial, onDone }: { initial?: NotificationChannel; onDon
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
{CONFIG_FIELDS[type].map((field) => (
|
||||
<div key={field}>
|
||||
<label className={labelClass}>{field}</label>
|
||||
<input
|
||||
className={inputClass}
|
||||
type={field === "password" ? "password" : "text"}
|
||||
value={config[field] ?? ""}
|
||||
onChange={(e) => setConfig({ ...config, [field]: e.target.value })}
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
{CONFIG_FIELDS[type].map((field) => {
|
||||
// A secret comes back from the API as the sentinel, never as itself.
|
||||
// The field renders empty rather than showing bullets in a URL box, and
|
||||
// the sentinel is left sitting in state so an untouched save preserves
|
||||
// the credential. Typing replaces it; clearing the field back to empty
|
||||
// is how a credential is removed.
|
||||
const secret = CHANNEL_SECRET_FIELDS[type].includes(field);
|
||||
const value = config[field] ?? "";
|
||||
const unchanged = secret && value === REDACTED_SECRET;
|
||||
return (
|
||||
<div key={field}>
|
||||
<label className={labelClass}>{field}</label>
|
||||
<input
|
||||
className={inputClass}
|
||||
type={field === "password" ? "password" : "text"}
|
||||
value={unchanged ? "" : value}
|
||||
placeholder={unchanged ? "unchanged — type to replace" : undefined}
|
||||
onChange={(e) => setConfig({ ...config, [field]: e.target.value })}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
{error && <p className="text-sm text-danger">{(error as Error).message}</p>}
|
||||
<div className="flex gap-3">
|
||||
<Button type="submit" variant="primary" loading={isPending}>
|
||||
|
||||
@@ -102,6 +102,25 @@ export interface Rollup {
|
||||
|
||||
export type ChannelType = "webhook" | "smtp" | "discord" | "slack" | "telegram";
|
||||
|
||||
/**
|
||||
* What a channel's secret config values read as over the API. Writing it back
|
||||
* unchanged preserves the stored credential; anything else, including "", is
|
||||
* written verbatim.
|
||||
*
|
||||
* Mirrors `models.RedactedSecret` and `models.channelSecretKeys` in
|
||||
* `server/internal/models/channel.go` — change both in the same commit, the
|
||||
* same hazard as the mirrored token blocks.
|
||||
*/
|
||||
export const REDACTED_SECRET = "••••••••";
|
||||
|
||||
export const CHANNEL_SECRET_FIELDS: Record<ChannelType, string[]> = {
|
||||
webhook: ["url"],
|
||||
slack: ["url"],
|
||||
discord: ["url"],
|
||||
telegram: ["token"],
|
||||
smtp: ["password"],
|
||||
};
|
||||
|
||||
export interface NotificationChannel {
|
||||
channel_id: string;
|
||||
name: string;
|
||||
|
||||
Reference in New Issue
Block a user