Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -522,7 +522,8 @@ public static async Task<bool> EnsureDelegatedConsentWithRetriesAsync(

/// <summary>
/// Creates Agent Blueprint application using Graph API
/// Implements dual-path discovery for idempotency: checks objectId from config first, falls back to displayName query.
/// Implements displayName-first discovery for idempotency: always searches by displayName from a365.config.json (the source of truth).
/// Cached objectIds are only used for dependent resources (FIC, etc.) after blueprint existence is confirmed.
/// Used by: BlueprintSubcommand and A365SetupRunner Phase 2.2
/// Returns: (success, appId, objectId, servicePrincipalId, alreadyExisted)
/// </summary>
Expand All @@ -545,47 +546,31 @@ public static async Task<bool> EnsureDelegatedConsentWithRetriesAsync(
CancellationToken ct)
{
// ========================================================================
// Idempotency Check: Dual-Path Discovery
// Idempotency Check: DisplayName-First Discovery
// ========================================================================

string? existingObjectId = setupConfig.AgentBlueprintObjectId;
// IMPORTANT: a365.config.json is the source of truth for displayName.
// We always search by displayName first to handle scenarios where the user
// changes displayName in a365.config.json. Cached objectIds are only used
// for dependent resources (FIC, etc.) after blueprint is confirmed to exist.

string? existingObjectId = null;
string? existingAppId = null;
string? existingServicePrincipalId = setupConfig.AgentBlueprintServicePrincipalObjectId;
bool blueprintAlreadyExists = false;
bool requiresPersistence = false;

// Primary path: Check if we have objectId in config
if (!string.IsNullOrWhiteSpace(existingObjectId))
{
logger.LogDebug("Checking for existing blueprint with objectId: {ObjectId}...", existingObjectId);
var lookupResult = await blueprintLookupService.GetApplicationByObjectIdAsync(tenantId, existingObjectId, ct);

if (lookupResult.Found)
{
logger.LogInformation("Blueprint '{DisplayName}' already exists", displayName);

existingAppId = lookupResult.AppId;
blueprintAlreadyExists = true;
}
else
{
logger.LogWarning("ObjectId in config not found in Entra ID - will try discovery by display name");
existingObjectId = null;
}
}

// Fallback path: Query by displayName for migration scenarios
if (!blueprintAlreadyExists && !string.IsNullOrWhiteSpace(displayName))
// Always search by displayName from a365.config.json (the master source of truth)
if (!string.IsNullOrWhiteSpace(displayName))
{
logger.LogDebug("Searching for existing blueprint by display name: {DisplayName}...", displayName);
var lookupResult = await blueprintLookupService.GetApplicationByDisplayNameAsync(tenantId, displayName, cancellationToken: ct);

if (lookupResult.Found)
{
logger.LogInformation("Found existing blueprint by display name - updating config with current identifiers");
logger.LogInformation("Found existing blueprint by display name");
logger.LogInformation(" - Object ID: {ObjectId}", lookupResult.ObjectId);
logger.LogInformation(" - App ID: {AppId}", lookupResult.AppId);

existingObjectId = lookupResult.ObjectId;
existingAppId = lookupResult.AppId;
blueprintAlreadyExists = true;
Expand Down
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
// Copyright (c) Microsoft Corporation.
// Licensed under the MIT License.

// Copyright (c) Microsoft Corporation.
// Licensed under the MIT License.
using System.Diagnostics;
using System.Runtime.InteropServices;
using System.Text;
Expand Down Expand Up @@ -153,7 +153,15 @@ public virtual async Task<CommandResult> ExecuteWithStreamingAsync(
if (args.Data != null)
{
outputBuilder.AppendLine(args.Data);
Console.WriteLine($"{outputPrefix}{args.Data}");
// Don't print JWT tokens to console (security)
if (!IsJwtToken(args.Data))
{
Console.WriteLine($"{outputPrefix}{args.Data}");
}
else
{
_logger.LogDebug("JWT token filtered from console output for security");
}
}
};

Expand Down Expand Up @@ -225,6 +233,63 @@ private string StripAzureWarningPrefix(string message)
}
return message;
}

private const string JwtTokenPrefix = "eyJ";
private const int JwtTokenDotCount = 2;
private const int MinimumJwtTokenLength = 100;

/// <summary>
/// Detects JWT tokens using a heuristic approach to prevent logging sensitive credentials.
/// </summary>
/// <remarks>
/// HEURISTIC DETECTION LIMITATIONS:
///
/// This method uses pattern matching rather than full JWT validation for performance reasons.
/// Detection criteria:
/// - Starts with "eyJ" (Base64url encoding of "{" - typical JWT header start)
/// - Contains exactly 2 dots (separating header.payload.signature)
/// - Length greater than 100 characters (typical JWT tokens are much longer)
///
/// Known Limitations:
/// 1. FALSE POSITIVES: May incorrectly flag non-JWT base64 strings that happen to match the pattern
/// - Example: A base64-encoded JSON starting with "{" that contains dots in the payload
/// - Impact: Harmless - such strings are simply hidden from console but still captured in output
///
/// 2. FALSE NEGATIVES: Will NOT detect tokens that deviate from standard JWT format
/// - Custom token formats not starting with "eyJ"
/// - Malformed JWTs with incorrect dot count
/// - Very short test tokens (less than 100 chars)
/// - Tokens embedded in log messages (e.g., "Token: eyJ...")
/// - Impact: Such tokens would be displayed in console (security risk)
/// - Rationale: Only filtering standalone tokens (lines that are purely tokens) to avoid
/// accidentally filtering legitimate log messages that happen to contain token-like strings
///
/// 3. NO STRUCTURAL VALIDATION: Does not decode or verify JWT structure
/// - Does not validate base64url encoding
/// - Does not verify header/payload are valid JSON
/// - Does not check signature validity
/// - Rationale: Full validation would require decoding overhead for every output line
///
/// SECURITY TRADE-OFF:
/// This heuristic approach prioritizes performance and simplicity over perfect detection.
/// It effectively filters standard Microsoft Graph JWT tokens (the primary security concern)
/// while avoiding expensive cryptographic operations on every console output line.
///
/// For absolute security, tokens should be transmitted through secure channels (environment
/// variables, key vaults) rather than command output. This filter is a defense-in-depth measure.
/// </remarks>
/// <param name="line">The output line to check for JWT token patterns</param>
/// <returns>True if the line appears to contain a JWT token and should be filtered from console</returns>
private static bool IsJwtToken(string line)
{
var trimmed = line?.Trim();
if (string.IsNullOrEmpty(trimmed))
return false;

return trimmed.StartsWith(JwtTokenPrefix, StringComparison.Ordinal) &&
trimmed.Count(c => c == '.') == JwtTokenDotCount &&
trimmed.Length > MinimumJwtTokenLength;
}
}

public class CommandResult
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -280,4 +280,39 @@ public async Task GetApplicationByDisplayNameAsync_WhenMultipleBlueprintsFound_R
result.Found.Should().BeTrue();
result.ObjectId.Should().Be(objectId1); // Should return the first match
}

[Fact]
public async Task GetApplicationByDisplayNameAsync_WhenDisplayNameMismatch_ReturnsNotFound()
{
// Arrange - Regression test for idempotency bug
// Scenario: User changes displayName in a365.config.json but cached objectId points to old name
// Expected: Search by new displayName should return NotFound (not the cached blueprint)
//
// Bug History:
// - Step 1: 'a365 setup all' creates "MyAgent Blueprint" -> saves objectId to config
// - Step 2: User edits a365.config.json -> changes displayName to "NewAgent Blueprint"
// - Step 3: 'a365 setup all' searches by new displayName -> should NOT find old blueprint
//
// Fix: BlueprintSubcommand now always uses displayName-first discovery (lines 547-578)
// This test verifies the lookup service correctly returns NotFound when displayName doesn't match

var newDisplayName = "NewAgent Blueprint";
var jsonResponse = @"{""value"": []}"; // No blueprints match the new displayName
var jsonDoc = JsonDocument.Parse(jsonResponse);

_graphApiService.GraphGetAsync(
TestTenantId,
Arg.Is<string>(s => s.Contains("/beta/applications?$filter=") && s.Contains("NewAgent")),
Arg.Any<CancellationToken>())
.Returns(jsonDoc);

// Act
var result = await _service.GetApplicationByDisplayNameAsync(TestTenantId, newDisplayName);

// Assert
result.Should().NotBeNull();
result.Found.Should().BeFalse("searching by new displayName should not find old cached blueprint");
result.LookupMethod.Should().Be("displayName");
result.RequiresPersistence.Should().BeFalse("no blueprint found means nothing to persist");
}
}
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
// Copyright (c) Microsoft Corporation.
// Licensed under the MIT License.

// Copyright (c) Microsoft Corporation.
// Licensed under the MIT License.
using FluentAssertions;
using Microsoft.Agents.A365.DevTools.Cli.Services;
using Microsoft.Extensions.Logging;
Expand Down Expand Up @@ -153,4 +153,140 @@ public async Task ExecuteWithStreamingAsync_CapturesOutputInRealTime()
result.Success.Should().BeTrue();
result.StandardOutput.Should().Contain("streaming test");
}

#region Regression Tests for JWT Token Filtering

[Theory]
[InlineData("eyJ0ZXN0IjoidGVzdCJ9.eyJ0ZXN0IjoidGVzdCIsInRlc3QiOiJ0ZXN0IiwidGVzdCI6InRlc3QiLCJ0ZXN0IjoidGVzdCIsInRlc3QiOiJ0ZXN0IiwidGVzdCI6InRlc3QifQ.TEST-SIGNATURE-NOT-REAL-JWT-FOR-UNIT-TESTING-ONLY", true)]
[InlineData(" eyJ0ZXN0IjoidGVzdCJ9.eyJ0ZXN0IjoidGVzdCIsInRlc3QiOiJ0ZXN0IiwidGVzdCI6InRlc3QiLCJ0ZXN0IjoidGVzdCJ9.TEST-SIG-FAKE ", true)]
[InlineData("Regular log message", false)]
[InlineData("", false)]
[InlineData("eyJ.test", false)] // Too short
[InlineData("eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9", false)] // Only one part, not three
[InlineData("This eyJ0eXAi line contains eyJ but is not a token", false)]
public void IsJwtToken_IdentifiesTokensCorrectly(string line, bool expectedResult)
{
// REGRESSION TEST: Verify JWT token detection
// Security fix to prevent tokens from being logged to console
//
// Bug History:
// - Microsoft Graph JWT access tokens were printed to console during 'a365 setup all'
// - Tokens are sensitive credentials that should never be displayed
// - Example leaked token started with: eyJ0eXAiOiJKV1QiLCJub25jZSI6...
//
// Fix: IsJwtToken() method filters JWT tokens from console output
// - Detects tokens by signature: starts with "eyJ", has 2 dots, length > 100
// - Token still captured internally for use, just not displayed

// Use reflection to test the private method
var method = typeof(CommandExecutor).GetMethod("IsJwtToken",
System.Reflection.BindingFlags.NonPublic | System.Reflection.BindingFlags.Static);

var result = (bool)method!.Invoke(null, new object[] { line })!;

result.Should().Be(expectedResult);
}

[Fact]
public void IsJwtToken_WithRealMicrosoftGraphToken_ShouldDetect()
{
// REGRESSION TEST: Verify detection of actual Microsoft Graph JWT token format
// This is the exact format that was being leaked to console

// Arrange - Test JWT with realistic structure but clearly fake content
// Note: This is NOT a real token - it's a test fixture with dummy data
var realToken = "eyJ0ZXN0IjoidGVzdCIsInRlc3QiOiJ0ZXN0In0.eyJ0ZXN0IjoidGVzdCIsInRlc3QiOiJ0ZXN0IiwidGVzdCI6InRlc3QiLCJ0ZXN0IjoidGVzdCIsInRlc3QiOiJ0ZXN0IiwidGVzdCI6InRlc3QiLCJ0ZXN0IjoidGVzdCIsInRlc3QiOiJ0ZXN0IiwidGVzdCI6InRlc3QifQ.FAKE-SIGNATURE-FOR-UNIT-TEST-NOT-A-REAL-JWT-TOKEN-USED-TO-TEST-DETECTION-LOGIC-ONLY";

// Act
var method = typeof(CommandExecutor).GetMethod("IsJwtToken",
System.Reflection.BindingFlags.NonPublic | System.Reflection.BindingFlags.Static);
var result = (bool)method!.Invoke(null, new object[] { realToken })!;

// Assert
result.Should().BeTrue("real Microsoft Graph JWT tokens should be detected and filtered");
}

[Theory]
[InlineData("Acquiring Microsoft Graph delegated access token via PowerShell", false)]
[InlineData("Microsoft Graph access token acquired successfully", false)]
[InlineData("Connect-MgGraph completed successfully", false)]
public void IsJwtToken_WithNormalLogMessages_ShouldNotDetect(string logMessage, bool expectedResult)
{
// REGRESSION TEST: Verify that normal log messages are NOT filtered
// Only JWT tokens should be filtered, not informational messages

// Act
var method = typeof(CommandExecutor).GetMethod("IsJwtToken",
System.Reflection.BindingFlags.NonPublic | System.Reflection.BindingFlags.Static);
var result = (bool)method!.Invoke(null, new object[] { logMessage })!;

// Assert
result.Should().Be(expectedResult, "normal log messages should not be filtered");
}

[Fact]
public void IsJwtToken_WithNullOrEmpty_ShouldReturnFalse()
{
// REGRESSION TEST: Verify null/empty handling

var method = typeof(CommandExecutor).GetMethod("IsJwtToken",
System.Reflection.BindingFlags.NonPublic | System.Reflection.BindingFlags.Static);

// Test null
var nullResult = (bool)method!.Invoke(null, new object?[] { null })!;
nullResult.Should().BeFalse("null should not be detected as token");

// Test empty
var emptyResult = (bool)method!.Invoke(null, new object[] { "" })!;
emptyResult.Should().BeFalse("empty string should not be detected as token");

// Test whitespace
var whitespaceResult = (bool)method!.Invoke(null, new object[] { " " })!;
whitespaceResult.Should().BeFalse("whitespace should not be detected as token");
}

[Fact]
public async Task ExecuteWithStreamingAsync_CapturesTokenButDoesNotPrintIt()
{
// REGRESSION TEST: Verify JWT tokens are captured in StandardOutput but not printed to console
// This is the core security fix - tokens are filtered from console but still available internally

// Arrange - Command that outputs a JWT-like token (fake test token)
var jwtToken = "eyJ0ZXN0IjoidGVzdCJ9.eyJ0ZXN0IjoidGVzdCIsInRlc3QiOiJ0ZXN0IiwidGVzdCI6InRlc3QiLCJ0ZXN0IjoidGVzdCJ9.FAKE-TEST-SIGNATURE-FOR-UNIT-TESTING-JWT-DETECTION-LOGIC-NOT-A-REAL-TOKEN";

var command = OperatingSystem.IsWindows() ? "cmd.exe" : "echo";
var args = OperatingSystem.IsWindows() ? $"/c echo {jwtToken}" : jwtToken;

// Act
var result = await _executor.ExecuteWithStreamingAsync(command, args);

// Assert
result.Should().NotBeNull();
result.Success.Should().BeTrue();

// Token should be captured in StandardOutput (for internal use)
result.StandardOutput.Should().Contain(jwtToken,
"token should be captured in StandardOutput for internal processing");

// Note: We cannot directly test console output from unit tests, but the IsJwtToken tests
// verify that the filtering logic works correctly
}

[Theory]
[InlineData("Short token eyJ.abc.xyz")] // Too short to be JWT
[InlineData("eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkpvaG4gRG9lIiwiaWF0IjoxNTE2MjM5MDIyfQ")] // Only 2 parts
public void IsJwtToken_WithInvalidJwtFormat_ShouldNotDetect(string invalidToken)
{
// REGRESSION TEST: Verify invalid JWT formats are not filtered
// Prevents false positives in filtering

var method = typeof(CommandExecutor).GetMethod("IsJwtToken",
System.Reflection.BindingFlags.NonPublic | System.Reflection.BindingFlags.Static);

var result = (bool)method!.Invoke(null, new object[] { invalidToken })!;

result.Should().BeFalse("invalid JWT formats should not be filtered");
}

#endregion
}