Honor -ErrorAction, fix pipeline-stop noise, and correct certificate request paths
Publish to PowerShell Gallery / build (pull_request) Successful in 43s
Publish to PowerShell Gallery / release (pull_request) Successful in 18s
Publish to PowerShell Gallery / publish (pull_request) Successful in 14s

BREAKING: operation failures are now non-terminating errors, so -ErrorAction
(and $ErrorActionPreference) decides the outcome. try/catch around these cmdlets
now requires -ErrorAction Stop or $ErrorActionPreference = 'Stop'. A failing
pipeline item no longer aborts the batch.

Cmdlets no longer report "The pipeline has been stopped." as an error.
Select-Object -First, and Where-Object feeding it, stop the upstream cmdlet by
design; the shared error path in InfisicalCmdletBase now lets pipeline-control
exceptions propagate untouched instead of logging them and raising an error.

Error-level diagnostics moved off the warning stream to verbose. Every
Logger.Error call site logs and then throws, so the failure already reaches the
caller as an ErrorRecord; emitting it again as eight warning lines put failures
under -WarningAction instead of -ErrorAction. One error per failure now.

Request-InfisicalCertificate:
- -CommonName accepts the RDN form (CN=WEB01) and reduces it to the bare value,
  which previously produced a CN=CN=WEB01 subject plus a bogus DNS SAN.
- -DnsName routes IP literals to iPAddress SAN entries, so the mixed output of
  Get-InfisicalSANList can be splatted in as documented.
- The CA path sends the normalized common name to the signing endpoint.
- The issuance path is resolved and reported before a keypair is generated, and
  a CA with direct issuance disabled fails fast with guidance naming
  -PkiSubscriberSlug and -CertificateProfileId. Infisical exposes no
  template-based issuance route, so no -CertificateTemplateId is added.

Get-InfisicalCertificateAuthority table output gains a DirectIssue column
(EnableDirectIssuance) so CAs eligible for -CertificateAuthorityId are visible.

README, about_PSInfisicalAPI, and cmdlet help document the stream/-ErrorAction
contract, subscriber discovery, and direct-issuance setup. Adds regression tests
for pipeline-stop propagation, logger stream routing, SAN splitting, common-name
normalization, and the non-terminating convention across all cmdlets.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-07-30 17:52:32 -04:00
parent 14c8c4f384
commit f62b3e90b1
65 changed files with 792 additions and 94 deletions
@@ -114,6 +114,170 @@ namespace PSInfisicalAPI.Tests
Assert.Equal("DE", countryProp.GetValue(result));
}
[Theory]
[InlineData("CN=WEB01", "WEB01")]
[InlineData("cn=web01.contoso.local", "web01.contoso.local")]
[InlineData("CN=WEB01,OU=IT,O=Contoso", "WEB01")]
[InlineData(" CN=WEB01 ", "WEB01")]
[InlineData("WEB01.contoso.local", "WEB01.contoso.local")]
[InlineData(null, null)]
public void MergeSubject_Normalizes_Rdn_Style_CommonName(string supplied, string expected)
{
Type helperType = ModuleAssembly.GetType("PSInfisicalAPI.Pki.InfisicalCertificateRequestHelpers", true);
MethodInfo merge = helperType.GetMethod("MergeSubject", BindingFlags.Public | BindingFlags.Static);
object result = merge.Invoke(null, new object[] { null, supplied, null, null, null, null, null, null });
Assert.Equal(expected, result.GetType().GetProperty("CommonName").GetValue(result));
}
[Fact]
public void MergeSubject_Normalizes_CommonName_Supplied_Through_Subject_Hashtable()
{
Type helperType = ModuleAssembly.GetType("PSInfisicalAPI.Pki.InfisicalCertificateRequestHelpers", true);
MethodInfo merge = helperType.GetMethod("MergeSubject", BindingFlags.Public | BindingFlags.Static);
Hashtable subject = new Hashtable { { "CN", "CN=WEB01" } };
object result = merge.Invoke(null, new object[] { subject, null, null, null, null, null, null, null });
Assert.Equal("WEB01", result.GetType().GetProperty("CommonName").GetValue(result));
}
[Fact]
public void BuildDnsNames_Routes_Ip_Literals_From_DnsName_To_IpAddress_Sans()
{
// Get-InfisicalSANList emits host names and IP addresses in one list, and the documented usage
// splats that whole list into -DnsName.
PSInfisicalAPI.Cmdlets.RequestInfisicalCertificateCmdlet cmdlet = new PSInfisicalAPI.Cmdlets.RequestInfisicalCertificateCmdlet
{
DnsName = new[] { "WEB01", "172.16.32.24", "WEB01.contoso.local", "127.0.0.1", "::1" }
};
List<string> ipAddresses = new List<string>();
List<string> dnsNames = InvokeBuildDnsNames(cmdlet, new InfisicalCsrSubject { CommonName = "WEB01" }, ipAddresses);
Assert.Equal(new[] { "WEB01", "WEB01.contoso.local" }, dnsNames);
Assert.Equal(new[] { "172.16.32.24", "127.0.0.1", "::1" }, ipAddresses);
}
[Fact]
public void BuildDnsNames_Merges_Explicit_IpAddress_Parameter_And_Deduplicates()
{
PSInfisicalAPI.Cmdlets.RequestInfisicalCertificateCmdlet cmdlet = new PSInfisicalAPI.Cmdlets.RequestInfisicalCertificateCmdlet
{
DnsName = new[] { "WEB01", "10.0.0.5" },
IpAddress = new[] { "10.0.0.5", "10.0.0.6" }
};
List<string> ipAddresses = new List<string>();
List<string> dnsNames = InvokeBuildDnsNames(cmdlet, new InfisicalCsrSubject { CommonName = "WEB01" }, ipAddresses);
Assert.Equal(new[] { "WEB01" }, dnsNames);
Assert.Equal(new[] { "10.0.0.5", "10.0.0.6" }, ipAddresses);
}
[Fact]
public void BuildDnsNames_Mirrors_Ip_CommonName_Into_IpAddress_Sans_Not_Dns()
{
PSInfisicalAPI.Cmdlets.RequestInfisicalCertificateCmdlet cmdlet = new PSInfisicalAPI.Cmdlets.RequestInfisicalCertificateCmdlet
{
DnsName = new[] { "WEB01.contoso.local" }
};
List<string> ipAddresses = new List<string>();
List<string> dnsNames = InvokeBuildDnsNames(cmdlet, new InfisicalCsrSubject { CommonName = "10.0.0.5" }, ipAddresses);
Assert.Equal(new[] { "WEB01.contoso.local" }, dnsNames);
Assert.Equal(new[] { "10.0.0.5" }, ipAddresses);
}
[Fact]
public void BuildDnsNames_Ip_Only_Request_Does_Not_Pick_Up_Local_Fqdn()
{
PSInfisicalAPI.Cmdlets.RequestInfisicalCertificateCmdlet cmdlet = new PSInfisicalAPI.Cmdlets.RequestInfisicalCertificateCmdlet
{
IpAddress = new[] { "10.0.0.5" }
};
List<string> ipAddresses = new List<string>();
List<string> dnsNames = InvokeBuildDnsNames(cmdlet, new InfisicalCsrSubject { CommonName = "10.0.0.5" }, ipAddresses);
Assert.Empty(dnsNames);
Assert.Equal(new[] { "10.0.0.5" }, ipAddresses);
}
private static List<string> InvokeBuildDnsNames(PSInfisicalAPI.Cmdlets.RequestInfisicalCertificateCmdlet cmdlet, InfisicalCsrSubject subject, List<string> ipAddresses)
{
MethodInfo build = cmdlet.GetType().GetMethod("BuildDnsNames", BindingFlags.NonPublic | BindingFlags.Instance);
Assert.NotNull(build);
return (List<string>)build.Invoke(cmdlet, new object[] { subject, ipAddresses });
}
[Fact]
public void DirectIssuance_Guidance_Names_The_Parameters_That_Resolve_It()
{
// Infisical exposes no certificate-template issuance route over REST, so the actionable alternatives
// are enabling direct issuance on the CA, a subscriber, or a profile.
PSInfisicalAPI.Cmdlets.RequestInfisicalCertificateCmdlet cmdlet = new PSInfisicalAPI.Cmdlets.RequestInfisicalCertificateCmdlet
{
CertificateAuthorityId = "ca-1234",
ProjectId = "proj-5678"
};
MethodInfo build = cmdlet.GetType().GetMethod("BuildDirectIssuanceGuidance", BindingFlags.NonPublic | BindingFlags.Instance);
Assert.NotNull(build);
string guidance = (string)build.Invoke(cmdlet, new object[] { null });
Assert.Contains("ca-1234", guidance);
Assert.Contains("proj-5678", guidance);
Assert.Contains("-PkiSubscriberSlug", guidance);
Assert.Contains("-CertificateProfileId", guidance);
Assert.Contains("Get-InfisicalPkiSubscriber", guidance);
Assert.Contains("Direct Issuance", guidance);
}
[Fact]
public void DirectIssuance_Guidance_Prefers_The_Ca_Name_When_Known()
{
PSInfisicalAPI.Cmdlets.RequestInfisicalCertificateCmdlet cmdlet = new PSInfisicalAPI.Cmdlets.RequestInfisicalCertificateCmdlet
{
CertificateAuthorityId = "ca-1234",
ProjectId = "proj-5678"
};
PSInfisicalAPI.Models.InfisicalCertificateAuthority ca = new PSInfisicalAPI.Models.InfisicalCertificateAuthority
{
Id = "ca-1234",
Name = "intermediate-ca",
EnableDirectIssuance = false
};
MethodInfo build = cmdlet.GetType().GetMethod("BuildDirectIssuanceGuidance", BindingFlags.NonPublic | BindingFlags.Instance);
string guidance = (string)build.Invoke(cmdlet, new object[] { ca });
Assert.Contains("intermediate-ca", guidance);
Assert.Contains("ca-1234", guidance);
}
[Theory]
[InlineData("WriteErrorForException")]
[InlineData("ThrowTerminatingForException")]
public void Failure_Handlers_Rethrow_Pipeline_Stops_Untouched(string handlerName)
{
// Select-Object -First makes WriteObject throw a PipelineStoppedException-derived type. Reporting it
// as an error surfaces spurious "The pipeline has been stopped." warnings on normal early exits.
PSInfisicalAPI.Cmdlets.GetInfisicalCertificateAuthorityCmdlet cmdlet = new PSInfisicalAPI.Cmdlets.GetInfisicalCertificateAuthorityCmdlet();
MethodInfo method = typeof(PSInfisicalAPI.Cmdlets.InfisicalCmdletBase).GetMethod(handlerName, BindingFlags.NonPublic | BindingFlags.Instance);
Assert.NotNull(method);
PipelineStoppedException stop = new PipelineStoppedException();
TargetInvocationException wrapper = Assert.Throws<TargetInvocationException>(
() => method.Invoke(cmdlet, new object[] { "TestComponent", "TestOperation", stop }));
Assert.Same(stop, wrapper.InnerException);
}
[Fact]
public void SignCertificateBySubscriber_Uses_Pki_Subscribers_Template()
{
@@ -0,0 +1,115 @@
using System;
using System.Collections.Generic;
using System.Reflection;
using PSInfisicalAPI.Logging;
using Xunit;
namespace PSInfisicalAPI.Tests
{
/// <summary>
/// The test project references PowerShellStandard.Library, which cannot host a runspace, so the logger's
/// stream choice is asserted structurally: which Cmdlet.Write* method each level compiles down to.
/// </summary>
public class LoggerStreamRoutingTests
{
[Theory]
[InlineData("Error", "WriteVerbose")]
[InlineData("Warning", "WriteWarning")]
[InlineData("Information", "WriteVerbose")]
[InlineData("Verbose", "WriteVerbose")]
[InlineData("Debug", "WriteDebug")]
public void PSCmdletLogger_Routes_Level_To_Expected_Stream(string levelMethod, string expectedWriteMethod)
{
MethodInfo method = typeof(PSCmdletLogger).GetMethod(levelMethod, BindingFlags.Public | BindingFlags.Instance);
Assert.NotNull(method);
List<string> called = GetCalledMethodNames(method);
Assert.Contains(expectedWriteMethod, called);
}
[Fact]
public void PSCmdletLogger_Error_Does_Not_Write_To_Warning_Stream()
{
// Every Logger.Error call site in this module logs and then throws, so the failure already reaches the
// caller as an ErrorRecord. Duplicating it on the warning stream put failures under -WarningAction
// instead of -ErrorAction and buried the real error under eight lines of noise.
MethodInfo error = typeof(PSCmdletLogger).GetMethod("Error", BindingFlags.Public | BindingFlags.Instance);
List<string> called = GetCalledMethodNames(error);
Assert.DoesNotContain("WriteWarning", called);
Assert.DoesNotContain("WriteError", called);
}
[Fact]
public void No_Cmdlet_Reports_Operation_Failures_As_Terminating_Errors()
{
// Operation failures go through WriteErrorForException so -ErrorAction decides the outcome.
// ThrowTerminatingForException remains available for aborts that ignore -ErrorAction, but no cmdlet
// should be using it for ordinary failures; this pins the convention against drift.
Assembly assembly = typeof(PSInfisicalAPI.Cmdlets.InfisicalCmdletBase).Assembly;
List<string> offenders = new List<string>();
int inspected = 0;
foreach (Type type in assembly.GetTypes())
{
if (!typeof(PSInfisicalAPI.Cmdlets.InfisicalCmdletBase).IsAssignableFrom(type)) { continue; }
if (type == typeof(PSInfisicalAPI.Cmdlets.InfisicalCmdletBase)) { continue; }
inspected++;
foreach (MethodInfo method in type.GetMethods(BindingFlags.Public | BindingFlags.NonPublic | BindingFlags.Instance | BindingFlags.DeclaredOnly))
{
if (method.GetMethodBody() == null) { continue; }
if (GetCalledMethodNames(method).Contains("ThrowTerminatingForException"))
{
offenders.Add(string.Concat(type.Name, ".", method.Name));
}
}
}
Assert.True(inspected > 40, string.Concat("Expected to inspect the cmdlet set, saw ", inspected.ToString()));
Assert.Empty(offenders);
}
[Fact]
public void Cmdlets_Route_Failures_Through_WriteErrorForException()
{
Assembly assembly = typeof(PSInfisicalAPI.Cmdlets.InfisicalCmdletBase).Assembly;
Type cmdletType = assembly.GetType("PSInfisicalAPI.Cmdlets.GetInfisicalCertificateAuthorityCmdlet", true);
MethodInfo processRecord = cmdletType.GetMethod("ProcessRecord", BindingFlags.NonPublic | BindingFlags.Instance | BindingFlags.DeclaredOnly);
Assert.NotNull(processRecord);
Assert.Contains("WriteErrorForException", GetCalledMethodNames(processRecord));
}
private static List<string> GetCalledMethodNames(MethodInfo method)
{
List<string> names = new List<string>();
MethodBody body = method.GetMethodBody();
Assert.NotNull(body);
byte[] il = body.GetILAsByteArray();
Assert.NotNull(il);
const byte Call = 0x28;
const byte CallVirt = 0x6F;
for (int i = 0; i + 4 < il.Length; i++)
{
if (il[i] != Call && il[i] != CallVirt) { continue; }
int token = BitConverter.ToInt32(il, i + 1);
try
{
MethodBase resolved = method.Module.ResolveMethod(token);
if (resolved != null) { names.Add(resolved.Name); }
}
catch (ArgumentException)
{
// Byte sequence was operand data rather than an opcode; ignore.
}
}
return names;
}
}
}
@@ -205,7 +205,7 @@ namespace PSInfisicalAPI.Cmdlets
}
catch (Exception exception)
{
ThrowTerminatingForException(Component, "Connect", exception);
WriteErrorForException(Component, "Connect", exception);
}
}
@@ -50,7 +50,7 @@ namespace PSInfisicalAPI.Cmdlets
}
catch (Exception exception)
{
ThrowTerminatingForException("ConvertToInfisicalCertificateCmdlet", "ConvertToCertificate", exception);
WriteErrorForException("ConvertToInfisicalCertificateCmdlet", "ConvertToCertificate", exception);
}
}
@@ -66,7 +66,7 @@ namespace PSInfisicalAPI.Cmdlets
}
catch (Exception exception)
{
ThrowTerminatingForException("ConvertToInfisicalSecretDictionaryCmdlet", "ConvertToDictionary", exception);
WriteErrorForException("ConvertToInfisicalSecretDictionaryCmdlet", "ConvertToDictionary", exception);
}
}
@@ -65,7 +65,7 @@ namespace PSInfisicalAPI.Cmdlets
}
catch (Exception exception)
{
ThrowTerminatingForException("CopyInfisicalSecretCmdlet", "DuplicateSecrets", exception);
WriteErrorForException("CopyInfisicalSecretCmdlet", "DuplicateSecrets", exception);
}
}
}
@@ -27,7 +27,7 @@ namespace PSInfisicalAPI.Cmdlets
}
catch (Exception exception)
{
ThrowTerminatingForException("DisconnectInfisicalCmdlet", "Disconnect", exception);
WriteErrorForException("DisconnectInfisicalCmdlet", "Disconnect", exception);
}
}
}
@@ -85,7 +85,7 @@ namespace PSInfisicalAPI.Cmdlets
}
catch (Exception exception)
{
ThrowTerminatingForException("ExportInfisicalCertificateCmdlet", "ExportCertificate", exception);
WriteErrorForException("ExportInfisicalCertificateCmdlet", "ExportCertificate", exception);
}
}
@@ -58,7 +58,7 @@ namespace PSInfisicalAPI.Cmdlets
}
catch (Exception exception)
{
ThrowTerminatingForException(Component, "ExportScepMdmProfile", exception);
WriteErrorForException(Component, "ExportScepMdmProfile", exception);
}
}
}
@@ -92,7 +92,7 @@ namespace PSInfisicalAPI.Cmdlets
}
catch (Exception exception)
{
ThrowTerminatingForException("ExportInfisicalSecretsCmdlet", string.Concat("Export-", Format.ToString()), exception);
WriteErrorForException("ExportInfisicalSecretsCmdlet", string.Concat("Export-", Format.ToString()), exception);
}
}
@@ -54,7 +54,7 @@ namespace PSInfisicalAPI.Cmdlets
}
catch (Exception exception)
{
ThrowTerminatingForException("GetInfisicalCertificateApplicationCmdlet", "GetCertificateApplication", exception);
WriteErrorForException("GetInfisicalCertificateApplicationCmdlet", "GetCertificateApplication", exception);
}
}
}
@@ -35,7 +35,7 @@ namespace PSInfisicalAPI.Cmdlets
}
catch (Exception exception)
{
ThrowTerminatingForException("GetInfisicalCertificateApplicationEnrollmentCmdlet", "GetCertificateApplicationEnrollment", exception);
WriteErrorForException("GetInfisicalCertificateApplicationEnrollmentCmdlet", "GetCertificateApplicationEnrollment", exception);
}
}
}
@@ -60,7 +60,7 @@ namespace PSInfisicalAPI.Cmdlets
}
catch (Exception exception)
{
ThrowTerminatingForException("GetInfisicalCertificateAuthorityCmdlet", "GetCertificateAuthority", exception);
WriteErrorForException("GetInfisicalCertificateAuthorityCmdlet", "GetCertificateAuthority", exception);
}
}
@@ -134,7 +134,7 @@ namespace PSInfisicalAPI.Cmdlets
}
catch (Exception exception)
{
ThrowTerminatingForException("GetInfisicalCertificateCmdlet", "GetCertificate", exception);
WriteErrorForException("GetInfisicalCertificateCmdlet", "GetCertificate", exception);
}
}
@@ -47,7 +47,7 @@ namespace PSInfisicalAPI.Cmdlets
}
catch (Exception exception)
{
ThrowTerminatingForException("GetInfisicalCertificatePolicyCmdlet", "GetCertificatePolicy", exception);
WriteErrorForException("GetInfisicalCertificatePolicyCmdlet", "GetCertificatePolicy", exception);
}
}
}
@@ -50,7 +50,7 @@ namespace PSInfisicalAPI.Cmdlets
}
catch (Exception exception)
{
ThrowTerminatingForException("GetInfisicalCertificateProfileCmdlet", "GetCertificateProfile", exception);
WriteErrorForException("GetInfisicalCertificateProfileCmdlet", "GetCertificateProfile", exception);
}
}
}
@@ -43,7 +43,7 @@ namespace PSInfisicalAPI.Cmdlets
}
catch (Exception exception)
{
ThrowTerminatingForException("GetInfisicalEnvironmentCmdlet", "GetEnvironment", exception);
WriteErrorForException("GetInfisicalEnvironmentCmdlet", "GetEnvironment", exception);
}
}
}
@@ -45,7 +45,7 @@ namespace PSInfisicalAPI.Cmdlets
}
catch (Exception exception)
{
ThrowTerminatingForException("GetInfisicalFolderCmdlet", "GetFolder", exception);
WriteErrorForException("GetInfisicalFolderCmdlet", "GetFolder", exception);
}
}
}
@@ -41,7 +41,7 @@ namespace PSInfisicalAPI.Cmdlets
}
catch (Exception exception)
{
ThrowTerminatingForException("GetInfisicalOrganizationCmdlet", "GetOrganization", exception);
WriteErrorForException("GetInfisicalOrganizationCmdlet", "GetOrganization", exception);
}
}
}
@@ -43,7 +43,7 @@ namespace PSInfisicalAPI.Cmdlets
}
catch (Exception exception)
{
ThrowTerminatingForException("GetInfisicalPkiSubscriberCmdlet", "GetPkiSubscriber", exception);
WriteErrorForException("GetInfisicalPkiSubscriberCmdlet", "GetPkiSubscriber", exception);
}
}
}
@@ -47,7 +47,7 @@ namespace PSInfisicalAPI.Cmdlets
}
catch (Exception exception)
{
ThrowTerminatingForException("GetInfisicalProjectCmdlet", "GetProject", exception);
WriteErrorForException("GetInfisicalProjectCmdlet", "GetProject", exception);
}
}
}
@@ -82,7 +82,7 @@ namespace PSInfisicalAPI.Cmdlets
}
catch (Exception exception)
{
ThrowTerminatingForException(Component, "GetSANList", exception);
WriteErrorForException(Component, "GetSANList", exception);
}
}
@@ -86,7 +86,7 @@ namespace PSInfisicalAPI.Cmdlets
}
catch (Exception exception)
{
ThrowTerminatingForException(Component, "GetScepMdmProfile", exception);
WriteErrorForException(Component, "GetScepMdmProfile", exception);
}
}
@@ -93,7 +93,7 @@ namespace PSInfisicalAPI.Cmdlets
}
catch (Exception exception)
{
ThrowTerminatingForException("GetInfisicalSecretCmdlet", "GetSecret", exception);
WriteErrorForException("GetInfisicalSecretCmdlet", "GetSecret", exception);
}
}
@@ -53,7 +53,7 @@ namespace PSInfisicalAPI.Cmdlets
}
catch (Exception exception)
{
ThrowTerminatingForException("GetInfisicalSubOrganizationCmdlet", "GetSubOrganization", exception);
WriteErrorForException("GetInfisicalSubOrganizationCmdlet", "GetSubOrganization", exception);
}
}
}
@@ -43,7 +43,7 @@ namespace PSInfisicalAPI.Cmdlets
}
catch (Exception exception)
{
ThrowTerminatingForException("GetInfisicalTagCmdlet", "GetTag", exception);
WriteErrorForException("GetInfisicalTagCmdlet", "GetTag", exception);
}
}
}
@@ -66,7 +66,7 @@ namespace PSInfisicalAPI.Cmdlets
}
catch (Exception exception)
{
ThrowTerminatingForException("ImportInfisicalSecretCmdlet", "ImportSecret", exception);
WriteErrorForException("ImportInfisicalSecretCmdlet", "ImportSecret", exception);
}
}
@@ -1,5 +1,6 @@
using System;
using System.Management.Automation;
using System.Runtime.ExceptionServices;
using PSInfisicalAPI.Connections;
using PSInfisicalAPI.Errors;
using PSInfisicalAPI.Http;
@@ -44,12 +45,54 @@ namespace PSInfisicalAPI.Cmdlets
return current != null && current.SkipCertificateCheck;
}
/// <summary>
/// Reports an operation failure as a non-terminating error, which is what lets -ErrorAction decide the
/// outcome: Continue prints and carries on, SilentlyContinue and Ignore suppress, Inquire prompts, and
/// Stop is promoted by the engine into a terminating error that try/catch sees. Scripts that want to
/// catch these must ask for it with -ErrorAction Stop or $ErrorActionPreference = 'Stop'.
/// </summary>
protected void WriteErrorForException(string component, string operation, Exception exception)
{
ErrorRecord record = BuildFailureRecord(component, operation, exception);
WriteError(record);
}
/// <summary>
/// Reports a failure the cmdlet cannot continue past regardless of -ErrorAction. Reserved for aborts that
/// are not per-item failures; ordinary operation failures belong on <see cref="WriteErrorForException"/>.
/// </summary>
protected void ThrowTerminatingForException(string component, string operation, Exception exception)
{
ErrorRecord record = BuildFailureRecord(component, operation, exception);
ThrowTerminatingError(record);
}
private ErrorRecord BuildFailureRecord(string component, string operation, Exception exception)
{
if (IsPipelineControlException(exception))
{
ExceptionDispatchInfo.Capture(exception).Throw();
}
InfisicalErrorDetails details = InfisicalErrorHandler.BuildDetails(component, operation, exception);
InfisicalErrorHandler.LogFailure(Logger, details);
ErrorRecord record = InfisicalErrorHandler.ToErrorRecord(exception, details);
ThrowTerminatingError(record);
return InfisicalErrorHandler.ToErrorRecord(exception, details);
}
/// <summary>
/// Identifies exceptions the PowerShell engine uses to unwind a pipeline rather than to report a fault.
/// Downstream cmdlets that stop early (<c>Select-Object -First</c>, <c>Where-Object</c> feeding such a
/// cmdlet, Ctrl+C) make <see cref="System.Management.Automation.Cmdlet.WriteObject(object)"/> throw one of
/// these. Reporting them as errors turns a normal early exit into spurious "The pipeline has been stopped."
/// output, so they must propagate untouched.
/// </summary>
protected static bool IsPipelineControlException(Exception exception)
{
// StopUpstreamCommandsException (internal, thrown by Select-Object -First) derives from
// PipelineStoppedException, so the base type covers it.
return exception is PipelineStoppedException
|| exception is PipelineClosedException
|| exception is HaltCommandException;
}
protected string ResolveApiVersion(InfisicalConnection connection, string explicitValue)
@@ -56,7 +56,7 @@ namespace PSInfisicalAPI.Cmdlets
}
catch (Exception exception)
{
ThrowTerminatingForException("InstallInfisicalCertificateCmdlet", "InstallCertificate", exception);
WriteErrorForException("InstallInfisicalCertificateCmdlet", "InstallCertificate", exception);
}
}
@@ -34,7 +34,7 @@ namespace PSInfisicalAPI.Cmdlets
}
catch (Exception exception)
{
ThrowTerminatingForException("NewInfisicalEnvironmentCmdlet", "CreateEnvironment", exception);
WriteErrorForException("NewInfisicalEnvironmentCmdlet", "CreateEnvironment", exception);
}
}
}
@@ -34,7 +34,7 @@ namespace PSInfisicalAPI.Cmdlets
}
catch (Exception exception)
{
ThrowTerminatingForException("NewInfisicalFolderCmdlet", "CreateFolder", exception);
WriteErrorForException("NewInfisicalFolderCmdlet", "CreateFolder", exception);
}
}
}
@@ -32,7 +32,7 @@ namespace PSInfisicalAPI.Cmdlets
}
catch (Exception exception)
{
ThrowTerminatingForException("NewInfisicalOrganizationCmdlet", "CreateOrganization", exception);
WriteErrorForException("NewInfisicalOrganizationCmdlet", "CreateOrganization", exception);
}
}
}
@@ -40,7 +40,7 @@ namespace PSInfisicalAPI.Cmdlets
}
catch (Exception exception)
{
ThrowTerminatingForException("NewInfisicalProjectCmdlet", "CreateProject", exception);
WriteErrorForException("NewInfisicalProjectCmdlet", "CreateProject", exception);
}
}
}
@@ -42,7 +42,7 @@ namespace PSInfisicalAPI.Cmdlets
}
catch (Exception exception)
{
ThrowTerminatingForException("NewInfisicalScepDynamicChallengeCmdlet", "GenerateScepDynamicChallenge", exception);
WriteErrorForException("NewInfisicalScepDynamicChallengeCmdlet", "GenerateScepDynamicChallenge", exception);
}
}
}
@@ -99,7 +99,7 @@ namespace PSInfisicalAPI.Cmdlets
}
catch (Exception exception)
{
ThrowTerminatingForException("NewInfisicalSecretCmdlet", "CreateSecret", exception);
WriteErrorForException("NewInfisicalSecretCmdlet", "CreateSecret", exception);
}
}
}
@@ -32,7 +32,7 @@ namespace PSInfisicalAPI.Cmdlets
}
catch (Exception exception)
{
ThrowTerminatingForException("NewInfisicalSubOrganizationCmdlet", "CreateSubOrganization", exception);
WriteErrorForException("NewInfisicalSubOrganizationCmdlet", "CreateSubOrganization", exception);
}
}
}
@@ -34,7 +34,7 @@ namespace PSInfisicalAPI.Cmdlets
}
catch (Exception exception)
{
ThrowTerminatingForException("NewInfisicalTagCmdlet", "CreateTag", exception);
WriteErrorForException("NewInfisicalTagCmdlet", "CreateTag", exception);
}
}
}
@@ -35,7 +35,7 @@ namespace PSInfisicalAPI.Cmdlets
}
catch (Exception exception)
{
ThrowTerminatingForException("RemoveInfisicalEnvironmentCmdlet", "DeleteEnvironment", exception);
WriteErrorForException("RemoveInfisicalEnvironmentCmdlet", "DeleteEnvironment", exception);
}
}
}
@@ -37,7 +37,7 @@ namespace PSInfisicalAPI.Cmdlets
}
catch (Exception exception)
{
ThrowTerminatingForException("RemoveInfisicalFolderCmdlet", "DeleteFolder", exception);
WriteErrorForException("RemoveInfisicalFolderCmdlet", "DeleteFolder", exception);
}
}
}
@@ -35,7 +35,7 @@ namespace PSInfisicalAPI.Cmdlets
}
catch (Exception exception)
{
ThrowTerminatingForException("RemoveInfisicalOrganizationCmdlet", "DeleteOrganization", exception);
WriteErrorForException("RemoveInfisicalOrganizationCmdlet", "DeleteOrganization", exception);
}
}
}
@@ -35,7 +35,7 @@ namespace PSInfisicalAPI.Cmdlets
}
catch (Exception exception)
{
ThrowTerminatingForException("RemoveInfisicalProjectCmdlet", "DeleteProject", exception);
WriteErrorForException("RemoveInfisicalProjectCmdlet", "DeleteProject", exception);
}
}
}
@@ -79,7 +79,7 @@ namespace PSInfisicalAPI.Cmdlets
}
catch (Exception exception)
{
ThrowTerminatingForException("RemoveInfisicalSecretCmdlet", "DeleteSecret", exception);
WriteErrorForException("RemoveInfisicalSecretCmdlet", "DeleteSecret", exception);
}
}
}
@@ -35,7 +35,7 @@ namespace PSInfisicalAPI.Cmdlets
}
catch (Exception exception)
{
ThrowTerminatingForException("RemoveInfisicalSubOrganizationCmdlet", "DeleteSubOrganization", exception);
WriteErrorForException("RemoveInfisicalSubOrganizationCmdlet", "DeleteSubOrganization", exception);
}
}
}
@@ -35,7 +35,7 @@ namespace PSInfisicalAPI.Cmdlets
}
catch (Exception exception)
{
ThrowTerminatingForException("RemoveInfisicalTagCmdlet", "DeleteTag", exception);
WriteErrorForException("RemoveInfisicalTagCmdlet", "DeleteTag", exception);
}
}
}
@@ -4,6 +4,7 @@ using System.Collections.Generic;
using System.Management.Automation;
using System.Security.Cryptography.X509Certificates;
using PSInfisicalAPI.Connections;
using PSInfisicalAPI.Errors;
using PSInfisicalAPI.Models;
using PSInfisicalAPI.Pki;
@@ -79,7 +80,8 @@ namespace PSInfisicalAPI.Cmdlets
InfisicalPkiClient client = new InfisicalPkiClient(HttpClient, Logger);
InfisicalCsrSubject csrSubject = InfisicalCertificateRequestHelpers.MergeSubject(Subject, CommonName, Country, State, Locality, Organization, OrganizationalUnit, EmailAddress);
List<string> dnsNames = BuildDnsNames(csrSubject);
List<string> ipAddresses = new List<string>();
List<string> dnsNames = BuildDnsNames(csrSubject, ipAddresses);
if (string.IsNullOrEmpty(csrSubject.CommonName) && dnsNames.Count > 0) { csrSubject.CommonName = dnsNames[0]; }
if (string.IsNullOrEmpty(csrSubject.CommonName)) { throw new InvalidOperationException("Subject CommonName could not be determined and no DnsName was provided."); }
@@ -104,6 +106,7 @@ namespace PSInfisicalAPI.Cmdlets
}
catch (Exception bundleException)
{
if (IsPipelineControlException(bundleException)) { throw; }
Logger.Verbose(Component, string.Concat("Infisical bundle fetch for reuse path failed (continuing with local-only chain): ", bundleException.Message));
}
}
@@ -112,12 +115,14 @@ namespace PSInfisicalAPI.Cmdlets
return;
}
string target = string.Concat("PKI subscriber '", PkiSubscriberSlug ?? "(n/a)", "', CA '", CertificateAuthorityId ?? "(n/a)", "', or profile '", CertificateProfileId ?? "(n/a)", "' for CN=", csrSubject.CommonName);
string issuer = ResolveIssuancePath(client, connection);
string target = string.Concat(issuer, " for CN=", csrSubject.CommonName);
if (!ShouldProcess(target, "Request new certificate")) { return; }
InfisicalCsrOptions csrOptions = new InfisicalCsrOptions { KeyAlgorithm = KeyAlgorithm, RsaKeySize = KeySize, EcCurve = Curve };
InfisicalCsrResult csr = InfisicalCsrBuilder.Build(csrSubject, dnsNames, IpAddress, csrOptions);
InfisicalSignedCertificate signed = SignCertificate(client, connection, ProjectId, csr.CsrPem);
InfisicalCsrResult csr = InfisicalCsrBuilder.Build(csrSubject, dnsNames, ipAddresses, csrOptions);
InfisicalSignedCertificate signed = SignCertificate(client, connection, ProjectId, csr.CsrPem, csrSubject);
signed.PrivateKeyPem = csr.PrivateKeyPem;
if (string.IsNullOrEmpty(signed.CertificatePem))
@@ -160,24 +165,123 @@ namespace PSInfisicalAPI.Cmdlets
}
catch (Exception exception)
{
ThrowTerminatingForException(Component, "RequestCertificate", exception);
WriteErrorForException(Component, "RequestCertificate", exception);
}
}
private List<string> BuildDnsNames(InfisicalCsrSubject subject)
/// <summary>
/// States which issuer will sign this request, and rejects an unusable one before a keypair is generated.
/// Direct CA signing is only permitted when the CA has direct issuance enabled; without this check the
/// cmdlet builds a CSR and learns that from a 400 at the very end.
/// </summary>
private string ResolveIssuancePath(InfisicalPkiClient client, InfisicalConnection connection)
{
if (string.Equals(ParameterSetName, "BySubscriber", StringComparison.Ordinal))
{
Logger.Information(Component, string.Concat("Issuing via PKI subscriber '", PkiSubscriberSlug, "' in project '", ProjectId, "'."));
return string.Concat("PKI subscriber '", PkiSubscriberSlug, "'");
}
if (string.Equals(ParameterSetName, "ByProfile", StringComparison.Ordinal))
{
Logger.Information(Component, string.Concat("Issuing via certificate profile '", CertificateProfileId, "' in project '", ProjectId, "'."));
return string.Concat("certificate profile '", CertificateProfileId, "'");
}
InfisicalCertificateAuthority ca = null;
try
{
ca = client.GetInternalCertificateAuthority(connection, CertificateAuthorityId, ProjectId);
}
catch (Exception lookupException)
{
if (IsPipelineControlException(lookupException)) { throw; }
// A caller may be able to sign without permission to read the CA record. Defer to the API.
Logger.Verbose(Component, string.Concat("Could not read certificate authority '", CertificateAuthorityId, "' for preflight (continuing): ", lookupException.Message));
return string.Concat("certificate authority '", CertificateAuthorityId, "'");
}
if (ca != null && ca.EnableDirectIssuance.HasValue && !ca.EnableDirectIssuance.Value)
{
throw new InfisicalConfigurationException(BuildDirectIssuanceGuidance(ca));
}
string caLabel = ca != null ? (ca.Name ?? ca.FriendlyName ?? CertificateAuthorityId) : CertificateAuthorityId;
Logger.Information(Component, string.Concat("Issuing directly via certificate authority '", caLabel, "' (", CertificateAuthorityId, "); direct issuance is enabled."));
return string.Concat("certificate authority '", caLabel, "'");
}
/// <summary>
/// Restates the direct-issuance restriction in terms of the parameters that resolve it. Infisical's REST
/// API exposes no certificate-template issuance route, so the alternatives are a subscriber or a profile.
/// </summary>
private string BuildDirectIssuanceGuidance(InfisicalCertificateAuthority ca)
{
string caLabel = ca != null ? (ca.Name ?? ca.FriendlyName ?? CertificateAuthorityId) : CertificateAuthorityId;
return string.Concat(
"Certificate authority '", caLabel, "' (", CertificateAuthorityId, ") has direct issuance disabled, so it cannot sign a CSR on its own. ",
"Either enable direct issuance on the CA in Infisical (Certificate Authorities > the CA > Enable Direct Issuance), ",
"or issue through a subscriber or profile instead: Request-InfisicalCertificate -PkiSubscriberSlug <name> (see Get-InfisicalPkiSubscriber -ProjectId '", ProjectId ?? "<projectId>", "') ",
"or -CertificateProfileId <id> (see Get-InfisicalCertificateProfile).");
}
/// <summary>
/// Splits the requested SAN values into DNS names and IP addresses. Get-InfisicalSANList emits both kinds
/// in one list, so IP literals arriving through -DnsName are routed to the IP SAN bucket rather than
/// emitted as malformed dNSName entries.
/// </summary>
private List<string> BuildDnsNames(InfisicalCsrSubject subject, List<string> ipAddresses)
{
List<string> result = new List<string>();
if (DnsName != null) { foreach (string dns in DnsName) { if (!string.IsNullOrEmpty(dns)) { result.Add(dns); } } }
if (result.Count == 0)
AddSanCandidates(DnsName, result, ipAddresses);
AddSanCandidates(IpAddress, null, ipAddresses);
// Fall back to the local FQDN only when no SAN of either kind was requested; an explicit IP-only
// request must not silently pick up this machine's name.
if (result.Count == 0 && ipAddresses.Count == 0)
{
string fqdn = InfisicalCertificateRequestHelpers.ResolveLocalFqdn();
if (!string.IsNullOrEmpty(fqdn)) { result.Add(fqdn); }
}
if (!string.IsNullOrEmpty(subject.CommonName) && !result.Contains(subject.CommonName)) { result.Insert(0, subject.CommonName); }
// The common name is mirrored into the SAN list because most validators ignore a CN that has no
// matching SAN entry. An IP common name belongs in the iPAddress bucket, not the dNSName one.
if (!string.IsNullOrEmpty(subject.CommonName))
{
if (IsIpLiteral(subject.CommonName))
{
if (!ipAddresses.Contains(subject.CommonName)) { ipAddresses.Insert(0, subject.CommonName); }
}
else if (!result.Contains(subject.CommonName))
{
result.Insert(0, subject.CommonName);
}
}
return result;
}
private static void AddSanCandidates(IEnumerable<string> candidates, List<string> dnsNames, List<string> ipAddresses)
{
if (candidates == null) { return; }
foreach (string candidate in candidates)
{
if (string.IsNullOrEmpty(candidate)) { continue; }
string value = candidate.Trim();
if (value.Length == 0) { continue; }
List<string> bucket = IsIpLiteral(value) ? ipAddresses : dnsNames;
if (bucket != null && !bucket.Contains(value)) { bucket.Add(value); }
}
}
private static bool IsIpLiteral(string value)
{
System.Net.IPAddress parsed;
return System.Net.IPAddress.TryParse(value, out parsed);
}
private X509Certificate2 TryFindExisting(InfisicalPkiClient client, InfisicalConnection connection, string projectId, string commonName)
{
List<string> candidateSerials = new List<string>();
@@ -192,6 +296,7 @@ namespace PSInfisicalAPI.Cmdlets
}
catch (Exception searchException)
{
if (IsPipelineControlException(searchException)) { throw; }
Logger.Verbose(Component, string.Concat("Infisical search for idempotency check failed: ", searchException.Message));
}
@@ -208,7 +313,7 @@ namespace PSInfisicalAPI.Cmdlets
return InfisicalCertificateRequestHelpers.ResolveKeyStorageFlags(PrivateKeyProtection, PersistKey.IsPresent, MachineKey.IsPresent);
}
private InfisicalSignedCertificate SignCertificate(InfisicalPkiClient client, InfisicalConnection connection, string projectId, string csrPem)
private InfisicalSignedCertificate SignCertificate(InfisicalPkiClient client, InfisicalConnection connection, string projectId, string csrPem, InfisicalCsrSubject subject)
{
if (string.Equals(ParameterSetName, "BySubscriber", StringComparison.Ordinal))
{
@@ -217,11 +322,49 @@ namespace PSInfisicalAPI.Cmdlets
if (string.Equals(ParameterSetName, "ByProfile", StringComparison.Ordinal))
{
InfisicalCsrSubject subject = InfisicalCertificateRequestHelpers.MergeSubject(Subject, CommonName, Country, State, Locality, Organization, OrganizationalUnit, EmailAddress);
return client.IssueCertificateByProfile(connection, CertificateProfileId, csrPem, subject.CommonName, subject.Organization, subject.OrganizationalUnit, subject.Country, subject.State, subject.Locality, Ttl, NotBefore, NotAfter, KeyUsage, ExtendedKeyUsage);
}
return client.SignCertificateByCa(connection, CertificateAuthorityId, csrPem, CommonName, null, Ttl, NotBefore, NotAfter, FriendlyName, PkiCollectionId, KeyUsage, ExtendedKeyUsage);
try
{
return client.SignCertificateByCa(connection, CertificateAuthorityId, csrPem, subject.CommonName, null, Ttl, NotBefore, NotAfter, FriendlyName, PkiCollectionId, KeyUsage, ExtendedKeyUsage);
}
catch (InfisicalApiException apiException)
{
throw EnrichDirectIssuanceFailure(apiException);
}
}
/// <summary>
/// Backstop for when the preflight in <see cref="ResolveIssuancePath"/> could not read the CA record and
/// the API rejects the signing request instead. The raw 400 does not say which cmdlet parameter to reach
/// for, so restate it in the module's own terms.
/// </summary>
private InfisicalApiException EnrichDirectIssuanceFailure(InfisicalApiException apiException)
{
if (apiException == null || apiException.StatusCode != 400) { return apiException; }
string apiMessage = apiException.ApiErrorMessage ?? apiException.Message ?? string.Empty;
if (apiMessage.IndexOf("template or subscriber", StringComparison.OrdinalIgnoreCase) < 0)
{
return apiException;
}
string guidance = string.Concat(BuildDirectIssuanceGuidance(null), " Original API error: ", apiMessage);
return new InfisicalApiException(guidance, apiException)
{
StatusCode = apiException.StatusCode,
ReasonPhrase = apiException.ReasonPhrase,
ApiErrorCode = apiException.ApiErrorCode,
ApiErrorMessage = apiException.ApiErrorMessage,
ApiRequestId = apiException.ApiRequestId,
SanitizedBody = apiException.SanitizedBody,
EndpointName = apiException.EndpointName,
RequestMethod = apiException.RequestMethod,
Component = apiException.Component,
Operation = apiException.Operation
};
}
}
}
@@ -155,13 +155,13 @@ namespace PSInfisicalAPI.Cmdlets
string message = string.Concat("Process '", FilePath, "' exited with code ", result.ExitCode.HasValue ? result.ExitCode.Value.ToString() : "<null>", " which is not in the acceptable exit code list.");
InvalidOperationException exception = new InvalidOperationException(message);
ErrorRecord error = new ErrorRecord(exception, "StartInfisicalProcess.UnacceptableExitCode", ErrorCategory.InvalidResult, result);
ThrowTerminatingError(error);
WriteError(error);
}
}
catch (PipelineStoppedException) { throw; }
catch (Exception exception)
{
ThrowTerminatingForException(Component, "StartProcess", exception);
WriteErrorForException(Component, "StartProcess", exception);
}
}
}
@@ -74,7 +74,7 @@ namespace PSInfisicalAPI.Cmdlets
}
catch (Exception exception)
{
ThrowTerminatingForException("UninstallInfisicalCertificateCmdlet", "UninstallCertificate", exception);
WriteErrorForException("UninstallInfisicalCertificateCmdlet", "UninstallCertificate", exception);
}
}
@@ -38,7 +38,7 @@ namespace PSInfisicalAPI.Cmdlets
}
catch (Exception exception)
{
ThrowTerminatingForException("UpdateInfisicalEnvironmentCmdlet", "UpdateEnvironment", exception);
WriteErrorForException("UpdateInfisicalEnvironmentCmdlet", "UpdateEnvironment", exception);
}
}
}
@@ -38,7 +38,7 @@ namespace PSInfisicalAPI.Cmdlets
}
catch (Exception exception)
{
ThrowTerminatingForException("UpdateInfisicalFolderCmdlet", "UpdateFolder", exception);
WriteErrorForException("UpdateInfisicalFolderCmdlet", "UpdateFolder", exception);
}
}
}
@@ -37,7 +37,7 @@ namespace PSInfisicalAPI.Cmdlets
}
catch (Exception exception)
{
ThrowTerminatingForException("UpdateInfisicalOrganizationCmdlet", "UpdateOrganization", exception);
WriteErrorForException("UpdateInfisicalOrganizationCmdlet", "UpdateOrganization", exception);
}
}
}
@@ -38,7 +38,7 @@ namespace PSInfisicalAPI.Cmdlets
}
catch (Exception exception)
{
ThrowTerminatingForException("UpdateInfisicalProjectCmdlet", "UpdateProject", exception);
WriteErrorForException("UpdateInfisicalProjectCmdlet", "UpdateProject", exception);
}
}
}
@@ -98,7 +98,7 @@ namespace PSInfisicalAPI.Cmdlets
}
catch (Exception exception)
{
ThrowTerminatingForException("UpdateInfisicalSecretCmdlet", "UpdateSecret", exception);
WriteErrorForException("UpdateInfisicalSecretCmdlet", "UpdateSecret", exception);
}
}
}
@@ -37,7 +37,7 @@ namespace PSInfisicalAPI.Cmdlets
}
catch (Exception exception)
{
ThrowTerminatingForException("UpdateInfisicalSubOrganizationCmdlet", "UpdateSubOrganization", exception);
WriteErrorForException("UpdateInfisicalSubOrganizationCmdlet", "UpdateSubOrganization", exception);
}
}
}
@@ -38,7 +38,7 @@ namespace PSInfisicalAPI.Cmdlets
}
catch (Exception exception)
{
ThrowTerminatingForException("UpdateInfisicalTagCmdlet", "UpdateTag", exception);
WriteErrorForException("UpdateInfisicalTagCmdlet", "UpdateTag", exception);
}
}
}
@@ -62,7 +62,7 @@ namespace PSInfisicalAPI.Cmdlets
}
catch (Exception exception)
{
ThrowTerminatingForException(Component, "WriteScepMdmProfileToWmi", exception);
WriteErrorForException(Component, "WriteScepMdmProfileToWmi", exception);
}
}
+8 -1
View File
@@ -36,10 +36,17 @@ namespace PSInfisicalAPI.Logging
_cmdlet.WriteWarning(line);
}
/// <summary>
/// Error-level lines are diagnostic breadcrumbs: every call site in this module logs one and then throws,
/// so the failure itself always reaches the caller as an ErrorRecord carrying the same detail. Emitting
/// them on the warning stream duplicated that failure eight lines deep and put it under -WarningAction
/// instead of -ErrorAction. They belong on the verbose stream, where -Verbose opts into the trail and the
/// ErrorRecord remains the single authority on what failed.
/// </summary>
public void Error(string component, string message)
{
string line = InfisicalLogFormatter.FormatNow(InfisicalLogLevel.Error, component, message);
_cmdlet.WriteWarning(line);
_cmdlet.WriteVerbose(line);
}
}
}
@@ -32,9 +32,29 @@ namespace PSInfisicalAPI.Pki
if (!string.IsNullOrEmpty(organizationalUnit)) { result.OrganizationalUnit = organizationalUnit; }
if (!string.IsNullOrEmpty(emailAddress)) { result.EmailAddress = emailAddress; }
result.CommonName = NormalizeCommonName(result.CommonName);
return result;
}
/// <summary>
/// Reduces a caller-supplied common name to the bare CN value. Callers commonly pass the RDN form
/// ("CN=HOST") or a full DN ("CN=HOST,OU=IT"); using either verbatim produces a doubled "CN=CN=HOST"
/// subject and a bogus "CN=HOST" DNS SAN, since the CSR builder adds the CN= prefix itself.
/// </summary>
public static string NormalizeCommonName(string commonName)
{
if (string.IsNullOrEmpty(commonName)) { return commonName; }
string value = commonName.Trim();
if (!value.StartsWith("CN=", StringComparison.OrdinalIgnoreCase)) { return value; }
value = value.Substring(3);
int separator = value.IndexOf(',');
if (separator >= 0) { value = value.Substring(0, separator); }
return value.Trim();
}
public static string ResolveLocalFqdn()
{
try