44package agent
55
66import (
7+ "bytes"
8+ "encoding/json"
79 "maps"
10+ "strconv"
11+ "strings"
812
913 "github.com/dagucloud/dagu/v2/internal/cmn/collections"
1014 "github.com/dagucloud/dagu/v2/internal/cmn/masking"
@@ -28,11 +32,39 @@ func (a *Agent) maskStatusSecrets(status *ir.DAGRunStatus) {
2832 status .Error = a .secretMasker .MaskString (status .Error )
2933}
3034
35+ // newStatusSecretMasker returns a masker for run status. Stored outputs are
36+ // JSON text, so each secret is also masked in the escaped forms JSON encoding
37+ // gives it.
3138func newStatusSecretMasker (secretEnvs []string ) * masking.Masker {
3239 if len (secretEnvs ) == 0 {
3340 return nil
3441 }
35- return masking .NewMasker (masking.SourcedEnvVars {Secrets : secretEnvs })
42+ secrets := make ([]string , 0 , len (secretEnvs ))
43+ for _ , env := range secretEnvs {
44+ secrets = append (secrets , env )
45+ name , value , ok := strings .Cut (env , "=" )
46+ if ! ok || value == "" {
47+ continue
48+ }
49+ for _ , escapeHTML := range []bool {true , false } {
50+ if escaped := jsonStringBody (value , escapeHTML ); escaped != value {
51+ secrets = append (secrets , name + "=" + escaped )
52+ }
53+ }
54+ }
55+ return masking .NewMasker (masking.SourcedEnvVars {Secrets : secrets })
56+ }
57+
58+ // jsonStringBody returns value encoded as a JSON string, without the quotes.
59+ func jsonStringBody (value string , escapeHTML bool ) string {
60+ var buf bytes.Buffer
61+ encoder := json .NewEncoder (& buf )
62+ encoder .SetEscapeHTML (escapeHTML )
63+ if err := encoder .Encode (value ); err != nil {
64+ return value
65+ }
66+ encoded := strings .TrimSuffix (buf .String (), "\n " )
67+ return encoded [1 : len (encoded )- 1 ]
3668}
3769
3870func maskNodeSecrets (masker * masking.Masker , node * ir.Node ) {
@@ -44,10 +76,89 @@ func maskNodeSecrets(masker *masking.Masker, node *ir.Node) {
4476 node .StatusDetails = maskNodeStatusDetails (masker , node .StatusDetails )
4577 node .OutputVariables = maskOutputVariables (masker , node .OutputVariables )
4678 node .OutputValue = maskStringPointer (masker , node .OutputValue )
47- node .OutputsValue = maskStringPointer (masker , node .OutputsValue )
79+ node .OutputsValue = maskOutputDocument (masker , node .OutputsValue )
80+ node .StepOutputsValue = maskStepOutputs (masker , node )
4881 node .AgentSession = maskAgentSession (masker , node .AgentSession )
4982}
5083
84+ // maskStepOutputs masks the outputs a step published. Human-task outputs are
85+ // kept: they are operator input, stored as entered next to HumanTaskInput, and
86+ // a resumed run reads them back from status.
87+ func maskStepOutputs (masker * masking.Masker , node * ir.Node ) * string {
88+ if node .Step .HumanTask != nil {
89+ return node .StepOutputsValue
90+ }
91+ return maskOutputDocument (masker , node .StepOutputsValue )
92+ }
93+
94+ // maskOutputDocument masks a stored JSON output document. Plain replacement
95+ // can split a JSON token, such as a secret matching a number; such a document
96+ // is masked value by value instead, so a run that reuses it can still read it.
97+ // It never keeps secret text that plain replacement masks.
98+ func maskOutputDocument (masker * masking.Masker , value * string ) * string {
99+ masked := maskStringPointer (masker , value )
100+ if masked == nil || * masked == * value || json .Valid ([]byte (* masked )) || ! json .Valid ([]byte (* value )) {
101+ return masked
102+ }
103+ decoder := json .NewDecoder (strings .NewReader (* value ))
104+ decoder .UseNumber ()
105+ var decoded any
106+ if err := decoder .Decode (& decoded ); err != nil {
107+ return masked
108+ }
109+ var buf bytes.Buffer
110+ encoder := json .NewEncoder (& buf )
111+ encoder .SetEscapeHTML (false )
112+ if err := encoder .Encode (maskJSONValue (masker , decoded )); err != nil {
113+ return masked
114+ }
115+ document := strings .TrimSuffix (buf .String (), "\n " )
116+ // A secret spanning JSON values is in no single decoded value, so
117+ // re-encoding writes it back out.
118+ if masker .MaskString (document ) != document {
119+ return masked
120+ }
121+ return & document
122+ }
123+
124+ // maskJSONValue masks secrets in decoded JSON. A number, boolean, or null
125+ // holding a secret becomes the masked string.
126+ func maskJSONValue (masker * masking.Masker , value any ) any {
127+ switch typed := value .(type ) {
128+ case string :
129+ return masker .MaskString (typed )
130+ case json.Number :
131+ return maskJSONLiteral (masker , typed .String (), typed )
132+ case bool :
133+ return maskJSONLiteral (masker , strconv .FormatBool (typed ), typed )
134+ case nil :
135+ return maskJSONLiteral (masker , "null" , nil )
136+ case []any :
137+ masked := make ([]any , len (typed ))
138+ for i , item := range typed {
139+ masked [i ] = maskJSONValue (masker , item )
140+ }
141+ return masked
142+ case map [string ]any :
143+ masked := make (map [string ]any , len (typed ))
144+ for key , item := range typed {
145+ masked [masker .MaskString (key )] = maskJSONValue (masker , item )
146+ }
147+ return masked
148+ default :
149+ return value
150+ }
151+ }
152+
153+ // maskJSONLiteral returns the masked text of a JSON literal holding a secret,
154+ // and value otherwise.
155+ func maskJSONLiteral (masker * masking.Masker , literal string , value any ) any {
156+ if masked := masker .MaskString (literal ); masked != literal {
157+ return masked
158+ }
159+ return value
160+ }
161+
51162// maskAgentSession masks the displayed text of an agent session. Answers are
52163// kept because a resumed step reads them back from status.
53164func maskAgentSession (masker * masking.Masker , session * ir.AgentSession ) * ir.AgentSession {
0 commit comments