feat: Hide secrets on api and channels

This commit is contained in:
2026-08-14 12:23:36 +00:00
parent ac61015cc0
commit aa1c8e4aa1
6 changed files with 156 additions and 14 deletions
@@ -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
+9 -2
View File
@@ -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
+44
View File
@@ -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
}
+45
View File
@@ -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()
+30 -12
View File
@@ -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}>
+19
View File
@@ -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;