Skip to content

Commit 34e2c30

Browse files
Update process handler (#4425)
* Update env expand logic * Upd process handler args validation * Remove test ex * Add copyright + cleanup imports * Simplify telemetry type * Simplify PH telemetry tests * Update debug logs * Exclude % from allowed symbols * Fix cmd expand env * remove useless telemetry * Add test traits * Fix env incorrect expanding * Add more test cases * Fix exception condition * Move validation args to public * Update validation logic * Add % to test * Code cleanup * Hide new logic under knob * Move constants to class * move const value back * Add PublishTelemetry overload * Update throw logic * Update invalid args exception * Update private const letter case --------- Co-authored-by: Kirill Ivlev <102740624+kirill-ivlev@users.noreply.github.com>
1 parent e5dca16 commit 34e2c30

File tree

10 files changed

+548
-177
lines changed

10 files changed

+548
-177
lines changed

src/Agent.Sdk/Knob/AgentKnobs.cs

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -473,6 +473,13 @@ public class AgentKnobs
473473
new EnvironmentKnobSource("AZP_75787_ENABLE_COLLECT"),
474474
new BuiltInDefaultKnobSource("false"));
475475

476+
public static readonly Knob ProcessHandlerEnableNewLogic = new Knob(
477+
nameof(ProcessHandlerEnableNewLogic),
478+
"Enables new sanitization logic for process handler",
479+
new RuntimeKnobSource("AZP_75787_ENABLE_NEW_PH_LOGIC"),
480+
new EnvironmentKnobSource("AZP_75787_ENABLE_NEW_PH_LOGIC"),
481+
new BuiltInDefaultKnobSource("false"));
482+
476483
public static readonly Knob DisableDrainQueuesAfterTask = new Knob(
477484
nameof(DisableDrainQueuesAfterTask),
478485
"Forces the agent to disable draining queues after each task",

src/Agent.Worker/Handlers/Handler.cs

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -308,10 +308,20 @@ protected void RemovePSModulePathFromEnvironment()
308308
}
309309
}
310310

311+
// This overload is to handle specific types some other way.
311312
protected void PublishTelemetry<T>(
312313
Dictionary<string, T> telemetryData,
313314
string feature = "TaskHandler"
314315
)
316+
{
317+
// JsonConvert.SerializeObject always converts to base object.
318+
PublishTelemetry((object)telemetryData, feature);
319+
}
320+
321+
private void PublishTelemetry(
322+
object telemetryData,
323+
string feature = "TaskHandler"
324+
)
315325
{
316326
ArgUtil.NotNull(Task, nameof(Task));
317327

Lines changed: 96 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,96 @@
1+
// Copyright (c) Microsoft Corporation.
2+
// Licensed under the MIT License.
3+
4+
using System.Collections.Generic;
5+
using System.Linq;
6+
using System.Text.RegularExpressions;
7+
using Microsoft.VisualStudio.Services.Agent.Util;
8+
9+
namespace Agent.Worker.Handlers.Helpers
10+
{
11+
public static class CmdArgsSanitizer
12+
{
13+
private const string _removedSymbolSign = "_#removed#_";
14+
private const string _argsSplitSymbols = "^^";
15+
private static readonly Regex _sanitizeRegExp = new("(?<!\\^)([^a-zA-Z0-9\\\\` _''\"\\-=\\/:\\.*,+~?^%])");
16+
17+
public static (string, CmdArgsSanitizingTelemetry) SanitizeArguments(string inputArgs)
18+
{
19+
if (inputArgs == null)
20+
{
21+
return (null, null);
22+
}
23+
24+
var argsChunks = inputArgs.Split(_argsSplitSymbols);
25+
var matchesChunks = new List<MatchCollection>();
26+
27+
for (int i = 0; i < argsChunks.Length; i++)
28+
{
29+
var matches = _sanitizeRegExp.Matches(argsChunks[i]);
30+
if (matches.Count > 0)
31+
{
32+
matchesChunks.Add(matches);
33+
argsChunks[i] = _sanitizeRegExp.Replace(argsChunks[i], _removedSymbolSign);
34+
}
35+
}
36+
37+
var resultArgs = string.Join(_argsSplitSymbols, argsChunks);
38+
39+
CmdArgsSanitizingTelemetry telemetry = null;
40+
41+
if (resultArgs != inputArgs)
42+
{
43+
var symbolsCount = matchesChunks
44+
.Select(chunk => chunk.Count)
45+
.Aggregate(0, (acc, mc) => acc + mc);
46+
telemetry = new CmdArgsSanitizingTelemetry
47+
(
48+
RemovedSymbols: CmdArgsSanitizingTelemetry.ToSymbolsDictionary(matchesChunks),
49+
RemovedSymbolsCount: symbolsCount
50+
);
51+
}
52+
53+
return (resultArgs, telemetry);
54+
}
55+
}
56+
57+
public record CmdArgsSanitizingTelemetry
58+
(
59+
Dictionary<string, int> RemovedSymbols,
60+
int RemovedSymbolsCount
61+
)
62+
{
63+
public static Dictionary<string, int> ToSymbolsDictionary(List<MatchCollection> matches)
64+
{
65+
ArgUtil.NotNull(matches, nameof(matches));
66+
67+
var symbolsDict = new Dictionary<string, int>();
68+
foreach (var mc in matches)
69+
{
70+
foreach (var m in mc.Cast<Match>())
71+
{
72+
var symbol = m.Value;
73+
if (symbolsDict.TryGetValue(symbol, out _))
74+
{
75+
symbolsDict[symbol] += 1;
76+
}
77+
else
78+
{
79+
symbolsDict[symbol] = 1;
80+
}
81+
}
82+
}
83+
84+
return symbolsDict;
85+
}
86+
87+
public Dictionary<string, object> ToDictionary()
88+
{
89+
return new()
90+
{
91+
["removedSymbols"] = RemovedSymbols,
92+
["removedSymbolsCount"] = RemovedSymbolsCount,
93+
};
94+
}
95+
}
96+
}
Lines changed: 96 additions & 80 deletions
Original file line numberDiff line numberDiff line change
@@ -1,66 +1,46 @@
1-
using System;
1+
// Copyright (c) Microsoft Corporation.
2+
// Licensed under the MIT License.
3+
4+
using System;
25
using System.Collections.Generic;
3-
using System.Text;
6+
using Agent.Sdk.Knob;
7+
using Microsoft.VisualStudio.Services.Agent.Util;
8+
using Microsoft.VisualStudio.Services.Agent.Worker;
9+
using Microsoft.VisualStudio.Services.Common;
410

511
namespace Agent.Worker.Handlers.Helpers
612
{
713
public static class ProcessHandlerHelper
814
{
9-
public static (string, CmdTelemetry) ProcessInputArguments(string inputArgs)
15+
private const char _escapingSymbol = '^';
16+
private const string _envPrefix = "%";
17+
private const string _envPostfix = "%";
18+
19+
public static (string, CmdTelemetry) ExpandCmdEnv(string inputArgs, Dictionary<string, string> environment)
1020
{
11-
const char quote = '"';
12-
const char escapingSymbol = '^';
13-
const string envPrefix = "%";
14-
const string envPostfix = "%";
21+
ArgUtil.NotNull(inputArgs, nameof(inputArgs));
22+
ArgUtil.NotNull(environment, nameof(environment));
1523

1624
string result = inputArgs;
1725
int startIndex = 0;
1826
var telemetry = new CmdTelemetry();
1927

2028
while (true)
2129
{
22-
int prefixIndex = result.IndexOf(envPrefix, startIndex);
30+
int prefixIndex = result.IndexOf(_envPrefix, startIndex);
2331
if (prefixIndex < 0)
2432
{
2533
break;
2634
}
2735

2836
telemetry.FoundPrefixes++;
2937

30-
if (prefixIndex > 0 && result[prefixIndex - 1] == escapingSymbol)
38+
if (prefixIndex > 0 && result[prefixIndex - 1] == _escapingSymbol)
3139
{
32-
if (result[prefixIndex - 2] == 0 || result[prefixIndex - 2] != escapingSymbol)
33-
{
34-
startIndex++;
35-
result = result[..(prefixIndex - 1)] + result[prefixIndex..];
36-
37-
telemetry.EscapedVariables++;
38-
39-
continue;
40-
}
41-
42-
telemetry.EscapedEscapingSymbols++;
40+
telemetry.EscapingSymbolBeforeVar++;
4341
}
4442

45-
// We possibly should simplify that part -> if just no close quote, then break
46-
int quoteIndex = result.IndexOf(quote, startIndex);
47-
if (quoteIndex >= 0 && prefixIndex > quoteIndex)
48-
{
49-
int nextQuoteIndex = result.IndexOf(quote, quoteIndex + 1);
50-
if (nextQuoteIndex < 0)
51-
{
52-
telemetry.QuotesNotEnclosed = 1;
53-
break;
54-
}
55-
56-
startIndex = nextQuoteIndex + 1;
57-
58-
telemetry.QuottedBlocks++;
59-
60-
continue;
61-
}
62-
63-
int envStartIndex = prefixIndex + envPrefix.Length;
43+
int envStartIndex = prefixIndex + _envPrefix.Length;
6444
int envEndIndex = FindEnclosingIndex(result, prefixIndex);
6545
if (envEndIndex == 0)
6646
{
@@ -69,32 +49,29 @@ public static (string, CmdTelemetry) ProcessInputArguments(string inputArgs)
6949
}
7050

7151
string envName = result[envStartIndex..envEndIndex];
72-
73-
telemetry.BracedVariables++;
74-
75-
if (envName.StartsWith(escapingSymbol))
52+
if (envName.StartsWith(_escapingSymbol))
7653
{
77-
var sanitizedEnvName = envPrefix + envName[1..] + envPostfix;
78-
79-
result = result[..prefixIndex] + sanitizedEnvName + result[(envEndIndex + envPostfix.Length)..];
80-
startIndex = prefixIndex + sanitizedEnvName.Length;
81-
8254
telemetry.VariablesStartsFromES++;
83-
84-
continue;
8555
}
8656

8757
var head = result[..prefixIndex];
88-
if (envName.Contains(escapingSymbol))
58+
if (envName.Contains(_escapingSymbol, StringComparison.Ordinal))
8959
{
90-
head += envName.Split(escapingSymbol)[1];
91-
envName = envName.Split(escapingSymbol)[0];
92-
9360
telemetry.VariablesWithESInside++;
9461
}
9562

96-
var envValue = System.Environment.GetEnvironmentVariable(envName) ?? "";
97-
var tail = result[(envEndIndex + envPostfix.Length)..];
63+
// Since Windows have case-insensetive environment, and Process handler is windows-specific, we should allign this behavior.
64+
var windowsEnvironment = new Dictionary<string, string>(environment, StringComparer.OrdinalIgnoreCase);
65+
66+
// In case we don't have such variable, we just leave it as is
67+
if (!windowsEnvironment.TryGetValue(envName, out string envValue) || string.IsNullOrEmpty(envValue))
68+
{
69+
telemetry.NotExistingEnv++;
70+
startIndex = prefixIndex + 1;
71+
continue;
72+
}
73+
74+
var tail = result[(envEndIndex + _envPostfix.Length)..];
9875

9976
result = head + envValue + tail;
10077
startIndex = prefixIndex + envValue.Length;
@@ -119,49 +96,88 @@ private static int FindEnclosingIndex(string input, int targetIndex)
11996

12097
return 0;
12198
}
99+
100+
public static (bool, Dictionary<string, object>) ValidateInputArguments(
101+
string inputArgs,
102+
Dictionary<string, string> environment,
103+
IExecutionContext context)
104+
{
105+
var enableValidation = AgentKnobs.ProcessHandlerSecureArguments.GetValue(context).AsBoolean();
106+
context.Debug($"Enable args validation: '{enableValidation}'");
107+
var enableAudit = AgentKnobs.ProcessHandlerSecureArgumentsAudit.GetValue(context).AsBoolean();
108+
context.Debug($"Enable args validation audit: '{enableAudit}'");
109+
var enableTelemetry = AgentKnobs.ProcessHandlerTelemetry.GetValue(context).AsBoolean();
110+
context.Debug($"Enable telemetry: '{enableTelemetry}'");
111+
112+
if (enableValidation || enableAudit || enableTelemetry)
113+
{
114+
context.Debug("Starting args env expansion");
115+
var (expandedArgs, envExpandTelemetry) = ExpandCmdEnv(inputArgs, environment);
116+
context.Debug($"Expanded args={expandedArgs}");
117+
118+
context.Debug("Starting args sanitization");
119+
var (sanitizedArgs, sanitizeTelemetry) = CmdArgsSanitizer.SanitizeArguments(expandedArgs);
120+
121+
Dictionary<string, object> telemetry = null;
122+
if (sanitizedArgs != inputArgs)
123+
{
124+
if (enableTelemetry)
125+
{
126+
telemetry = envExpandTelemetry.ToDictionary();
127+
if (sanitizeTelemetry != null)
128+
{
129+
telemetry.AddRange(sanitizeTelemetry.ToDictionary());
130+
}
131+
}
132+
if (sanitizedArgs != expandedArgs)
133+
{
134+
if (enableAudit && !enableValidation)
135+
{
136+
context.Warning(StringUtil.Loc("ProcessHandlerInvalidScriptArgs"));
137+
}
138+
if (enableValidation)
139+
{
140+
return (false, telemetry);
141+
}
142+
143+
return (true, telemetry);
144+
}
145+
}
146+
147+
return (true, null);
148+
}
149+
else
150+
{
151+
context.Debug("Args sanitization skipped.");
152+
return (true, null);
153+
}
154+
}
122155
}
123156

124157
public class CmdTelemetry
125158
{
126159
public int FoundPrefixes { get; set; } = 0;
127-
public int QuottedBlocks { get; set; } = 0;
128160
public int VariablesExpanded { get; set; } = 0;
129-
public int EscapedVariables { get; set; } = 0;
130-
public int EscapedEscapingSymbols { get; set; } = 0;
161+
public int EscapingSymbolBeforeVar { get; set; } = 0;
131162
public int VariablesStartsFromES { get; set; } = 0;
132-
public int BraceSyntaxEntries { get; set; } = 0;
133-
public int BracedVariables { get; set; } = 0;
134163
public int VariablesWithESInside { get; set; } = 0;
135164
public int QuotesNotEnclosed { get; set; } = 0;
136165
public int NotClosedEnvSyntaxPosition { get; set; } = 0;
166+
public int NotExistingEnv { get; set; } = 0;
137167

138-
public Dictionary<string, int> ToDictionary()
168+
public Dictionary<string, object> ToDictionary()
139169
{
140-
return new Dictionary<string, int>
170+
return new Dictionary<string, object>
141171
{
142172
["foundPrefixes"] = FoundPrefixes,
143-
["quottedBlocks"] = QuottedBlocks,
144173
["variablesExpanded"] = VariablesExpanded,
145-
["escapedVariables"] = EscapedVariables,
146-
["escapedEscapingSymbols"] = EscapedEscapingSymbols,
174+
["escapedVariables"] = EscapingSymbolBeforeVar,
147175
["variablesStartsFromES"] = VariablesStartsFromES,
148-
["braceSyntaxEntries"] = BraceSyntaxEntries,
149-
["bracedVariables"] = BracedVariables,
150176
["bariablesWithESInside"] = VariablesWithESInside,
151177
["quotesNotEnclosed"] = QuotesNotEnclosed,
152-
["notClosedBraceSyntaxPosition"] = NotClosedEnvSyntaxPosition
178+
["notClosedBraceSyntaxPosition"] = NotClosedEnvSyntaxPosition,
179+
["notExistingEnv"] = NotExistingEnv
153180
};
154181
}
155-
156-
public Dictionary<string, string> ToStringsDictionary()
157-
{
158-
var dict = ToDictionary();
159-
var result = new Dictionary<string, string>();
160-
foreach (var key in dict.Keys)
161-
{
162-
result.Add(key, dict[key].ToString());
163-
}
164-
return result;
165-
}
166182
};
167183
}

src/Agent.Worker/Handlers/NodeHandler.cs

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -156,7 +156,6 @@ public async Task RunAsync()
156156
{
157157
Trace.Info("AZP_AGENT_IGNORE_VSTSTASKLIB enabled, ignoring fix");
158158
}
159-
160159

161160
StepHost.OutputDataReceived += OnDataReceived;
162161
StepHost.ErrorDataReceived += OnDataReceived;

0 commit comments

Comments
 (0)