Skip to content

Sanitize ScriptArguments in AzurePowerShellV2-V5 and ServiceFabricPowerShellV1 (MSRC 115118) - #22171

Merged
wawanawna merged 27 commits into
masterfrom
users/wawanawna/msrc-115118-azurepowershell-sanitizer
May 19, 2026
Merged

Sanitize ScriptArguments in AzurePowerShellV2-V5 and ServiceFabricPowerShellV1 (MSRC 115118)#22171
wawanawna merged 27 commits into
masterfrom
users/wawanawna/msrc-115118-azurepowershell-sanitizer

Conversation

@wawanawna

@wawanawna wawanawna commented May 13, 2026

Copy link
Copy Markdown
Contributor

Context

The PowerShell-runner task family appended the YAML-supplied ScriptArguments input directly into a generated PowerShell command (& '<scriptPath>' $scriptArguments / . '<scriptPath>' $scriptArguments) without sanitization. A YAML template-parameter splice containing shell metacharacters (;, |, &, ``, etc.) would be parsed by PowerShell as additional statements and executed under the service connection's identity. This is the same class of injection that motivated the original work item #75787 and that has been progressively retrofitted across the repo:

Task family Retrofit PR
AzureFileCopyV1-V5, WindowsMachineFileCopyV1-V2 #18646 (2023-07-21)
PowerShellV2, BashV3, SshV0 #18744 (2023-08-04)
PowerShellV2 (moved onto common Sanitizer) #19183 (2023-11-08)
SqlAzureDacpacDeploymentV1, SqlDacpacDeploymentOnMachineGroupV0 #21947 (2026-04-16)
PowerShellOnTargetMachinesV3 #21968 (2026-04-15)
AzureCLIV2, AzureCLIV3 #22066 (2026-04-28)

The PowerShell-flavored sister tasks that had not been retrofitted were AzurePowerShellV2-V5 and ServiceFabricPowerShellV1. This PR closes that gap.

Related: ICM 31000000596029, MSRC case 115118 (resolved as Duplicate; this PR is the proper code-level remediation in the line of #18646 / #18744 / #22066).


Task Names

AzurePowerShellV2, AzurePowerShellV3, AzurePowerShellV4, AzurePowerShellV5, ServiceFabricPowerShellV1, plus a new helper file in Tasks/Common/Sanitizer/.

  • AzureCloudPowerShellDeploymentV1 - deprecated: true in task.json.
  • AzureCloudPowerShellDeploymentV2 - deprecated: true and preview; no ScriptArguments concat sink.
  • PowerShellOnTargetMachinesV2 - deprecated: true (PowerShellOnTargetMachinesV3 fixed in Fix security issue for PowerShellOnTargetMachines@3 #21968).
  • ChefKnifeV1 - deprecated: true, removal date 2024-01-31 in task.json.
  • AzureCLIV1 - deprecated: true, explicitly skipped in feat(AzureCLI): Implement security enhancements for scriptArguments validation #22066.
  • ServiceFabricDeployV1 - uses its own SfSafeParser feature-flagged safe parser instead of Invoke-Expression.
  • AzureVmssDeploymentV{0,1}/Resources/customScriptInvoker.ps1 - by design runs customer-supplied script on the target VM (the customer is the script author, not a third-party injecting into a YAML splice).

AzurePowerShellV1 and ServiceFabricPowerShellV2 do not exist in any branch of the repo.


Description

Two-flag rollout, mirroring PR #21947's Should-UseSanitizedArguments pattern (and #22066's EnableAzureCliArgsValidation pattern). Sanitization fires only when BOTH:

  1. Org-level - the existing "Enable shell tasks arguments validation" toggle (Org/Project Settings -> Pipelines -> Settings), which sets the AZP_75787_* env triplet on the agent. Surfaced via Get-SanitizerCallStatus in the existing Tasks/Common/Sanitizer module.
  2. Per-task pipeline - a new pipeline feature flag, queried via Get-VstsPipelineFeature:
    • EnableAzurePowerShellArgumentsSanitization - shared across AzurePowerShellV2-V5.
    • EnableServiceFabricPowerShellArgumentsSanitization - ServiceFabricPowerShellV1.

This means a customer who has the org toggle on today (e.g. because they enabled it for the PowerShellV2 fix) will not see any behavior change on these newly-onboarded tasks until Microsoft turns on the per-task pipeline flag, preventing accidental regression. The same rollout model was used by #21947 and #22066.

New shared helper - Tasks/Common/Sanitizer/Invoke-ScriptArgumentSanitization.ps1 (sourced from Sanitizer.psm1 and exported). Three functions:

Fail-closed dispatch (deliberate divergence from PowerShellV2's PS port): Invoke-ScriptArgumentSanitization treats any exception out of Protect-ScriptArguments as a sanitization failure and aborts the task with the task-localized ScriptArgsSanitized message. We deliberately do not compare .Exception.Message against Get-VstsLocString -Key 'ScriptArgsSanitized' to distinguish "rejection" from "unexpected error":

  • The Sanitizer module throws Get-VstsLocString -Key 'PS_ScriptArgsSanitized' (its own resjson key) while the task resjson uses key ScriptArgsSanitized. The two strings are translated independently. fr-FR already diverges today: coche vs backtick. An -eq comparison on a French agent would let a real sanitizer rejection fall into the "swallow into telemetry" branch and bypass the gate.
  • Even on an unrelated sanitizer crash, executing the un-vetted args is exactly the vulnerability we are guarding against. Failing closed is the safer default.

This is a divergence from the inline catch blocks in PowerShellV2 / SqlAzureDacpacDeploymentV1 / AzureCLIV2 (which compare to Get-VstsLocString); those task-specific implementations carry the same latent locale-drift bug but ship in TypeScript / inline PS where it has been masked by en-US-identical strings. The new shared helper fixes it once.

Per-task collapse - each task entry script went from a ~25-line inline try/catch + local Publish-Telemetry helper to:

powershell if ($scriptType -ne "InlineScript") { Invoke-ScriptArgumentSanitization
-InputArgs $scriptArguments -TaskName 'AzurePowerShellV5'
-PipelineFeatureFlagName 'EnableAzurePowerShellArgumentsSanitization'
}
`

Per task, the change set is still the proven PowerShellV2-style retrofit:

  1. make.json adds ../Common/Sanitizer to the common array.

  2. The entry script imports the module and calls the shared helper right after the existing [\r\n] newline check, only when -ne "InlineScript".

  3. task.json / task.loc.json patch-bumped (post-rebase against current master):

    Task master PR
    AzurePowerShellV2 2.274.0 2.274.2
    AzurePowerShellV3 3.274.0 3.274.2
    AzurePowerShellV4 4.274.2 (Default), 4.274.3 (Node24_1) 4.274.6 (Default), 4.274.7 (Node24_1)
    AzurePowerShellV5 5.274.4 (Default), 5.274.5 (Node24_1) 5.274.6 (Default), 5.274.7 (Node24_1)
    ServiceFabricPowerShellV1 1.228.0 1.274.0

    Patch-number gaps (V4: +4, V5: +2 from current master Default) are intentional - they reserve headroom for currently open in-flight PRs (Update package dependencies and fix security vulnerabilities #22058 Update package dependencies and fix security vulnerabilities is still based on older V4/V5 patches and is expected to land first).

  4. task.loc.json.messages adds "ScriptArgsSanitized": "ms-resource:loc.messages.ScriptArgsSanitized".

  5. All ten locale Strings/resources.resjson/<locale>/resources.resjson files add the new loc.messages.ScriptArgsSanitized entry, copying the already-translated text from PowerShellV2.

  6. _generated/AzurePowerShellV{4,5}{,_Node24} mirrors the source changes and _buildConfigMapping / *.versionmap.txt are kept consistent (Default = master patch, Node24_1 = Default+1).


Common/Sanitizer dependents bumped to satisfy ci/filter-tasks.js

Adding Tasks/Common/Sanitizer/Invoke-ScriptArgumentSanitization.ps1 (and modifying Sanitizer.psm1) trips the repo's policy that every task with ../Common/Sanitizer in its make.json must have its own version bumped in the same PR. Same precedent as PR #19834 (Common/Sanitizer audit-event change, May 2024). These 12 tasks gain no functional change; only task.json / task.loc.json / _generated/ mirrors are touched.

Bumping rule: smallest legal patch increment that satisfies BuildConfigGen's source > max(versionmap) invariant. Tasks without a Node24 build config get +1 (Default+1). Tasks with a Node24_1 config get +2 so that Node24_1 stays the conventional Default+1. Four tasks (PowerShellOnTargetMachinesV3, ServiceFabricPowerShellV1, WindowsMachineFileCopyV1, WindowsMachineFileCopyV2) had Minor versions older than the current sprint, so they were rolled forward to *.274.0 to satisfy ci/check-downgrading.js (minor >= currentSprintVersion).

Task master Default master Node24_1 PR Default PR Node24_1 Δ on Default
AzureFileCopyV1 1.274.0 - 1.274.1 - +1
AzureFileCopyV2 2.274.0 - 2.274.1 - +1
AzureFileCopyV3 3.274.0 - 3.274.1 - +1
AzureFileCopyV4 4.274.0 4.274.1 4.274.2 4.274.3 +2
AzureFileCopyV5 5.274.0 5.274.1 5.274.2 5.274.3 +2
AzureFileCopyV6 6.274.2 6.274.3 6.274.4 6.274.5 +2
PowerShellV2 2.274.0 - 2.274.1 - +1
PowerShellOnTargetMachinesV3 3.273.0 - 3.274.0 - sprint roll (273→274)
SqlAzureDacpacDeploymentV1 1.274.0 - 1.274.1 - +1
SqlDacpacDeploymentOnMachineGroupV0 0.274.0 - 0.274.1 - +1
WindowsMachineFileCopyV1 1.243.0 - 1.274.0 - sprint roll (243→274)
WindowsMachineFileCopyV2 2.251.0 - 2.274.0 - sprint roll (251→274)

_generated/AzureFileCopyV4/ and _generated/AzureFileCopyV5/ Default mirrors are re-materialized by BuildConfigGen as a consequence of the bump (PR #20819 had previously removed them when consolidating Node20 configs). The mirror content is byte-identical to source except for task.json / task.loc.json version fields - same shape as the existing _generated/AzureFileCopyV6/ Default mirror that has been checked in for the same reason.


InlineScript and ScriptPath are intentionally not sanitized: inline scripts are author-written; the path is not shell-spliced (it goes through '' quoting).


Risk Assessment - Low

  • Dual-flag rollout removes the only realistic regression vector. Org-level FF off -> no-op. Org on + per-task off -> no-op (this is the protective bit for customers who already enabled the org toggle). Both on -> enforce.
  • Test-ShouldUseSanitizer falls open (returns False) on cmdlet-missing or FF-check error and emits diagnostic telemetry - never a hard failure on the FF query path itself.
  • Allowlist regex and FF semantics are reused verbatim from the shipped Tasks/Common/Sanitizer module (no new sanitization code).
  • Verified manually by sourcing Sanitizer.psm1 and running five scenarios:
    1. Org off -> no-op even with malicious args.
    2. Org on, pipeline off -> no-op even with malicious args.
    3. Both on + malicious args -> throws with the localized ScriptArgsSanitized message.
    4. Both on + clean args -> returns.
    5. Get-VstsPipelineFeature missing -> no-op, emits cmdletMissing telemetry.
  • Locale-drift regression test included.

Documentation changes required: Y

The Learn topic at https://learn.microsoft.com/azure/devops/pipelines/security/inputs#enable-shell-tasks-arguments-validation has not been updated since the 2023 retrofit and is missing the four subsequent retrofits (#21947, #21968, #22066, this PR). Companion docs PR: MicrosoftDocs/azure-devops-docs#14371.

Added unit tests: Y

Tasks/Common/Sanitizer/Tests/L0Invoke-ScriptArgumentSanitization.ps1 covers the new dispatcher across six gating branches, fail-closed behaviour, and locale-drift regression. Registered in Tasks/Common/Sanitizer/Tests/L0.ts. The existing Tasks/Common/Sanitizer/Tests/L0* suite continues to cover the underlying regex/throw/audit/telemetry FF behaviour and bash/cmd/PowerShell format-compatibility.

Attached related issue: ICM 31000000596029, MSRC 115118, AB#75787

Checklist

  • Task versions bumped per instructions.
  • Patch-number gaps verified against current open PR queue; no collisions.
  • Changes apply only when both feature flags are enabled.
  • L0 test infrastructure unaffected (Register-Mock Import-Module in Tests/lib/Initialize-Test.ps1 neutralizes the new import; the helper falls open through the FF gate when Get-VstsPipelineFeature is unavailable).
  • _generated/ regenerated for the two tasks that have generated mirrors (V4, V5).
  • Shared helper passes 5/5 manual end-to-end smoke scenarios + 14 automated L0 assertions.
  • Rebased onto current master, _generated/ and versionmaps re-validated.
  • Server-side prerequisite (NOT in this PR): register EnableAzurePowerShellArgumentsSanitization and EnableServiceFabricPowerShellArgumentsSanitization in Tools.PipelineFeatures (mseng/AzureDevOps) with default off. Without this, the PR is a permanent no-op.
  • Independent review by MSRC-75787 / Tasks/Common/Sanitizer owners requested before merge.

Co-authored-by: Copilot 223556219+Copilot@users.noreply.github.com

wawanawna1984 and others added 14 commits May 12, 2026 16:59
MSRC 115118 / ICM 31000000596029. Mirrors the PowerShellV2 retrofit from PR #18744 / #19183.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
MSRC 115118 / ICM 31000000596029. Mirrors the PowerShellV2 retrofit from PR #18744 / #19183.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
MSRC 115118 / ICM 31000000596029. Mirrors the PowerShellV2 retrofit from PR #18744 / #19183.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
MSRC 115118 / ICM 31000000596029. Mirrors the PowerShellV2 retrofit from PR #18744 / #19183.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
MSRC 115118 / ICM 31000000596029. Mirrors the PowerShellV2 retrofit from PR #18744 / #19183.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
MSRC 115118 / ICM 31000000596029. Mirrors the Should-UseSanitizedArguments / per-task pipeline FF pattern from PR #21947.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
…-task pipeline FF

MSRC 115118 / ICM 31000000596029. Mirrors the Should-UseSanitizedArguments / per-task pipeline FF pattern from PR #21947.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
…-task pipeline FF

MSRC 115118 / ICM 31000000596029. Mirrors the Should-UseSanitizedArguments / per-task pipeline FF pattern from PR #21947.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
…-task pipeline FF

MSRC 115118 / ICM 31000000596029. Mirrors the Should-UseSanitizedArguments / per-task pipeline FF pattern from PR #21947.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
…-task pipeline FF

MSRC 115118 / ICM 31000000596029. Mirrors the Should-UseSanitizedArguments / per-task pipeline FF pattern from PR #21947.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
…on + per-task pipeline FF

MSRC 115118 / ICM 31000000596029. Mirrors the Should-UseSanitizedArguments / per-task pipeline FF pattern from PR #21947.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
…dispatcher

Covers the new dispatcher introduced by PR #22163 (MSRC 115118):
- Test-ShouldUseSanitizer gate logic across all combinations of org-level`n  toggle (Get-SanitizerCallStatus) and per-task pipeline FF
  (Get-VstsPipelineFeature), including throw, missing-cmdlet, and audit fallthrough.
- Invoke-ScriptArgumentSanitization dispatch: no-op when either gate is off;
  passes through clean args; re-throws localized ScriptArgsSanitized verbatim;
  swallows unexpected sanitizer exceptions into telemetry; handles empty input.
- Publish-SanitizerErrorTelemetry emits the expected ##vso[telemetry.publish]
  command shape.

Follows the precedent set by PR #21947's
Tasks/SqlAzureDacpacDeploymentV1/Tests/L0SecurityFunctions.ps1.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
…g for re-throw

The previous implementation caught any exception from Protect-ScriptArguments
and compared its message to Get-VstsLocString -Key 'ScriptArgsSanitized' to
decide whether to re-throw (rejection) or swallow into telemetry (unexpected
error). Two problems:

1. Protect-ScriptArguments throws Get-VstsLocString -Key 'PS_ScriptArgsSanitized'
   resolved from the Sanitizer module's resjson, while the dispatcher compared
   against 'ScriptArgsSanitized' resolved from the task's resjson. The two
   strings are translated independently. fr-FR uses 'coche' in one and
   'backtick' in the other, so on a French agent the -eq comparison returned
   false and a real sanitizer rejection was silently swallowed into telemetry
   - the task continued executing the injected arguments. The en-US strings
   happen to be identical, which is why this latent bug went unnoticed.

2. Even when Protect-ScriptArguments crashes with an unrelated error, the
   safer default for a security gate is to fail the task rather than execute
   un-vetted arguments.

The dispatcher now fails closed: any exception out of Protect-ScriptArguments
produces a telemetry record with the underlying message + stack and re-throws
the task-localized 'ScriptArgsSanitized' message to the caller. No locale-
dependent string comparison remains.

Tests cover all three throw paths (matching message, locale-drifted message,
unexpected crash) - all must fail closed with 'ScriptArgsSanitized'.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Rebase auto-resolve introduced a 'comma on own line' formatting glitch in
all AzurePowerShellV5 locale resources.resjson files (and the V5 generated
mirrors). Moves the comma to the end of the preceding line, matching the
rest of the resjson format and addressing review feedback from @nemanjarogic.

No content / behavior change.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
@wawanawna

Copy link
Copy Markdown
Contributor Author

@nemanjarogic this replaces #22163 — same branch, same commits (tip fe608119f), but now pushed to the upstream repo so Azure Pipelines / CodeQL / SDL run automatically (which was the blocker on the fork PR). Your review threads on #22163 are all addressed in the existing commits; flagging you for a fresh look once CI completes.

MSRC 115118 added a new file (Invoke-ScriptArgumentSanitization.ps1) to
Tasks/Common/Sanitizer/. ci/filter-tasks.js requires every task whose
Common dependencies change to have its own version bumped, otherwise the
'Filter out unchanged tasks' build step fails with 'The following tasks
should have their versions bumped due to changes in common: ...'.

This matches the precedent established by PR #19834 (Add audit event for
Shell Tasks Validation warnings), which made a similar Common/Sanitizer
change and bumped the same set of consumer tasks.

Bumps (patch +1, except where the existing _generated mirror's maxVersion
already exceeded source — in those cases bumped to maxVersion+1 to satisfy
BuildConfigGen's monotonicity invariant):
  AzureFileCopyV1 1.274.0 -> 1.274.1
  AzureFileCopyV2 2.274.0 -> 2.274.1
  AzureFileCopyV3 3.274.0 -> 3.274.1
  AzureFileCopyV4 4.274.0 -> 4.274.2 (Node24_1 was already 4.274.1)
  AzureFileCopyV5 5.274.0 -> 5.274.2 (Node24_1was already 5.274.1)
  AzureFileCopyV6 6.274.2 -> 6.274.4 (Node24_1 was already 6.274.3)
  PowerShellV2 2.274.0 -> 2.274.1
  PowerShellOnTargetMachinesV3 3.273.0 -> 3.273.1
  SqlAzureDacpacDeploymentV1 1.274.0 -> 1.274.1
  SqlDacpacDeploymentOnMachineGroupV0 0.274.0 -> 0.274.1
  WindowsMachineFileCopyV1 1.243.0 -> 1.243.1
  WindowsMachineFileCopyV2 2.251.0 -> 2.251.1

Also updates _generated/ mirrors to match (re-run via BuildConfigGen with
--write-updates --current-sprint 274). Most of the new files in _generated/
AzureFileCopyV4 and _generated/AzureFileCopyV5 are byte-identical copies of
source (54/56 unchanged); they exist because BCG materializes the Default
mirror whenever any non-Default config (here Node24_1) is registered.
Precedent: _generated/AzureFileCopyV6/ has been checked in this way for
the same reason.

No source / behavior changes in any of the 12 bumped tasks. The Sanitizer
dispatcher (Invoke-ScriptArgumentSanitization.ps1) is consumed only by
AzurePowerShellV2-V5 and ServiceFabricPowerShellV1; the 12 tasks bumped
here all already have org-level + per-task FF dispatch and gain nothing
from the new helper.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
@wawanawna
wawanawna requested review from a team and tarunramsinghani as code owners May 13, 2026 11:00
wawanawna1984 and others added 7 commits May 13, 2026 13:47
…e check)

ci/check-downgrading.js failed on the previous push because four tasks had
Minor versions older than the current sprint (274). The check enforces
minor >= currentSprintVersion. Patch-only bumps were not enough.

  ServiceFabricPowerShellV1     1.228.2 -> 1.274.0
  PowerShellOnTargetMachinesV3  3.273.1 -> 3.274.0
  WindowsMachineFileCopyV1      1.243.1 -> 1.274.0
  WindowsMachineFileCopyV2      2.251.1 -> 2.274.0

None of these have Node24 generated mirrors so no _generated/ updates
are needed (BuildConfigGen verified clean for all four).

ServiceFabricPowerShellV1 is one of the 5 sanitizer-adopting tasks, so
the Risk Assessment / version table in the PR description has been
updated to match.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
The original retrofit added the localized 'ScriptArgsSanitized' message to
task.loc.json and Strings/resources.resjson/<locale>/resources.resjson, but
forgot to add the canonical source-of-truth entry to task.json's 'messages'
block for AzurePowerShellV2-V5 and ServiceFabricPowerShellV1.

The CI 'Verify task source changes' step runs 'node make.js build', which
regenerates task.loc.json and en-US/resources.resjson from task.json.messages.
Without the source entry, that step stripped the ScriptArgsSanitized line on
every rebuild — leaving the dispatcher's localized throw with no message to
resolve at runtime in en-US.

Fix: add the message string to task.json's messages block for each of the 5
tasks. Then runs 'node make.js build' against each so task.loc.json and en-US
resources.resjson are emitted by the canonical build path; BCG re-syncs the
V4/V5 generated mirrors.

No functional change to non-en-US localizations — they continue to use the
existing translations from the original retrofit.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
The CI's static loc-resource check ('Find missing string in .ps1/.psm1')
scans each PS file under Tasks/Common/<Module>/ and requires every
Get-VstsLocString -Key 'X' call to have 'X' declared in that module's
module.json. My dispatcher lives in Tasks/Common/Sanitizer/ and threw with
the task-level key 'ScriptArgsSanitized', which is declared in each task's
task.json but NOT in the Sanitizer module.json — so the test failed.

Fix: switch the throw to the module's own 'PS_ScriptArgsSanitized' key,
which is already declared in Tasks/Common/Sanitizer/module.json and its
10 locale resjson files. This makes the dispatcher self-contained at the
module level (the principle the static check enforces).

Behavior is unchanged: the en-US text of 'PS_ScriptArgsSanitized'
(module) and 'ScriptArgsSanitized' (task) are byte-identical, and the
fail-closed logic is preserved. Non-en-US locales will see the module's
translation instead of the task's; this is actually a small improvement
because the dispatcher and the underlying Protect-ScriptArguments now
speak the same key — no locale-drift surface remains at all.

Updates the dispatcher's L0 tests to assert on 'PS_ScriptArgsSanitized'
accordingly.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
…ter tasks

Earlier commits in this PR added 'ScriptArgsSanitized' to task.json /
task.loc.json / 10 locale resjsons for AzurePowerShellV2-V5 and
ServiceFabricPowerShellV1, mirroring the convention used by PowerShellV2
and the other inline-sanitizer tasks. After consolidating the dispatch
logic into Tasks/Common/Sanitizer/Invoke-ScriptArgumentSanitization.ps1
and switching its throw to the module's own 'PS_ScriptArgsSanitized' key,
those task-level entries became unreachable - no .ps1 in these 5 tasks
calls Get-VstsLocString -Key 'ScriptArgsSanitized' anymore (verified by
grep).

This commit removes the dead entries from each of the 5 tasks:
  - task.json messages -> rebuild via 'node make.js build --task X' to
    regenerate task.loc.json and en-US/resources.resjson canonically.
  - 9 non-en-US locale resjsons -> manually stripped.

BuildConfigGen re-syncs the AzurePowerShellV4/V5 generated mirrors.

Net effect: only the live 'PS_ScriptArgsSanitized' key (declared in
Tasks/Common/Sanitizer/module.json + its 10 locales) is referenced at
runtime by any code path in this PR. The diff vs master shrinks because
we're undoing additions made earlier in the same PR.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
…lint)

The Tests/L0 'Find missing string in .ps1/.psm1' test does a case-insensitive
substring search for 'Get-VstsLocString' on every line in every .ps1/.psm1
under Tasks/ (excluding Tests/). When it finds the substring, it tries to
parse the line as a 'Get-VstsLocString -Key ...' call. If the parse fails,
it asserts 'Bad format string'. The test doesn't distinguish code from
comments.

My explanatory comment mentioned the cmdlet name verbatim, so the test
flagged it. Reworded the comment to describe the behavior ('loc resource
references') without using the literal cmdlet name. No code change.

Also adds C:/ado/temp/check-loc-strings.js (local equivalent of the CI
step) so future iterations of this PR can be validated before pushing.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
… cleanup

After the previous commit removed the dead ScriptArgsSanitized entries from
the source task.json files, _generated/AzurePowerShellV4_Node24/task.json
and V5_Node24/task.json were updated by BuildConfigGen but their dependent
task.loc.json + en-US/resources.resjson were not re-emitted from the build
pass. CI 'Verify task source changes' flagged them.

Re-ran 'node make.js build --task AzurePowerShellV{4,5}' which regenerates
all config mirrors (Default + Node24_1) from the updated task.json sources.
Four files updated per task (Default and Node24 both have task.loc.json +
en-US resjson).

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
…US resjson

Tasks/AzurePowerShellV4 and V5 use the _buildConfigMapping mechanism, so
'node make.js build --task X' regenerates only the _generated/ mirrors —
the source Tasks/<task>/task.loc.json and Strings/.../en-US/resources.resjson
are hand-maintained / BCG-synced, not build-regenerated.

When I removed ScriptArgsSanitized from task.json earlier, the build's regen
pass updated V2/V3/SFV1 source loc files (those tasks don't have build
configs) but left V4/V5 source loc files with the now-stale entry. BCG
verify caught the divergence: V4 task.loc.json had 9 messages (including
ScriptArgsSanitized) while V4 task.json had 8.

Manually stripped ScriptArgsSanitized from Tasks/AzurePowerShellV{4,5}/
task.loc.json + Strings/.../en-US/resources.resjson (non-en-US locales
were already cleaned in a prior commit). BCG verify now reports clean.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
The per-task L0 tests for AzurePowerShellV2-V5 and ServiceFabricPowerShellV1
dot-source Tests/lib/Initialize-Test.ps1 (which mocks Import-Module so the
Sanitizer common module isn't actually loaded), then directly invoke the
task's main script. After this PR's retrofit, those main scripts call
Invoke-ScriptArgumentSanitization — which is undefined under the test
mock, so PowerShell aborts with 'term not recognized'.

MacOS/Linux CI passed because per-task PowerShell-based L0 suites only
run on Windows. My local validation also missed it; adding the per-task
L0 step to validate-pr-22171.ps1 so future iterations catch this class
of failure before push.

Fix: add Register-Mock Invoke-ScriptArgumentSanitization (stub no-op) right
after Initialize-Test.ps1 in each of the 40 affected source test files
across the 5 tasks. BuildConfigGen propagated the mock to the V4/V5
generated mirror Tests/ trees automatically.

Local: all 5 tasks now pass L0 (8-13 passing each).

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
@wawanawna

Copy link
Copy Markdown
Contributor Author

/azp run

@azure-pipelines

Copy link
Copy Markdown
Azure Pipelines successfully started running 3 pipeline(s).

@wawanawna

Copy link
Copy Markdown
Contributor Author

Re-running CI: the previous ci-tests-tasks-v2 failure (build 294554) was unrelated to this PR. Root-causes per timeline inspection:

  • 294559 / 294577 (AzureFileCopyV5, WindowsMachineFileCopyV2) actually succeeded; parent orchestrator failed with Request timeout: /canarytest/PipelineTasks/_apis/build/Builds/... while polling them (infra flake).
  • 294569 (PowerShellV2) only succeededWithIssues on the (Injected) Canary tests - publish task as artifact step with The HTTP request timed out after 00:01:40 — infra flake; test phase itself passed.
  • 294555 / 294556 (AzureCLIV3) succeededWithIssues from the Azure CLI - scriptPath with injection args (bash) [must fail] negative tests correctly throwing ArgsSanitizingError — pre-existing reporting quirk introduced by feat(AzureCLI): Implement security enhancements for scriptArguments validation #22066's sanitizer tests; AzureCLIV3 is not modified by this PR.
  • 294572 (PublishTestResultsV2) succeededWithIssues from negative tests Check failed task when test files not found / Check failed task on failed test deliberately triggering errors — pre-existing; this task is not touched by this PR.

None of the modified tasks (AzurePowerShellV2-V5, ServiceFabricPowerShellV1, the version-only bumps on AzureFileCopy*, PowerShellV2, SqlAzureDacpacDeploymentV1, SqlDacpacDeploymentOnMachineGroupV0, PowerShellOnTargetMachinesV3, WindowsMachineFileCopy*) failed any actual assertion.

/azp run

wawanawna and others added 3 commits May 18, 2026 14:18
The sprint version on master rolled from 274 to 275 (via #22175),
so ci/check-downgrading.js now rejects every task left at *.274.*.
Bump each task's Default minor to 275, with the smallest patch >=
master's current patch. Tasks with a Node24_1 BuildConfig get
Node24_1 = Default + 1 (per the same convention used earlier in
this PR).

New versions:
  AzureFileCopyV1                      1.275.0
  AzureFileCopyV2                      2.275.0
  AzureFileCopyV3                      3.275.0
  AzureFileCopyV4                      4.275.0 / 4.275.1 (Node24_1)
  AzureFileCopyV5                      5.275.0 / 5.275.1 (Node24_1)
  AzureFileCopyV6                      6.275.2 / 6.275.3 (Node24_1)
  AzurePowerShellV2                    2.275.0
  AzurePowerShellV3                    3.275.0
  AzurePowerShellV4                    4.275.2 / 4.275.3 (Node24_1)
  AzurePowerShellV5                    5.275.4 / 5.275.5 (Node24_1)
  PowerShellOnTargetMachinesV3         3.275.0
  PowerShellV2                         2.275.0
  ServiceFabricPowerShellV1            1.275.0
  SqlAzureDacpacDeploymentV1           1.275.0
  SqlDacpacDeploymentOnMachineGroupV0  0.275.0
  WindowsMachineFileCopyV1             1.275.0
  WindowsMachineFileCopyV2             2.275.0

Source task.json/task.loc.json, _generated mirrors, _buildConfigMapping
entries, and *.versionmap.txt files are all kept consistent. No code
changes.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Addresses review feedback on PR #22171:

1. ServiceFabricPowerShellV1 was pinning VstsTaskSdk 0.11.0, but
   Get-VstsPipelineFeature (used by the new
   Invoke-ScriptArgumentSanitization dispatcher) only landed in 0.20.0+.
   The dispatcher's Test-ShouldUseSanitizer falls open to no-op when
   the cmdlet is missing, which silently made the SF V1 sanitization
   inert in production - exactly the regression the new helper was
   designed to surface only on real failures.

   Bumped to 0.21.0 to match AzurePowerShellV5 / PowerShellV2
   (sister sanitizer-adopting PowerShell tasks).

   Verified safe:
   - SF V1 only uses Get-VstsInput / Trace-VstsEnteringInvocation,
     which are stable across SDK versions.
   - All SF V1 L0 tests Register-Mock Invoke-ScriptArgumentSanitization
     at top, so the actual SDK call is never exercised in tests.
   - Tests/lib/Initialize-Test.ps1 already provides a global stub for
     Get-VstsPipelineFeature, so the dispatcher's cmdlet-presence
     check is neutralized in test runs regardless of SDK version.

2. L0Invoke-ScriptArgumentSanitization.ps1 header comment referenced
   the predecessor fork PR #22163 (which this PR explicitly replaces);
   updated to #22171.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
@wawanawna
wawanawna enabled auto-merge (squash) May 19, 2026 06:42
@wawanawna
wawanawna merged commit 03f7648 into master May 19, 2026
14 checks passed
@wawanawna
wawanawna deleted the users/wawanawna/msrc-115118-azurepowershell-sanitizer branch May 19, 2026 07:20
wawanawna pushed a commit to wawanawna/azure-pipelines-tasks that referenced this pull request May 26, 2026
Fix Expand-EnvVariables.ps1 so that YAML folded scalars ('arguments: >'), which emit literal newlines between joined lines, don't make the env-name greedily span across lines, miss the lookup, and fall through to the sanitizer allowlist.

Companion to PR microsoft#22181 / issue microsoft#22173, which fixed the same bug in the TypeScript twin (Tasks/PowerShellV2/helpers.ts -> expandPowerShellEnvVariables). The PowerShell expander shares the design (and the bug) but had not been ported. This PR ('AzurePowerShellV2-V5 + ServiceFabricPowerShellV1 sanitization', microsoft#22171) is the first time Expand-EnvVariables is wired into a task family where folded-scalar arguments containing $env:* references is a documented pattern, so fixing it here removes a known false-positive before rollout.

Adds five regression cases to L0Expand-EnvVariables (LF, CRLF, tab, multiple env vars separated by LF, and the full folded-scalar reproducer from the AzurePowerShell scenario). Verified the new tests fail on the unfixed expander and pass with the fix; remaining Common/Sanitizer L0s (Protect-ScriptArguments.Passes/Throws, Get-SanitizedArgumentsArray.*) still green.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
wawanawna added a commit that referenced this pull request May 28, 2026
…— PS twin of #22181 (#22207)

* Common/Sanitizer: terminate $env: name on whitespace (LF/CR/TAB)

Fix Expand-EnvVariables.ps1 so that YAML folded scalars ('arguments: >'), which emit literal newlines between joined lines, don't make the env-name greedily span across lines, miss the lookup, and fall through to the sanitizer allowlist.

Companion to PR #22181 / issue #22173, which fixed the same bug in the TypeScript twin (Tasks/PowerShellV2/helpers.ts -> expandPowerShellEnvVariables). The PowerShell expander shares the design (and the bug) but had not been ported. This PR ('AzurePowerShellV2-V5 + ServiceFabricPowerShellV1 sanitization', #22171) is the first time Expand-EnvVariables is wired into a task family where folded-scalar arguments containing $env:* references is a documented pattern, so fixing it here removes a known false-positive before rollout.

Adds five regression cases to L0Expand-EnvVariables (LF, CRLF, tab, multiple env vars separated by LF, and the full folded-scalar reproducer from the AzurePowerShell scenario). Verified the new tests fail on the unfixed expander and pass with the fix; remaining Common/Sanitizer L0s (Protect-ScriptArguments.Passes/Throws, Get-SanitizedArgumentsArray.*) still green.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

* Bump dependent task versions for Common/Sanitizer change

ci/filter-tasks.js requires that every task with Tasks/Common/Sanitizer in its make.json have its source version bumped in the same PR that modifies the Sanitizer module (same policy that drove the bump batch in #22171). Patch is bumped by +1 on tasks without a Node24 build config and by +2 on tasks with a Node24_1 config so that Node24_1 stays Default+1.

_generated/<task>{,_Node24}/task.json + task.loc.json + <task>.versionmap.txt mirrors are updated to match for the five tasks with Node24_1 buildconfigs (AzureFileCopy V4/V5/V6, AzurePowerShell V4/V5).

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

* Regenerate _generated mirrors via BuildConfigGen

Updates _buildConfigMapping version strings in _generated task.json/task.loc.json
to match the bumped Patch values. Produced by:
  dotnet run --project BuildConfigGen/BuildConfigGen.csproj -- `n    --task "AzureFileCopyV4|AzureFileCopyV5|AzureFileCopyV6|AzurePowerShellV4|AzurePowerShellV5" `n    --write-updates

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

---------

Co-authored-by: Uladzimir Tratsiakou (Vladimir/Vova) <utratsiakou@microsoft.com>
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
wawanawna added a commit that referenced this pull request Jun 25, 2026
…llOnTargetMachinesV3 and AzureCloudPowerShellDeploymentV1 (#22286)

* Wire Invoke-ScriptArgumentSanitization on POTM/AzureCloud (MSRC 115118 Plan 1)

Closes the two remaining PowerShell command-injection sinks tracked by MSRC 115118
that pass user-controlled task inputs straight to Invoke-Expression, by wiring the
shared Invoke-ScriptArgumentSanitization dispatcher (Tasks/Common/Sanitizer/,
shipped by #22171 and hardened in #22273) onto each input. Validate-only: the
dispatcher throws on a violation when both the org-level 'Enable shell tasks
arguments validation' toggle AND the new per-task pipeline feature flag are on,
which fails the task before the iex runs. Otherwise it is a no-op.

Bug 1 - PowerShellOnTargetMachinesV3 / NewPsSessionOptionArguments
  Sink: Utility.ps1:57-59  Invoke-Expression "New-PSSessionOption $arguments"
  Wire: PowerShellOnTargetMachines.ps1 calls Invoke-ScriptArgumentSanitization with
        TaskName 'PowerShellOnTargetMachinesV3' and PipelineFeatureFlagName
        'EnablePowerShellOnTargetMachinesArgumentsSanitization' BEFORE the call
        that flows the input into Get-NewPSSessionOption.
  Module already bundled (RemoteDeployer + Sanitizer in make.json); no build
  config change. Version bump 3.276.1 -> 3.276.2.

Bug 2 - AzureCloudPowerShellDeploymentV1 / NewServiceAdditionalArguments
  Sink: Publish-AzureCloudDeployment.ps1:75-77  raw append to $azureService then
        Invoke-Expression -Command $azureService.
  Wire: Add ../Common/Sanitizer to make.json common, Import-Module Sanitizer,
        and call Invoke-ScriptArgumentSanitization with TaskName
        'AzureCloudPowerShellDeploymentV1' and PipelineFeatureFlagName
        'EnableAzureCloudPowerShellArgumentsSanitization' right after the input
        is read and before the new-service block. Version bump 1.276.0 -> 1.276.1.

FF registration in mseng/AzureDevOps lands in a companion PR (mirrors #22171's
two FFs: declared in all four FeatureAvailability XMLs, defaulted on in the
two DevFabric XMLs only). Production rollout via the FF service is staged
audit -> enforce -> 100%, then the FF can be retired.

The merged #22273 AST backstop (Test-SanitizerArgumentAst) is what closes the
newline-bypass and data-constructor injection that a strict-mode char allow-list
alone would not catch; the dispatcher therefore calls Protect-ScriptArguments
with -AllowDataConstructors. Both wirings inherit that hardening for free.

L0 tests added:
  PowerShellOnTargetMachinesV3/Tests/L0NewPsSessionOptionArgumentsSanitizerWiring.ps1
  AzureCloudPowerShellDeploymentV1/Tests/L0NewServiceAdditionalArgumentsSanitizerWiring.ps1
Each is a wiring contract: asserts the dispatcher call is present, validates the
right input variable, uses the task's exact TaskName, uses the FF name registered
in the companion ADO PR, AND runs before the vulnerable Invoke-Expression so the
throw aborts the task before the sink. Comprehensive dispatcher runtime behavior
is already covered by Tasks/Common/Sanitizer/Tests/L0Invoke-ScriptArgumentSanitization.ps1.

MSRC 115118 / IcM 31000000596029.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

* Bug 2: also sanitize ServiceName / ServiceLocation / NewServiceAffinityGroup

Adversarial review (Opus-4.8 max) demonstrated RCE on AzureCloudPowerShellDeploymentV1
even after the initial Bug 2 fix, by feeding the payload through ServiceName instead
of NewServiceAdditionalArguments. Three other task inputs flow into the SAME
Invoke-Expression -Command $azureService sink in the new-service-creation path:

  Publish-AzureCloudDeployment.ps1
    :84  $azureService  = "New-AzureService -ServiceName `"$ServiceName`""
    :86  $azureService += " -AffinityGroup `"$NewServiceAffinityGroup`""
    :89  $azureService += " -Location `"$ServiceLocation`""
    :94  $azureService += " $NewServiceAdditionalArguments"
    :96  $azureService  = Invoke-Expression -Command $azureService

Each input is interpolated inside "..." and can break the quoting (literal " or
subexpression $(...)) to inject PowerShell at the iex.

Fix: validate all four inputs through Invoke-ScriptArgumentSanitization (same gates,
same FF name). Empirically verified: the reviewer's payload
  ServiceName='svc"; $global:PWNED=$true; #'
now throws PS_ScriptArgsSanitized with the FF on and the injection does NOT execute.
Legitimate values (DNS-style service names, 'West US' locations, affinity-group
names) remain ALLOWED (tested against the real Protect-ScriptArguments).

Wiring L0 updated to assert all four -InputArgs anchors are present.

MSRC 115118 Bug 2 / IcM 31000000596029.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

* AzureCloud: move sanitizer call inside if (!$azureService) branch

Second adversarial review (GPT-5.5 xhigh) flagged that the new-service inputs
(ServiceName / ServiceLocation / NewServiceAffinityGroup / NewServiceAdditionalArguments)
are only used inside the if (!$azureService) new-service-creation branch, but the
sanitizer ran at top of try{}. With the FF on, this would falsely reject
existing-service deployments (e.g. an unrelated -Description "Prod (EU)" left
in the field), since the strict char allow-list rejects parens.

Move all four dispatcher calls just inside the if (!$azureService) branch, right
before the iex sink. Existing-service deployments now keep their pre-fix behavior;
new-service deployments still get full validation.

L0 wiring contract extended to assert the dispatcher position is after the
if (!$azureService) opening brace (i.e. inside the branch).

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

* L0: drop stale reviewer-attribution from test comments

Copilot review on PR #22286: the parenthetical (<reviewer>, <date>) notes on
the two test-comment blocks will go stale and aren't actionable for future
maintainers. Keep the rationale (multi-input, branch-scoped), drop the dates
and model names.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

---------

Co-authored-by: Uladzimir Tratsiakou (Vladimir/Vova) <utratsiakou@microsoft.com>
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

6 participants