feat(security): add agent output redaction and private agent assignment enforcement
- Add redact package to detect and mask secrets (AWS keys, private keys, API tokens, bearer tokens, credentials, home paths) in agent output before posting as comments in TaskService - Enforce agent visibility on issue assignment: private agents can only be assigned by their owner or workspace admins - Add visibility picker (workspace/private) to CreateAgentDialog, default to private - Grey out unassignable private agents in the assignee picker with lock icon indicator
This commit is contained in:
parent
9c3ff52363
commit
0491350f1b
9 changed files with 351 additions and 24 deletions
|
|
@ -25,10 +25,13 @@ import {
|
|||
MoreHorizontal,
|
||||
Play,
|
||||
ChevronDown,
|
||||
Globe,
|
||||
Lock,
|
||||
} from "lucide-react";
|
||||
import type {
|
||||
Agent,
|
||||
AgentStatus,
|
||||
AgentVisibility,
|
||||
AgentTool,
|
||||
AgentTrigger,
|
||||
AgentTriggerType,
|
||||
|
|
@ -126,6 +129,7 @@ function CreateAgentDialog({
|
|||
const [name, setName] = useState("");
|
||||
const [description, setDescription] = useState("");
|
||||
const [selectedRuntimeId, setSelectedRuntimeId] = useState(runtimes[0]?.id ?? "");
|
||||
const [visibility, setVisibility] = useState<AgentVisibility>("private");
|
||||
const [creating, setCreating] = useState(false);
|
||||
const [runtimeOpen, setRuntimeOpen] = useState(false);
|
||||
|
||||
|
|
@ -145,6 +149,7 @@ function CreateAgentDialog({
|
|||
name: name.trim(),
|
||||
description: description.trim(),
|
||||
runtime_id: selectedRuntime.id,
|
||||
visibility,
|
||||
triggers: [{ id: generateId(), type: "on_assign", enabled: true, config: {} }],
|
||||
});
|
||||
onClose();
|
||||
|
|
@ -189,6 +194,42 @@ function CreateAgentDialog({
|
|||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Label className="text-xs text-muted-foreground">Visibility</Label>
|
||||
<div className="mt-1.5 flex gap-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setVisibility("workspace")}
|
||||
className={`flex flex-1 items-center gap-2 rounded-lg border px-3 py-2.5 text-sm transition-colors ${
|
||||
visibility === "workspace"
|
||||
? "border-primary bg-primary/5"
|
||||
: "border-border hover:bg-muted"
|
||||
}`}
|
||||
>
|
||||
<Globe className="h-4 w-4 shrink-0 text-muted-foreground" />
|
||||
<div className="text-left">
|
||||
<div className="font-medium">Workspace</div>
|
||||
<div className="text-xs text-muted-foreground">All members can assign</div>
|
||||
</div>
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setVisibility("private")}
|
||||
className={`flex flex-1 items-center gap-2 rounded-lg border px-3 py-2.5 text-sm transition-colors ${
|
||||
visibility === "private"
|
||||
? "border-primary bg-primary/5"
|
||||
: "border-border hover:bg-muted"
|
||||
}`}
|
||||
>
|
||||
<Lock className="h-4 w-4 shrink-0 text-muted-foreground" />
|
||||
<div className="text-left">
|
||||
<div className="font-medium">Private</div>
|
||||
<div className="text-xs text-muted-foreground">Only you can assign</div>
|
||||
</div>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Label className="text-xs text-muted-foreground">Runtime</Label>
|
||||
<Popover open={runtimeOpen} onOpenChange={setRuntimeOpen}>
|
||||
|
|
|
|||
|
|
@ -1,8 +1,9 @@
|
|||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { Bot, UserMinus } from "lucide-react";
|
||||
import type { IssueAssigneeType, UpdateIssueRequest } from "@/shared/types";
|
||||
import { Bot, Lock, UserMinus } from "lucide-react";
|
||||
import type { Agent, IssueAssigneeType, UpdateIssueRequest } from "@/shared/types";
|
||||
import { useAuthStore } from "@/features/auth";
|
||||
import { useWorkspaceStore, useActorName } from "@/features/workspace";
|
||||
import {
|
||||
PropertyPicker,
|
||||
|
|
@ -11,6 +12,13 @@ import {
|
|||
PickerEmpty,
|
||||
} from "./property-picker";
|
||||
|
||||
function canAssignAgent(agent: Agent, userId: string | undefined, memberRole: string | undefined): boolean {
|
||||
if (agent.visibility !== "private") return true;
|
||||
if (agent.owner_id === userId) return true;
|
||||
if (memberRole === "owner" || memberRole === "admin") return true;
|
||||
return false;
|
||||
}
|
||||
|
||||
export function AssigneePicker({
|
||||
assigneeType,
|
||||
assigneeId,
|
||||
|
|
@ -24,10 +32,14 @@ export function AssigneePicker({
|
|||
}) {
|
||||
const [open, setOpen] = useState(false);
|
||||
const [filter, setFilter] = useState("");
|
||||
const user = useAuthStore((s) => s.user);
|
||||
const members = useWorkspaceStore((s) => s.members);
|
||||
const agents = useWorkspaceStore((s) => s.agents);
|
||||
const { getActorName, getActorInitials } = useActorName();
|
||||
|
||||
const currentMember = members.find((m) => m.user_id === user?.id);
|
||||
const memberRole = currentMember?.role;
|
||||
|
||||
const query = filter.toLowerCase();
|
||||
const filteredMembers = members.filter((m) =>
|
||||
m.name.toLowerCase().includes(query),
|
||||
|
|
@ -117,24 +129,32 @@ export function AssigneePicker({
|
|||
{/* Agents */}
|
||||
{filteredAgents.length > 0 && (
|
||||
<PickerSection label="Agents">
|
||||
{filteredAgents.map((a) => (
|
||||
<PickerItem
|
||||
key={a.id}
|
||||
selected={isSelected("agent", a.id)}
|
||||
onClick={() => {
|
||||
onUpdate({
|
||||
assignee_type: "agent",
|
||||
assignee_id: a.id,
|
||||
});
|
||||
setOpen(false);
|
||||
}}
|
||||
>
|
||||
<div className="inline-flex size-4.5 shrink-0 items-center justify-center rounded-full bg-info/10 text-info">
|
||||
<Bot className="size-2.5" />
|
||||
</div>
|
||||
<span>{a.name}</span>
|
||||
</PickerItem>
|
||||
))}
|
||||
{filteredAgents.map((a) => {
|
||||
const allowed = canAssignAgent(a, user?.id, memberRole);
|
||||
return (
|
||||
<PickerItem
|
||||
key={a.id}
|
||||
selected={isSelected("agent", a.id)}
|
||||
disabled={!allowed}
|
||||
onClick={() => {
|
||||
if (!allowed) return;
|
||||
onUpdate({
|
||||
assignee_type: "agent",
|
||||
assignee_id: a.id,
|
||||
});
|
||||
setOpen(false);
|
||||
}}
|
||||
>
|
||||
<div className={`inline-flex size-4.5 shrink-0 items-center justify-center rounded-full ${allowed ? "bg-info/10 text-info" : "bg-muted text-muted-foreground"}`}>
|
||||
<Bot className="size-2.5" />
|
||||
</div>
|
||||
<span className={allowed ? "" : "text-muted-foreground"}>{a.name}</span>
|
||||
{a.visibility === "private" && (
|
||||
<Lock className="ml-auto h-3 w-3 text-muted-foreground" />
|
||||
)}
|
||||
</PickerItem>
|
||||
);
|
||||
})}
|
||||
</PickerSection>
|
||||
)}
|
||||
|
||||
|
|
|
|||
|
|
@ -79,11 +79,13 @@ export function PropertyPicker({
|
|||
|
||||
export function PickerItem({
|
||||
selected,
|
||||
disabled,
|
||||
onClick,
|
||||
hoverClassName,
|
||||
children,
|
||||
}: {
|
||||
selected: boolean;
|
||||
disabled?: boolean;
|
||||
onClick: () => void;
|
||||
hoverClassName?: string;
|
||||
children: React.ReactNode;
|
||||
|
|
@ -91,8 +93,9 @@ export function PickerItem({
|
|||
return (
|
||||
<button
|
||||
type="button"
|
||||
disabled={disabled}
|
||||
onClick={onClick}
|
||||
className={`flex w-full items-center gap-2 rounded-md px-2 py-1.5 text-sm ${hoverClassName ?? "hover:bg-accent"} transition-colors`}
|
||||
className={`flex w-full items-center gap-2 rounded-md px-2 py-1.5 text-sm ${disabled ? "opacity-50 cursor-not-allowed" : hoverClassName ?? "hover:bg-accent"} transition-colors`}
|
||||
>
|
||||
<span className="flex flex-1 items-center gap-2">{children}</span>
|
||||
{selected && <Check className="h-3.5 w-3.5 text-muted-foreground" />}
|
||||
|
|
|
|||
|
|
@ -246,7 +246,7 @@ func (h *Handler) CreateAgent(w http.ResponseWriter, r *http.Request) {
|
|||
return
|
||||
}
|
||||
if req.Visibility == "" {
|
||||
req.Visibility = "workspace"
|
||||
req.Visibility = "private"
|
||||
}
|
||||
if req.MaxConcurrentTasks == 0 {
|
||||
req.MaxConcurrentTasks = 6
|
||||
|
|
|
|||
|
|
@ -182,6 +182,14 @@ func (h *Handler) CreateIssue(w http.ResponseWriter, r *http.Request) {
|
|||
assigneeID = parseUUID(*req.AssigneeID)
|
||||
}
|
||||
|
||||
// Enforce agent visibility: private agents can only be assigned by owner/admin.
|
||||
if req.AssigneeType != nil && *req.AssigneeType == "agent" && req.AssigneeID != nil {
|
||||
if ok, msg := h.canAssignAgent(r.Context(), r, *req.AssigneeID, workspaceID); !ok {
|
||||
writeError(w, http.StatusForbidden, msg)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
var parentIssueID pgtype.UUID
|
||||
if req.ParentIssueID != nil {
|
||||
parentIssueID = parseUUID(*req.ParentIssueID)
|
||||
|
|
@ -347,6 +355,14 @@ func (h *Handler) UpdateIssue(w http.ResponseWriter, r *http.Request) {
|
|||
}
|
||||
}
|
||||
|
||||
// Enforce agent visibility: private agents can only be assigned by owner/admin.
|
||||
if req.AssigneeType != nil && *req.AssigneeType == "agent" && req.AssigneeID != nil {
|
||||
if ok, msg := h.canAssignAgent(r.Context(), r, *req.AssigneeID, workspaceID); !ok {
|
||||
writeError(w, http.StatusForbidden, msg)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
issue, err := h.Queries.UpdateIssue(r.Context(), params)
|
||||
if err != nil {
|
||||
slog.Warn("update issue failed", append(logger.RequestAttrs(r), "error", err, "issue_id", id, "workspace_id", workspaceID)...)
|
||||
|
|
@ -403,6 +419,34 @@ func (h *Handler) UpdateIssue(w http.ResponseWriter, r *http.Request) {
|
|||
writeJSON(w, http.StatusOK, resp)
|
||||
}
|
||||
|
||||
// canAssignAgent checks whether the requesting user is allowed to assign issues
|
||||
// to the given agent. Private agents can only be assigned by their owner or
|
||||
// workspace admins/owners.
|
||||
func (h *Handler) canAssignAgent(ctx context.Context, r *http.Request, agentID, workspaceID string) (bool, string) {
|
||||
agent, err := h.Queries.GetAgentInWorkspace(ctx, db.GetAgentInWorkspaceParams{
|
||||
ID: parseUUID(agentID),
|
||||
WorkspaceID: parseUUID(workspaceID),
|
||||
})
|
||||
if err != nil {
|
||||
return false, "agent not found"
|
||||
}
|
||||
if agent.Visibility != "private" {
|
||||
return true, ""
|
||||
}
|
||||
userID := requestUserID(r)
|
||||
if uuidToString(agent.OwnerID) == userID {
|
||||
return true, ""
|
||||
}
|
||||
member, err := h.getWorkspaceMember(ctx, userID, workspaceID)
|
||||
if err != nil {
|
||||
return false, "cannot assign to private agent"
|
||||
}
|
||||
if roleAllowed(member.Role, "owner", "admin") {
|
||||
return true, ""
|
||||
}
|
||||
return false, "cannot assign to private agent"
|
||||
}
|
||||
|
||||
func (h *Handler) shouldEnqueueAgentTask(ctx context.Context, issue db.Issue) bool {
|
||||
if issue.Status != "todo" {
|
||||
return false
|
||||
|
|
@ -580,6 +624,13 @@ func (h *Handler) BatchUpdateIssues(w http.ResponseWriter, r *http.Request) {
|
|||
}
|
||||
}
|
||||
|
||||
// Enforce agent visibility for batch assignment.
|
||||
if req.Updates.AssigneeType != nil && *req.Updates.AssigneeType == "agent" && req.Updates.AssigneeID != nil {
|
||||
if ok, _ := h.canAssignAgent(r.Context(), r, *req.Updates.AssigneeID, workspaceID); !ok {
|
||||
continue
|
||||
}
|
||||
}
|
||||
|
||||
issue, err := h.Queries.UpdateIssue(r.Context(), params)
|
||||
if err != nil {
|
||||
slog.Warn("batch update issue failed", "issue_id", issueID, "error", err)
|
||||
|
|
|
|||
|
|
@ -16,6 +16,7 @@ import (
|
|||
"github.com/multica-ai/multica/server/internal/util"
|
||||
db "github.com/multica-ai/multica/server/pkg/db/generated"
|
||||
"github.com/multica-ai/multica/server/pkg/protocol"
|
||||
"github.com/multica-ai/multica/server/pkg/redact"
|
||||
)
|
||||
|
||||
type TaskService struct {
|
||||
|
|
@ -187,7 +188,7 @@ func (s *TaskService) CompleteTask(ctx context.Context, taskID pgtype.UUID, resu
|
|||
var payload protocol.TaskCompletedPayload
|
||||
if err := json.Unmarshal(result, &payload); err == nil {
|
||||
if payload.Output != "" {
|
||||
s.createAgentComment(ctx, task.IssueID, task.AgentID, payload.Output, "comment")
|
||||
s.createAgentComment(ctx, task.IssueID, task.AgentID, redact.Text(payload.Output), "comment")
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -227,7 +228,7 @@ func (s *TaskService) FailTask(ctx context.Context, taskID pgtype.UUID, errMsg s
|
|||
slog.Warn("task failed", "task_id", util.UUIDToString(task.ID), "issue_id", util.UUIDToString(task.IssueID), "error", errMsg)
|
||||
|
||||
if errMsg != "" {
|
||||
s.createAgentComment(ctx, task.IssueID, task.AgentID, errMsg, "system")
|
||||
s.createAgentComment(ctx, task.IssueID, task.AgentID, redact.Text(errMsg), "system")
|
||||
}
|
||||
// Reconcile agent status
|
||||
s.ReconcileAgentStatus(ctx, task.AgentID)
|
||||
|
|
|
|||
|
|
@ -218,6 +218,7 @@ func TestBuildEnvNilExtras(t *testing.T) {
|
|||
}
|
||||
}
|
||||
|
||||
|
||||
func mustMarshal(t *testing.T, v any) json.RawMessage {
|
||||
t.Helper()
|
||||
data, err := json.Marshal(v)
|
||||
|
|
|
|||
71
server/pkg/redact/redact.go
Normal file
71
server/pkg/redact/redact.go
Normal file
|
|
@ -0,0 +1,71 @@
|
|||
// Package redact provides functions for detecting and masking secrets
|
||||
// in agent output before it reaches the database or WebSocket broadcast.
|
||||
package redact
|
||||
|
||||
import (
|
||||
"os"
|
||||
"os/user"
|
||||
"regexp"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// secretPattern pairs a compiled regex with its replacement text.
|
||||
type secretPattern struct {
|
||||
re *regexp.Regexp
|
||||
replacement string
|
||||
}
|
||||
|
||||
// Patterns are checked in order; first match wins per position.
|
||||
var patterns = []secretPattern{
|
||||
// AWS access key IDs (always start with AKIA)
|
||||
{regexp.MustCompile(`\bAKIA[0-9A-Z]{16}\b`), "[REDACTED AWS KEY]"},
|
||||
|
||||
// AWS secret access keys (40 char base64-ish, preceded by a common separator)
|
||||
{regexp.MustCompile(`(?i)(?:aws_secret_access_key|secret_?access_?key)\s*[=:]\s*[A-Za-z0-9/+=]{40}`), "[REDACTED AWS SECRET]"},
|
||||
|
||||
// PEM private keys (multi-line)
|
||||
{regexp.MustCompile(`(?s)-----BEGIN[A-Z\s]*PRIVATE KEY-----.*?-----END[A-Z\s]*PRIVATE KEY-----`), "[REDACTED PRIVATE KEY]"},
|
||||
|
||||
// GitHub tokens (classic PAT, fine-grained, OAuth, etc.)
|
||||
{regexp.MustCompile(`\b(ghp|gho|ghu|ghs|ghr)_[A-Za-z0-9_]{36,255}\b`), "[REDACTED GITHUB TOKEN]"},
|
||||
|
||||
// OpenAI / Anthropic API keys
|
||||
{regexp.MustCompile(`\bsk-[A-Za-z0-9_-]{20,}\b`), "[REDACTED API KEY]"},
|
||||
|
||||
// Slack tokens
|
||||
{regexp.MustCompile(`\bxox[bporas]-[A-Za-z0-9\-]{10,}\b`), "[REDACTED SLACK TOKEN]"},
|
||||
|
||||
// Generic "Bearer <token>" in output
|
||||
{regexp.MustCompile(`(?i)\bBearer\s+[A-Za-z0-9\-._~+/]+=*\b`), "Bearer [REDACTED]"},
|
||||
|
||||
// Generic key=value patterns for common secret env var names
|
||||
{regexp.MustCompile(`(?i)(?:API_KEY|API_SECRET|SECRET_KEY|ACCESS_TOKEN|AUTH_TOKEN|PRIVATE_KEY|DATABASE_URL|DB_PASSWORD|REDIS_URL)\s*[=:]\s*\S+`), "[REDACTED CREDENTIAL]"},
|
||||
}
|
||||
|
||||
// homeDir is resolved once at init for path redaction.
|
||||
var homeDir string
|
||||
var username string
|
||||
|
||||
func init() {
|
||||
homeDir, _ = os.UserHomeDir()
|
||||
if u, err := user.Current(); err == nil {
|
||||
username = u.Username
|
||||
}
|
||||
}
|
||||
|
||||
// Text scans the input string for known secret patterns and replaces
|
||||
// matches with safe placeholders. It also masks the local user's home
|
||||
// directory path to prevent leaking the username.
|
||||
func Text(s string) string {
|
||||
for _, p := range patterns {
|
||||
s = p.re.ReplaceAllString(s, p.replacement)
|
||||
}
|
||||
|
||||
// Redact home directory paths (e.g. /Users/john/ → /Users/****/).
|
||||
if homeDir != "" && username != "" {
|
||||
masked := strings.Replace(homeDir, username, "****", 1)
|
||||
s = strings.ReplaceAll(s, homeDir, masked)
|
||||
}
|
||||
|
||||
return s
|
||||
}
|
||||
139
server/pkg/redact/redact_test.go
Normal file
139
server/pkg/redact/redact_test.go
Normal file
|
|
@ -0,0 +1,139 @@
|
|||
package redact
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestRedactAWSAccessKey(t *testing.T) {
|
||||
t.Parallel()
|
||||
input := "Found key AKIAIOSFODNN7EXAMPLE in config"
|
||||
got := Text(input)
|
||||
if strings.Contains(got, "AKIAIOSFODNN7EXAMPLE") {
|
||||
t.Fatalf("AWS key not redacted: %s", got)
|
||||
}
|
||||
if !strings.Contains(got, "[REDACTED AWS KEY]") {
|
||||
t.Fatalf("expected [REDACTED AWS KEY] placeholder, got: %s", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRedactAWSSecretKey(t *testing.T) {
|
||||
t.Parallel()
|
||||
input := "aws_secret_access_key = wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY"
|
||||
got := Text(input)
|
||||
if strings.Contains(got, "wJalrXUtnFEMI") {
|
||||
t.Fatalf("AWS secret not redacted: %s", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRedactPrivateKey(t *testing.T) {
|
||||
t.Parallel()
|
||||
input := "Here is the key:\n-----BEGIN RSA PRIVATE KEY-----\nMIIEow...\n-----END RSA PRIVATE KEY-----\nDone."
|
||||
got := Text(input)
|
||||
if strings.Contains(got, "MIIEow") {
|
||||
t.Fatalf("private key content not redacted: %s", got)
|
||||
}
|
||||
if !strings.Contains(got, "[REDACTED PRIVATE KEY]") {
|
||||
t.Fatalf("expected [REDACTED PRIVATE KEY] placeholder, got: %s", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRedactGitHubToken(t *testing.T) {
|
||||
t.Parallel()
|
||||
input := "export GITHUB_TOKEN=ghp_ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmn"
|
||||
got := Text(input)
|
||||
if strings.Contains(got, "ghp_") {
|
||||
t.Fatalf("GitHub token not redacted: %s", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRedactOpenAIKey(t *testing.T) {
|
||||
t.Parallel()
|
||||
input := "OPENAI_API_KEY=sk-proj-abc123def456ghi789jkl012mno345"
|
||||
got := Text(input)
|
||||
if strings.Contains(got, "sk-proj-abc123") {
|
||||
t.Fatalf("OpenAI key not redacted: %s", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRedactSlackToken(t *testing.T) {
|
||||
t.Parallel()
|
||||
input := "token: xoxb-123456789012-1234567890123-AbCdEfGhIjKl"
|
||||
got := Text(input)
|
||||
if strings.Contains(got, "xoxb-") {
|
||||
t.Fatalf("Slack token not redacted: %s", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRedactBearerToken(t *testing.T) {
|
||||
t.Parallel()
|
||||
input := "Authorization: Bearer eyJhbGciOiJIUzI1NiJ9.eyJzdWIiOiIxMjM0NTY3ODkwIn0.abc123"
|
||||
got := Text(input)
|
||||
if strings.Contains(got, "eyJhbGci") {
|
||||
t.Fatalf("Bearer token not redacted: %s", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRedactGenericCredentials(t *testing.T) {
|
||||
t.Parallel()
|
||||
cases := []struct {
|
||||
name string
|
||||
input string
|
||||
}{
|
||||
{"API_KEY", "API_KEY=mysupersecretkey123"},
|
||||
{"DATABASE_URL", "DATABASE_URL=postgres://user:pass@host/db"},
|
||||
{"DB_PASSWORD", "DB_PASSWORD: hunter2"},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
got := Text(tc.input)
|
||||
if !strings.Contains(got, "[REDACTED CREDENTIAL]") {
|
||||
t.Fatalf("expected credential redaction for %s, got: %s", tc.name, got)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestRedactHomeDirectory(t *testing.T) {
|
||||
t.Parallel()
|
||||
if homeDir == "" || username == "" {
|
||||
t.Skip("cannot determine home dir or username")
|
||||
}
|
||||
input := "Reading file at " + homeDir + "/Documents/secret.txt"
|
||||
got := Text(input)
|
||||
if strings.Contains(got, username) {
|
||||
t.Fatalf("home directory username not redacted: %s", got)
|
||||
}
|
||||
if !strings.Contains(got, "****") {
|
||||
t.Fatalf("expected **** in path, got: %s", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestNoFalsePositivesOnNormalText(t *testing.T) {
|
||||
t.Parallel()
|
||||
inputs := []string{
|
||||
"This is a normal commit message about fixing a bug",
|
||||
"The function returns skip-navigation as the class name",
|
||||
"Created PR #42 for the authentication feature",
|
||||
"Running tests in /tmp/test-workspace/project",
|
||||
"The API endpoint /api/issues/123 was updated",
|
||||
}
|
||||
for _, input := range inputs {
|
||||
got := Text(input)
|
||||
if got != input {
|
||||
t.Fatalf("false positive redaction:\n input: %s\n output: %s", input, got)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestRedactMultipleSecrets(t *testing.T) {
|
||||
t.Parallel()
|
||||
input := "Keys: AKIAIOSFODNN7EXAMPLE and ghp_ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmn"
|
||||
got := Text(input)
|
||||
if strings.Contains(got, "AKIAIOSFODNN7EXAMPLE") {
|
||||
t.Fatal("AWS key not redacted in multi-secret text")
|
||||
}
|
||||
if strings.Contains(got, "ghp_") {
|
||||
t.Fatal("GitHub token not redacted in multi-secret text")
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue