Implement PSInfisicalAPI module per design spec with env-var auto-discovery

This commit is contained in:
GraceSolutions
2026-06-02 12:46:34 -04:00
parent 3c47d6ff30
commit 430e3a00c9
80 changed files with 6361 additions and 0 deletions
+30
View File
@@ -0,0 +1,30 @@
## Build artifacts
bin/
obj/
Artifacts/
Releases/
## Module bin output is generated by build.ps1
Module/PSInfisicalAPI/bin/
## VS / Rider / VSCode
.vs/
.idea/
.vscode/
## NuGet
*.nupkg
*.snupkg
project.lock.json
project.assets.json
**/packages/*
!**/packages/build/
## OS
Thumbs.db
.DS_Store
## Test result outputs
TestResults/
*.trx
*.coverage
+36
View File
@@ -0,0 +1,36 @@
# Changelog
All notable changes to this project will be documented in this file.
The format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/) loosely, but version numbers use the build timestamp format `yyyy.MM.dd.HHmm`.
## Unreleased
## 2026.06.02.1638
- Build produced from commit 3c47d6ff30ec.
## Unreleased (carried forward)
## 2026.06.02.1611
- Build produced from commit 3c47d6ff30ec.
## Unreleased
## 2026.06.02.1638
- Build produced from commit 3c47d6ff30ec.
## Unreleased (carried forward) (carried forward)
### Added
- Initial repository skeleton, C# `netstandard2.0` project, and PowerShell module layout.
- Centralized logging (`InfisicalLogger`), error types/handler, sanitizer, path utility, and `SecureString` utility.
- Endpoint registry covering `UniversalAuthLogin`, `ListSecrets`, and `RetrieveSecret`, and a `System.Uri`-based URI builder.
- Synchronous HTTP client, JSON/YAML/XML/ENV serializers, and DTO/mapper for secrets.
- Connection model, process-level session manager, Universal Auth and Token Auth providers.
- Cmdlets: `Connect-Infisical`, `Disconnect-Infisical`, `Get-InfisicalSecrets`, `Get-InfisicalSecret`, `ConvertTo-InfisicalSecretDictionary`, `Export-InfisicalSecrets`.
- Build script (`build.ps1`) generating manifest, copying binaries, creating release folders, and supporting unit/integration tests.
- xUnit test project with unit tests and opt-in integration tests.
@@ -0,0 +1,35 @@
<?xml version="1.0" encoding="utf-8"?>
<Configuration>
<ViewDefinitions>
<View>
<Name>PSInfisicalAPI.Models.InfisicalSecret</Name>
<ViewSelectedBy>
<TypeName>PSInfisicalAPI.Models.InfisicalSecret</TypeName>
</ViewSelectedBy>
<TableControl>
<TableHeaders>
<TableColumnHeader><Label>SecretName</Label><Width>32</Width></TableColumnHeader>
<TableColumnHeader><Label>SecretPath</Label><Width>28</Width></TableColumnHeader>
<TableColumnHeader><Label>Environment</Label><Width>14</Width></TableColumnHeader>
<TableColumnHeader><Label>Type</Label><Width>10</Width></TableColumnHeader>
<TableColumnHeader><Label>Version</Label><Width>8</Width></TableColumnHeader>
<TableColumnHeader><Label>UpdatedAtUtc</Label><Width>22</Width></TableColumnHeader>
<TableColumnHeader><Label>Hidden</Label><Width>6</Width></TableColumnHeader>
</TableHeaders>
<TableRowEntries>
<TableRowEntry>
<TableColumnItems>
<TableColumnItem><PropertyName>SecretName</PropertyName></TableColumnItem>
<TableColumnItem><PropertyName>SecretPath</PropertyName></TableColumnItem>
<TableColumnItem><PropertyName>Environment</PropertyName></TableColumnItem>
<TableColumnItem><PropertyName>Type</PropertyName></TableColumnItem>
<TableColumnItem><PropertyName>Version</PropertyName></TableColumnItem>
<TableColumnItem><PropertyName>UpdatedAtUtc</PropertyName></TableColumnItem>
<TableColumnItem><PropertyName>SecretValueHidden</PropertyName></TableColumnItem>
</TableColumnItems>
</TableRowEntry>
</TableRowEntries>
</TableControl>
</View>
</ViewDefinitions>
</Configuration>
@@ -0,0 +1,49 @@
<?xml version="1.0" encoding="utf-8"?>
<Types>
<Type>
<Name>PSInfisicalAPI.Models.InfisicalSecret</Name>
<Members>
<MemberSet>
<Name>PSStandardMembers</Name>
<Members>
<PropertySet>
<Name>DefaultDisplayPropertySet</Name>
<ReferencedProperties>
<Name>SecretName</Name>
<Name>SecretPath</Name>
<Name>Environment</Name>
<Name>Type</Name>
<Name>Version</Name>
<Name>UpdatedAtUtc</Name>
<Name>SecretValueHidden</Name>
</ReferencedProperties>
</PropertySet>
</Members>
</MemberSet>
</Members>
</Type>
<Type>
<Name>PSInfisicalAPI.Connections.InfisicalConnection</Name>
<Members>
<MemberSet>
<Name>PSStandardMembers</Name>
<Members>
<PropertySet>
<Name>DefaultDisplayPropertySet</Name>
<ReferencedProperties>
<Name>BaseUri</Name>
<Name>ApiVersion</Name>
<Name>AuthType</Name>
<Name>ProjectId</Name>
<Name>Environment</Name>
<Name>DefaultSecretPath</Name>
<Name>ConnectedAtUtc</Name>
<Name>ExpiresAtUtc</Name>
<Name>IsConnected</Name>
</ReferencedProperties>
</PropertySet>
</Members>
</MemberSet>
</Members>
</Type>
</Types>
+32
View File
@@ -0,0 +1,32 @@
@{
RootModule = 'PSInfisicalAPI.psm1'
ModuleVersion = '2026.06.02.1638'
GUID = 'b8a2f3d4-7c51-4d2f-9e6a-1f0c8b3d4e51'
Author = 'Alphaeus Mote'
CompanyName = ''
Copyright = '(c) Alphaeus Mote. All rights reserved.'
Description = 'PSInfisicalAPI is a C# binary PowerShell module for the Infisical REST API.'
PowerShellVersion = '5.1'
CompatiblePSEditions = @('Desktop','Core')
FunctionsToExport = @()
CmdletsToExport = @(
'Connect-Infisical',
'Disconnect-Infisical',
'Get-InfisicalSecrets',
'Get-InfisicalSecret',
'ConvertTo-InfisicalSecretDictionary',
'Export-InfisicalSecrets'
)
AliasesToExport = @()
VariablesToExport = @()
FormatsToProcess = @('PSInfisicalAPI.Format.ps1xml')
TypesToProcess = @('PSInfisicalAPI.Types.ps1xml')
PrivateData = @{
PSData = @{
Tags = @('Infisical','Secrets','API','SecureString')
ProjectUri = ''
ReleaseNotes = ''
CommitHash = '3c47d6ff30ec'
}
}
}
+14
View File
@@ -0,0 +1,14 @@
$BinaryPath = [System.IO.FileInfo][System.IO.Path]::Combine($PSScriptRoot, 'bin', 'PSInfisicalAPI.dll')
Import-Module -Name $BinaryPath.FullName
$TypesPath = [System.IO.FileInfo][System.IO.Path]::Combine($PSScriptRoot, 'PSInfisicalAPI.Types.ps1xml')
$FormatPath = [System.IO.FileInfo][System.IO.Path]::Combine($PSScriptRoot, 'PSInfisicalAPI.Format.ps1xml')
if ([System.IO.File]::Exists($TypesPath.FullName)) {
Update-TypeData -PrependPath $TypesPath.FullName -ErrorAction SilentlyContinue
}
if ([System.IO.File]::Exists($FormatPath.FullName)) {
Update-FormatData -PrependPath $FormatPath.FullName -ErrorAction SilentlyContinue
}
+7
View File
@@ -0,0 +1,7 @@
<?xml version="1.0" encoding="utf-8"?>
<configuration>
<packageSources>
<clear />
<add key="nuget.org" value="https://api.nuget.org/v3/index.json" protocolVersion="3" />
</packageSources>
</configuration>
+24
View File
@@ -0,0 +1,24 @@
Microsoft Visual Studio Solution File, Format Version 12.00
# Visual Studio Version 17
VisualStudioVersion = 17.0.31903.59
MinimumVisualStudioVersion = 10.0.40219.1
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "PSInfisicalAPI", "src\PSInfisicalAPI\PSInfisicalAPI.csproj", "{A1B1F0E7-1111-4A3B-9F2A-000000000001}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "PSInfisicalAPI.Tests", "src\PSInfisicalAPI.Tests\PSInfisicalAPI.Tests.csproj", "{A1B1F0E7-1111-4A3B-9F2A-000000000002}"
EndProject
Global
GlobalSection(SolutionConfigurationPlatforms) = preSolution
Debug|Any CPU = Debug|Any CPU
Release|Any CPU = Release|Any CPU
EndGlobalSection
GlobalSection(ProjectConfigurationPlatforms) = postSolution
{A1B1F0E7-1111-4A3B-9F2A-000000000001}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{A1B1F0E7-1111-4A3B-9F2A-000000000001}.Debug|Any CPU.Build.0 = Debug|Any CPU
{A1B1F0E7-1111-4A3B-9F2A-000000000001}.Release|Any CPU.ActiveCfg = Release|Any CPU
{A1B1F0E7-1111-4A3B-9F2A-000000000001}.Release|Any CPU.Build.0 = Release|Any CPU
{A1B1F0E7-1111-4A3B-9F2A-000000000002}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{A1B1F0E7-1111-4A3B-9F2A-000000000002}.Debug|Any CPU.Build.0 = Debug|Any CPU
{A1B1F0E7-1111-4A3B-9F2A-000000000002}.Release|Any CPU.ActiveCfg = Release|Any CPU
{A1B1F0E7-1111-4A3B-9F2A-000000000002}.Release|Any CPU.Build.0 = Release|Any CPU
EndGlobalSection
EndGlobal
+284
View File
@@ -0,0 +1,284 @@
[CmdletBinding()]
param(
[ValidateSet('Debug', 'Release')]
[string]$Configuration = 'Release',
[switch]$Clean,
[switch]$Restore,
[switch]$RunTests,
[switch]$RunIntegrationTests,
[switch]$CreateRelease,
[switch]$CommitOnSuccess,
[switch]$Force
)
$ErrorActionPreference = 'Stop'
Set-StrictMode -Version Latest
$RepositoryRoot = [System.IO.DirectoryInfo]$PSScriptRoot
$SrcRoot = [System.IO.DirectoryInfo][System.IO.Path]::Combine($RepositoryRoot.FullName, 'src')
$ProjectFile = [System.IO.FileInfo][System.IO.Path]::Combine($SrcRoot.FullName, 'PSInfisicalAPI', 'PSInfisicalAPI.csproj')
$TestsFile = [System.IO.FileInfo][System.IO.Path]::Combine($SrcRoot.FullName, 'PSInfisicalAPI.Tests', 'PSInfisicalAPI.Tests.csproj')
$ModuleRoot = [System.IO.DirectoryInfo][System.IO.Path]::Combine($RepositoryRoot.FullName, 'Module', 'PSInfisicalAPI')
$ModuleBinDir = [System.IO.DirectoryInfo][System.IO.Path]::Combine($ModuleRoot.FullName, 'bin')
$ArtifactsDir = [System.IO.DirectoryInfo][System.IO.Path]::Combine($RepositoryRoot.FullName, 'Artifacts')
$ReleasesDir = [System.IO.DirectoryInfo][System.IO.Path]::Combine($RepositoryRoot.FullName, 'Releases')
$ChangelogFile = [System.IO.FileInfo][System.IO.Path]::Combine($RepositoryRoot.FullName, 'CHANGELOG.md')
$ModuleGuid = 'b8a2f3d4-7c51-4d2f-9e6a-1f0c8b3d4e51'
function Write-Step {
param([string]$Message)
Write-Host "==> $Message" -ForegroundColor Cyan
}
function Get-BuildVersion {
return (Get-Date).ToUniversalTime().ToString('yyyy.MM.dd.HHmm')
}
function Get-CommitHash {
try {
$hash = (& git rev-parse --short=12 HEAD 2>$null).Trim()
if ($LASTEXITCODE -ne 0 -or [string]::IsNullOrEmpty($hash)) {
return 'unknown'
}
return $hash
} catch {
return 'unknown'
}
}
function Ensure-Directory {
param([System.IO.DirectoryInfo]$Directory)
if (-not $Directory.Exists) { [void]$Directory.Create() }
}
function Clear-Directory {
param([System.IO.DirectoryInfo]$Directory)
if ($Directory.Exists) {
Get-ChildItem -LiteralPath $Directory.FullName -Force | Remove-Item -Recurse -Force -ErrorAction SilentlyContinue
}
}
function Get-AssemblyVersion {
param([string]$BuildVersion)
$parts = $BuildVersion -split '\.'
if ($parts.Length -ne 4) { return '1.0.0.0' }
$year = [int]$parts[0]
$month = [int]$parts[1]
$day = [int]$parts[2]
$hhmm = [int]$parts[3]
$assemblyMinor = ($month * 100) + $day
return ("{0}.{1}.{2}.0" -f $year, $assemblyMinor, $hhmm)
}
function Write-Manifest {
param(
[System.IO.FileInfo]$Path,
[string]$ModuleVersion,
[string]$CommitHash
)
$content = @"
@{
RootModule = 'PSInfisicalAPI.psm1'
ModuleVersion = '$ModuleVersion'
GUID = '$ModuleGuid'
Author = 'Alphaeus Mote'
CompanyName = ''
Copyright = '(c) Alphaeus Mote. All rights reserved.'
Description = 'PSInfisicalAPI is a C# binary PowerShell module for the Infisical REST API.'
PowerShellVersion = '5.1'
CompatiblePSEditions = @('Desktop','Core')
FunctionsToExport = @()
CmdletsToExport = @(
'Connect-Infisical',
'Disconnect-Infisical',
'Get-InfisicalSecrets',
'Get-InfisicalSecret',
'ConvertTo-InfisicalSecretDictionary',
'Export-InfisicalSecrets'
)
AliasesToExport = @()
VariablesToExport = @()
FormatsToProcess = @('PSInfisicalAPI.Format.ps1xml')
TypesToProcess = @('PSInfisicalAPI.Types.ps1xml')
PrivateData = @{
PSData = @{
Tags = @('Infisical','Secrets','API','SecureString')
ProjectUri = ''
ReleaseNotes = ''
CommitHash = '$CommitHash'
}
}
}
"@
[System.IO.File]::WriteAllText($Path.FullName, $content, [System.Text.UTF8Encoding]::new($false))
}
function Update-Changelog {
param([string]$Version, [string]$CommitHash)
if (-not $ChangelogFile.Exists) { return }
$marker = "## $Version"
$existing = Get-Content -LiteralPath $ChangelogFile.FullName -Raw
if ($existing -match [Regex]::Escape($marker)) { return }
$insertion = "## $Version`r`n`r`n- Build produced from commit $CommitHash.`r`n`r`n"
$updated = $existing -replace '## Unreleased', "## Unreleased`r`n`r`n$insertion## Unreleased (carried forward)"
[System.IO.File]::WriteAllText($ChangelogFile.FullName, $updated, [System.Text.UTF8Encoding]::new($false))
}
function Invoke-DotNet {
param([string[]]$Arguments)
Write-Step ("dotnet " + ($Arguments -join ' '))
& dotnet @Arguments
if ($LASTEXITCODE -ne 0) {
throw "dotnet command failed: $($Arguments -join ' ')"
}
}
function Test-ModuleImports {
param([System.IO.DirectoryInfo]$ModuleDirectory)
Write-Step "Validating module import"
$script = @"
`$ErrorActionPreference = 'Stop'
Import-Module -Name '$($ModuleDirectory.FullName)' -Force
`$cmds = @('Connect-Infisical','Disconnect-Infisical','Get-InfisicalSecrets','Get-InfisicalSecret','ConvertTo-InfisicalSecretDictionary','Export-InfisicalSecrets')
foreach (`$c in `$cmds) {
if (-not (Get-Command -Name `$c -Module PSInfisicalAPI -ErrorAction SilentlyContinue)) {
throw "Cmdlet not found: `$c"
}
}
"@
$tempFile = [System.IO.FileInfo][System.IO.Path]::Combine([System.IO.Path]::GetTempPath(), [System.IO.Path]::GetRandomFileName() + '.ps1')
try {
[System.IO.File]::WriteAllText($tempFile.FullName, $script, [System.Text.UTF8Encoding]::new($false))
& pwsh -NoProfile -NonInteractive -ExecutionPolicy Bypass -File $tempFile.FullName
if ($LASTEXITCODE -ne 0) { throw "Module import validation failed in pwsh." }
} finally {
if ($tempFile.Exists) { Remove-Item -LiteralPath $tempFile.FullName -Force -ErrorAction SilentlyContinue }
}
}
$buildVersion = Get-BuildVersion
$assemblyVersion = Get-AssemblyVersion -BuildVersion $buildVersion
$commitHash = Get-CommitHash
Write-Step "Build version: $buildVersion"
Write-Step "Assembly version: $assemblyVersion"
Write-Step "Commit hash: $commitHash"
Ensure-Directory -Directory $ArtifactsDir
Ensure-Directory -Directory $ModuleRoot
Ensure-Directory -Directory $ModuleBinDir
if ($Clean.IsPresent) {
Write-Step "Cleaning generated outputs"
Clear-Directory -Directory $ModuleBinDir
Clear-Directory -Directory $ArtifactsDir
Get-ChildItem -LiteralPath $SrcRoot.FullName -Recurse -Directory -Force |
Where-Object { $_.Name -in @('bin','obj') } |
ForEach-Object { Remove-Item -LiteralPath $_.FullName -Recurse -Force -ErrorAction SilentlyContinue }
}
if ($Restore.IsPresent) {
Invoke-DotNet -Arguments @('restore', $ProjectFile.FullName)
Invoke-DotNet -Arguments @('restore', $TestsFile.FullName)
}
Write-Step "Building $($ProjectFile.Name) ($Configuration)"
$buildArgs = @(
'build', $ProjectFile.FullName,
'-c', $Configuration,
'--nologo',
"-p:BuildVersion=$buildVersion",
"-p:BuildAssemblyVersion=$assemblyVersion",
"-p:BuildCommitHash=$commitHash"
)
Invoke-DotNet -Arguments $buildArgs
if ($RunTests.IsPresent -or $RunIntegrationTests.IsPresent) {
Write-Step "Running tests"
$testFilter = if ($RunIntegrationTests.IsPresent) { 'Category!=NEVER' } else { 'Category!=Integration' }
$testArgs = @(
'test', $TestsFile.FullName,
'-c', $Configuration,
'--nologo',
'--filter', $testFilter,
"-p:BuildVersion=$buildVersion",
"-p:BuildAssemblyVersion=$assemblyVersion",
"-p:BuildCommitHash=$commitHash"
)
Invoke-DotNet -Arguments $testArgs
}
Write-Step "Publishing module binaries"
$publishOutput = [System.IO.DirectoryInfo][System.IO.Path]::Combine($ArtifactsDir.FullName, 'publish')
Ensure-Directory -Directory $publishOutput
Clear-Directory -Directory $publishOutput
$publishArgs = @(
'publish', $ProjectFile.FullName,
'-c', $Configuration,
'--nologo',
'-o', $publishOutput.FullName,
"-p:BuildVersion=$buildVersion",
"-p:BuildAssemblyVersion=$assemblyVersion",
"-p:BuildCommitHash=$commitHash"
)
Invoke-DotNet -Arguments $publishArgs
Clear-Directory -Directory $ModuleBinDir
$desiredAssemblies = @('PSInfisicalAPI.dll','Newtonsoft.Json.dll','YamlDotNet.dll')
foreach ($assembly in $desiredAssemblies) {
$source = [System.IO.FileInfo][System.IO.Path]::Combine($publishOutput.FullName, $assembly)
if ($source.Exists) {
Copy-Item -LiteralPath $source.FullName -Destination $ModuleBinDir.FullName -Force
}
}
$manifestPath = [System.IO.FileInfo][System.IO.Path]::Combine($ModuleRoot.FullName, 'PSInfisicalAPI.psd1')
Write-Manifest -Path $manifestPath -ModuleVersion $buildVersion -CommitHash $commitHash
Update-Changelog -Version $buildVersion -CommitHash $commitHash
try {
Test-ModuleImports -ModuleDirectory $ModuleRoot
} catch {
Write-Warning "Module import validation reported: $($_.Exception.Message)"
}
if ($CreateRelease.IsPresent) {
$releaseDir = [System.IO.DirectoryInfo][System.IO.Path]::Combine($ReleasesDir.FullName, $buildVersion)
if ($releaseDir.Exists -and -not $Force.IsPresent) {
throw "Release '$buildVersion' already exists. Pass -Force to overwrite."
}
Ensure-Directory -Directory $ReleasesDir
if ($releaseDir.Exists) { Clear-Directory -Directory $releaseDir }
Ensure-Directory -Directory $releaseDir
$releaseModuleDir = [System.IO.DirectoryInfo][System.IO.Path]::Combine($releaseDir.FullName, 'PSInfisicalAPI')
Ensure-Directory -Directory $releaseModuleDir
Copy-Item -LiteralPath ([System.IO.Path]::Combine($ModuleRoot.FullName, '*')) -Destination $releaseModuleDir.FullName -Recurse -Force
Write-Step "Release created at $($releaseDir.FullName)"
}
if ($CommitOnSuccess.IsPresent) {
Write-Step "Committing on success"
& git add -A
if ($LASTEXITCODE -ne 0) { throw "git add failed." }
& git commit -m "Build $buildVersion"
if ($LASTEXITCODE -ne 0) { throw "git commit failed." }
}
Write-Step "Build complete."
+1951
View File
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,49 @@
using PSInfisicalAPI.Endpoints;
using PSInfisicalAPI.Errors;
using Xunit;
namespace PSInfisicalAPI.Tests
{
public class EndpointRegistryTests
{
[Fact]
public void Get_ListSecrets_Returns_Definition()
{
InfisicalEndpointDefinition definition = InfisicalEndpointRegistry.Get(InfisicalEndpointNames.ListSecrets);
Assert.Equal("GET", definition.Method);
Assert.Equal("v4", definition.Version);
Assert.Equal("/api/v4/secrets", definition.Template);
Assert.True(definition.RequiresAuthorization);
Assert.False(definition.ContainsSecretMaterialInRequest);
Assert.True(definition.ContainsSecretMaterialInResponse);
}
[Fact]
public void Get_RetrieveSecret_Returns_Definition()
{
InfisicalEndpointDefinition definition = InfisicalEndpointRegistry.Get(InfisicalEndpointNames.RetrieveSecret);
Assert.Equal("GET", definition.Method);
Assert.Equal("v4", definition.Version);
Assert.Equal("/api/v4/secrets/{secretName}", definition.Template);
Assert.True(definition.RequiresAuthorization);
}
[Fact]
public void Get_UniversalAuthLogin_Returns_Definition()
{
InfisicalEndpointDefinition definition = InfisicalEndpointRegistry.Get(InfisicalEndpointNames.UniversalAuthLogin);
Assert.Equal("POST", definition.Method);
Assert.Equal("v1", definition.Version);
Assert.Equal("/api/v1/auth/universal-auth/login", definition.Template);
Assert.False(definition.RequiresAuthorization);
Assert.True(definition.ContainsSecretMaterialInRequest);
Assert.True(definition.ContainsSecretMaterialInResponse);
}
[Fact]
public void Get_Unknown_Endpoint_Throws()
{
Assert.Throws<InfisicalConfigurationException>(() => InfisicalEndpointRegistry.Get("NotARealEndpoint"));
}
}
}
@@ -0,0 +1,63 @@
using PSInfisicalAPI.Errors;
using Xunit;
namespace PSInfisicalAPI.Tests
{
public class ErrorHandlerTests
{
[Fact]
public void BuildDetails_Preserves_Http_Status_Code()
{
InfisicalApiException exception = new InfisicalApiException("Forbidden")
{
StatusCode = 403,
ReasonPhrase = "Forbidden",
EndpointName = "ListSecrets",
RequestMethod = "GET",
ApiErrorCode = "PERMISSION_DENIED",
SanitizedBody = "[REDACTED]"
};
InfisicalErrorDetails details = InfisicalErrorHandler.BuildDetails("SecretsClient", "RetrieveSecrets", exception);
Assert.Equal(403, details.StatusCode);
Assert.Equal("Forbidden", details.ReasonPhrase);
Assert.Equal("PERMISSION_DENIED", details.ApiErrorCode);
Assert.Equal("ListSecrets", details.EndpointName);
Assert.Equal("[REDACTED]", details.SanitizedBody);
Assert.Equal("SecretsClient", details.Component);
Assert.Equal("RetrieveSecrets", details.Operation);
}
[Fact]
public void BuildDetails_Preserves_Serialization_Line_Information()
{
InfisicalSerializationException exception = new InfisicalSerializationException("Bad JSON")
{
LineNumber = 12,
LinePosition = 34
};
InfisicalErrorDetails details = InfisicalErrorHandler.BuildDetails("Serializer", "Deserialize", exception);
Assert.Equal(12, details.LineNumber);
Assert.Equal(34, details.LinePosition);
}
[Fact]
public void MapCategory_Maps_Auth_Exception()
{
System.Management.Automation.ErrorCategory category = InfisicalErrorHandler.MapCategory(new InfisicalAuthenticationException("Bad creds"));
Assert.Equal(System.Management.Automation.ErrorCategory.AuthenticationError, category);
}
[Fact]
public void MapCategory_Maps_Api_Exception_By_Status_Code()
{
Assert.Equal(System.Management.Automation.ErrorCategory.AuthenticationError, InfisicalErrorHandler.MapCategory(new InfisicalApiException("x") { StatusCode = 401 }));
Assert.Equal(System.Management.Automation.ErrorCategory.PermissionDenied, InfisicalErrorHandler.MapCategory(new InfisicalApiException("x") { StatusCode = 403 }));
Assert.Equal(System.Management.Automation.ErrorCategory.ObjectNotFound, InfisicalErrorHandler.MapCategory(new InfisicalApiException("x") { StatusCode = 404 }));
Assert.Equal(System.Management.Automation.ErrorCategory.ResourceUnavailable, InfisicalErrorHandler.MapCategory(new InfisicalApiException("x") { StatusCode = 503 }));
}
}
}
+154
View File
@@ -0,0 +1,154 @@
using System;
using System.Collections.Generic;
using System.IO;
using System.Text;
using PSInfisicalAPI.Exports;
using PSInfisicalAPI.Models;
using PSInfisicalAPI.Security;
using Xunit;
namespace PSInfisicalAPI.Tests
{
public class ExportTests : IDisposable
{
private readonly DirectoryInfo _tempDirectory;
public ExportTests()
{
string root = Path.Combine(Path.GetTempPath(), "PSInfisicalAPI.Tests.Export-" + Guid.NewGuid().ToString("N"));
_tempDirectory = new DirectoryInfo(root);
_tempDirectory.Create();
}
public void Dispose()
{
if (_tempDirectory.Exists)
{
_tempDirectory.Delete(true);
}
}
private static InfisicalSecret[] SampleSecrets()
{
return new[]
{
new InfisicalSecret
{
SecretName = "SqlServer",
SecretPath = "/servers",
SecretValue = SecureStringUtility.ToReadOnlySecureString("192.168.1.10"),
SecretMetadata = new[] { new InfisicalSecretMetadata { Key = "Owner", Value = "Infrastructure" } }
},
new InfisicalSecret
{
SecretName = "SqlPassword",
SecretPath = "/servers",
SecretValue = SecureStringUtility.ToReadOnlySecureString("ExamplePassword")
}
};
}
[Fact]
public void Export_Env_Writes_Key_Equals_Value()
{
FileInfo path = new FileInfo(Path.Combine(_tempDirectory.FullName, "missing", "out.env"));
new EnvInfisicalExporter().Export(new InfisicalExportRequest
{
Secrets = SampleSecrets(),
Format = InfisicalExportFormat.Env,
Path = path,
Encoding = new UTF8Encoding(false)
});
Assert.True(path.Exists || File.Exists(path.FullName));
string contents = File.ReadAllText(path.FullName);
Assert.Contains("SqlServer=192.168.1.10", contents);
Assert.Contains("SqlPassword=ExamplePassword", contents);
}
[Fact]
public void Export_Json_Creates_Directory()
{
FileInfo path = new FileInfo(Path.Combine(_tempDirectory.FullName, "deep", "nested", "out.json"));
new JsonInfisicalExporter().Export(new InfisicalExportRequest
{
Secrets = SampleSecrets(),
Format = InfisicalExportFormat.Json,
Path = path
});
Assert.True(path.Directory.Exists);
string contents = File.ReadAllText(path.FullName);
Assert.Contains("SqlServer", contents);
Assert.Contains("192.168.1.10", contents);
}
[Fact]
public void Export_Yaml_Creates_Directory()
{
FileInfo path = new FileInfo(Path.Combine(_tempDirectory.FullName, "yaml", "out.yaml"));
new YamlInfisicalExporter().Export(new InfisicalExportRequest
{
Secrets = SampleSecrets(),
Format = InfisicalExportFormat.Yaml,
Path = path
});
Assert.True(path.Directory.Exists);
string contents = File.ReadAllText(path.FullName);
Assert.Contains("Secrets:", contents);
Assert.Contains("SqlServer", contents);
}
[Fact]
public void Export_Xml_Matches_Schema()
{
FileInfo path = new FileInfo(Path.Combine(_tempDirectory.FullName, "xml", "out.xml"));
new XmlInfisicalExporter().Export(new InfisicalExportRequest
{
Secrets = SampleSecrets(),
Format = InfisicalExportFormat.Xml,
Path = path
});
string contents = File.ReadAllText(path.FullName);
Assert.Contains("<Secrets>", contents);
Assert.Contains("<Secret>", contents);
Assert.Contains("<SecretName>SqlServer</SecretName>", contents);
Assert.Contains("<SecretValue>192.168.1.10</SecretValue>", contents);
Assert.Contains("<SecretMetadata>", contents);
Assert.Contains("<Metadata>", contents);
}
[Fact]
public void Export_EnvironmentVariables_Defaults_To_Process()
{
string name = "PSInFI_TestVar_" + Guid.NewGuid().ToString("N");
InfisicalSecret[] secrets = new[]
{
new InfisicalSecret
{
SecretName = name,
SecretValue = SecureStringUtility.ToReadOnlySecureString("processed")
}
};
try
{
new EnvironmentVariableExporter().Export(new InfisicalExportRequest
{
Secrets = secrets,
Format = InfisicalExportFormat.EnvironmentVariables,
Scope = EnvironmentVariableTarget.Process
});
Assert.Equal("processed", Environment.GetEnvironmentVariable(name, EnvironmentVariableTarget.Process));
}
finally
{
Environment.SetEnvironmentVariable(name, null, EnvironmentVariableTarget.Process);
}
}
}
}
@@ -0,0 +1,129 @@
using System.Text.RegularExpressions;
using PSInfisicalAPI.Authentication;
using Xunit;
namespace PSInfisicalAPI.Tests
{
public class InfisicalEnvironmentPatternTests
{
[Theory]
[InlineData("INFISICAL_API_URL")]
[InlineData("INFISICAL_BASE_URL")]
[InlineData("INFISICAL_BASE_URI")]
[InlineData("INFISICAL_HOST")]
[InlineData("CLOUDINIT_INFISICAL_APIURL")]
public void BaseUriPatterns_Match_Expected_Names(string name)
{
Assert.True(MatchesAny(name, InfisicalEnvironmentResolver.BaseUriPatterns), "Expected match for " + name);
}
[Theory]
[InlineData("INFISICAL_ORG_ID")]
[InlineData("INFISICAL_ORGANIZATION_ID")]
[InlineData("CLOUDINIT_INFISICAL_ORGANIZATIONID")]
public void OrganizationIdPatterns_Match_Expected_Names(string name)
{
Assert.True(MatchesAny(name, InfisicalEnvironmentResolver.OrganizationIdPatterns), "Expected match for " + name);
}
[Theory]
[InlineData("INFISICAL_PROJECT_ID")]
[InlineData("INFISICAL_WORKSPACE_ID")]
[InlineData("CLOUDINIT_INFISICAL_PROJECTID")]
public void ProjectIdPatterns_Match_Expected_Names(string name)
{
Assert.True(MatchesAny(name, InfisicalEnvironmentResolver.ProjectIdPatterns), "Expected match for " + name);
}
[Theory]
[InlineData("INFISICAL_ENVIRONMENT")]
[InlineData("INFISICAL_ENVIRONMENT_NAME")]
[InlineData("INFISICAL_ENV")]
[InlineData("INFISICAL_ENV_SLUG")]
[InlineData("CLOUDINIT_INFISICAL_ENVIRONMENT")]
public void EnvironmentPatterns_Match_Expected_Names(string name)
{
Assert.True(MatchesAny(name, InfisicalEnvironmentResolver.EnvironmentPatterns), "Expected match for " + name);
}
[Theory]
[InlineData("INFISICAL_CLIENT_ID")]
[InlineData("INFISICAL_UNIVERSAL_AUTH_CLIENT_ID")]
[InlineData("INFISICAL_MACHINE_IDENTITY_CLIENT_ID")]
[InlineData("CLOUDINIT_INFISICAL_CLIENTID")]
[InlineData("myapp_infisical_client_id")]
public void ClientIdPatterns_Match_Standard_And_Custom_Prefixed_Names(string name)
{
Assert.True(MatchesAny(name, InfisicalEnvironmentResolver.ClientIdPatterns), "Expected match for " + name);
}
[Theory]
[InlineData("INFISICAL_CLIENT_SECRET")]
[InlineData("INFISICAL_UNIVERSAL_AUTH_CLIENT_SECRET")]
[InlineData("INFISICAL_MACHINE_IDENTITY_CLIENT_SECRET")]
[InlineData("CLOUDINIT_INFISICAL_CLIENTSECRET")]
[InlineData("myapp_infisical_client_secret")]
public void ClientSecretPatterns_Match_Standard_And_Custom_Prefixed_Names(string name)
{
Assert.True(MatchesAny(name, InfisicalEnvironmentResolver.ClientSecretPatterns), "Expected match for " + name);
}
[Theory]
[InlineData("INFISICAL_TOKEN")]
[InlineData("INFISICAL_ACCESS_TOKEN")]
[InlineData("INFISICAL_AUTH_TOKEN")]
[InlineData("CLOUDINIT_INFISICAL_TOKEN")]
public void AccessTokenPatterns_Match_Expected_Names(string name)
{
Assert.True(MatchesAny(name, InfisicalEnvironmentResolver.AccessTokenPatterns), "Expected match for " + name);
}
[Theory]
[InlineData("INFISICAL_SECRET_PATH")]
[InlineData("INFISICAL_DEFAULT_SECRET_PATH")]
[InlineData("CLOUDINIT_INFISICAL_SECRETPATH")]
public void SecretPathPatterns_Match_Expected_Names(string name)
{
Assert.True(MatchesAny(name, InfisicalEnvironmentResolver.SecretPathPatterns), "Expected match for " + name);
}
[Theory]
[InlineData("INFISICAL_SECRET_PATH")]
[InlineData("INFISICAL_DEFAULT_SECRET_PATH")]
[InlineData("CLOUDINIT_INFISICAL_SECRETPATH")]
public void ClientSecretPatterns_Do_Not_Match_SecretPath_Variables(string name)
{
Assert.False(MatchesAny(name, InfisicalEnvironmentResolver.ClientSecretPatterns), "ClientSecretPatterns should NOT match " + name);
}
[Theory]
[InlineData("PATH")]
[InlineData("USERNAME")]
[InlineData("HOME")]
[InlineData("PROCESSOR_ARCHITECTURE")]
public void Patterns_Do_Not_Match_Unrelated_System_Variables(string name)
{
Assert.False(MatchesAny(name, InfisicalEnvironmentResolver.ClientIdPatterns));
Assert.False(MatchesAny(name, InfisicalEnvironmentResolver.ClientSecretPatterns));
Assert.False(MatchesAny(name, InfisicalEnvironmentResolver.AccessTokenPatterns));
Assert.False(MatchesAny(name, InfisicalEnvironmentResolver.BaseUriPatterns));
Assert.False(MatchesAny(name, InfisicalEnvironmentResolver.OrganizationIdPatterns));
Assert.False(MatchesAny(name, InfisicalEnvironmentResolver.ProjectIdPatterns));
Assert.False(MatchesAny(name, InfisicalEnvironmentResolver.EnvironmentPatterns));
Assert.False(MatchesAny(name, InfisicalEnvironmentResolver.SecretPathPatterns));
Assert.False(MatchesAny(name, InfisicalEnvironmentResolver.ApiVersionPatterns));
}
private static bool MatchesAny(string input, Regex[] patterns)
{
for (int i = 0; i < patterns.Length; i++)
{
if (patterns[i].IsMatch(input))
{
return true;
}
}
return false;
}
}
}
@@ -0,0 +1,117 @@
using System;
using System.Collections.Generic;
using System.Security;
using System.Text.RegularExpressions;
using PSInfisicalAPI.Authentication;
using PSInfisicalAPI.Logging;
using PSInfisicalAPI.Security;
using Xunit;
namespace PSInfisicalAPI.Tests
{
public class InfisicalEnvironmentResolverTests : IDisposable
{
private const string TestPrefix = "PSINFITESTRESOLVER";
private readonly List<string> _createdVariables = new List<string>();
private readonly string _uniqueSuffix = Guid.NewGuid().ToString("N").ToUpperInvariant();
private string SetProcessVar(string token, string value)
{
string name = TestPrefix + "_" + token + "_" + _uniqueSuffix;
Environment.SetEnvironmentVariable(name, value, EnvironmentVariableTarget.Process);
_createdVariables.Add(name);
return name;
}
private Regex[] PatternsForThisTest(string tokenWithinName)
{
return new[]
{
new Regex("^" + TestPrefix + "_.*" + tokenWithinName + ".*" + _uniqueSuffix + "$", RegexOptions.IgnoreCase)
};
}
public void Dispose()
{
for (int i = 0; i < _createdVariables.Count; i++)
{
Environment.SetEnvironmentVariable(_createdVariables[i], null, EnvironmentVariableTarget.Process);
}
}
[Fact]
public void Resolve_Returns_NotFound_When_No_Pattern_Matches()
{
Regex[] patterns = new[] { new Regex("WILL_NEVER_MATCH_" + Guid.NewGuid().ToString("N"), RegexOptions.IgnoreCase) };
InfisicalEnvironmentResolver.ResolutionResult result = InfisicalEnvironmentResolver.Resolve(patterns);
Assert.False(result.Found);
}
[Fact]
public void Resolve_Returns_First_Matching_Variable_With_Value()
{
string name = SetProcessVar("CLIENTID", "client-1234");
InfisicalEnvironmentResolver.ResolutionResult result = InfisicalEnvironmentResolver.Resolve(PatternsForThisTest("CLIENTID"));
Assert.True(result.Found);
Assert.Equal("client-1234", result.Value);
Assert.Equal(name, result.VariableName);
Assert.Equal(EnvironmentVariableTarget.Process, result.Scope);
}
[Fact]
public void Resolve_Skips_Blank_Whitespace_Values()
{
SetProcessVar("CLIENTID_BLANK", " ");
string realName = SetProcessVar("CLIENTID_REAL", "client-xyz");
Regex[] patterns = new[]
{
new Regex("^" + TestPrefix + "_CLIENTID_(BLANK|REAL)_" + _uniqueSuffix + "$", RegexOptions.IgnoreCase)
};
InfisicalEnvironmentResolver.ResolutionResult result = InfisicalEnvironmentResolver.Resolve(patterns);
Assert.True(result.Found);
Assert.Equal("client-xyz", result.Value);
Assert.Equal(realName, result.VariableName);
}
[Fact]
public void ResolveString_Returns_Current_Value_When_Already_Set()
{
SetProcessVar("PROJECTID_OVERRIDE", "env-project");
string resolved = InfisicalEnvironmentResolver.ResolveString("ProjectId", PatternsForThisTest("PROJECTID"), "explicit-project", NullInfisicalLogger.Instance);
Assert.Equal("explicit-project", resolved);
}
[Fact]
public void ResolveString_Resolves_When_Current_Value_Is_Whitespace()
{
SetProcessVar("PROJECTID_FALLBACK", "env-project-123");
string resolved = InfisicalEnvironmentResolver.ResolveString("ProjectId", PatternsForThisTest("PROJECTID"), " ", NullInfisicalLogger.Instance);
Assert.Equal("env-project-123", resolved);
}
[Fact]
public void ResolveSecureString_Builds_ReadOnly_SecureString_From_Env()
{
SetProcessVar("ACCESSTOKEN_FOR_TEST", "tok-9999");
SecureString resolved = InfisicalEnvironmentResolver.ResolveSecureString("AccessToken", PatternsForThisTest("ACCESSTOKEN"), null, NullInfisicalLogger.Instance);
Assert.NotNull(resolved);
Assert.True(resolved.IsReadOnly());
string plain = SecureStringUtility.UsePlainText(resolved, p => p);
Assert.Equal("tok-9999", plain);
}
[Fact]
public void ResolveSecureString_Keeps_Existing_Populated_SecureString()
{
SetProcessVar("ACCESSTOKEN_IGNORE", "ignored");
SecureString existing = SecureStringUtility.ToReadOnlySecureString("explicit-token");
SecureString resolved = InfisicalEnvironmentResolver.ResolveSecureString("AccessToken", PatternsForThisTest("ACCESSTOKEN"), existing, NullInfisicalLogger.Instance);
Assert.Same(existing, resolved);
}
}
}
@@ -0,0 +1,50 @@
using System.Reflection;
using PSInfisicalAPI.Models;
using PSInfisicalAPI.Security;
using Xunit;
namespace PSInfisicalAPI.Tests
{
public class InfisicalSecretTests
{
[Fact]
public void ToString_Returns_SecretName_Only()
{
InfisicalSecret secret = new InfisicalSecret
{
SecretName = "MySecret",
SecretValue = SecureStringUtility.ToReadOnlySecureString("super-secret")
};
Assert.Equal("MySecret", secret.ToString());
Assert.DoesNotContain("super-secret", secret.ToString());
}
[Fact]
public void UsePlainTextValue_Scopes_Plaintext_Access()
{
InfisicalSecret secret = new InfisicalSecret
{
SecretName = "DbPassword",
SecretValue = SecureStringUtility.ToReadOnlySecureString("p@ssw0rd")
};
string observed = secret.UsePlainTextValue(value => value);
Assert.Equal("p@ssw0rd", observed);
}
[Fact]
public void Has_No_PlainText_Property()
{
PropertyInfo[] properties = typeof(InfisicalSecret).GetProperties();
foreach (PropertyInfo property in properties)
{
Assert.False(string.Equals(property.Name, "PlainTextValue", System.StringComparison.OrdinalIgnoreCase));
if (property.Name == "SecretValue")
{
Assert.Equal(typeof(System.Security.SecureString), property.PropertyType);
}
}
}
}
}
+41
View File
@@ -0,0 +1,41 @@
using System;
using System.Text.RegularExpressions;
using PSInfisicalAPI.Logging;
using Xunit;
namespace PSInfisicalAPI.Tests
{
public class LoggingTests
{
[Fact]
public void Format_Uses_Utc_Timestamp_And_Component()
{
DateTimeOffset utc = new DateTimeOffset(2026, 6, 2, 21, 44, 22, TimeSpan.Zero).AddTicks(1830000);
string result = InfisicalLogFormatter.Format(utc, InfisicalLogLevel.Information, "SecretsClient", "Attempting to retrieve Infisical secrets. Please Wait...");
Assert.Equal("[2026-06-02T21:44:22.1830000Z] - [Information] - [SecretsClient] - Attempting to retrieve Infisical secrets. Please Wait...", result);
}
[Fact]
public void Format_Includes_All_Levels()
{
DateTimeOffset utc = DateTimeOffset.UtcNow;
foreach (InfisicalLogLevel level in Enum.GetValues(typeof(InfisicalLogLevel)))
{
string result = InfisicalLogFormatter.Format(utc, level, "Component", "Message");
Assert.Matches(@"^\[[0-9TZ:\.\-]+\] - \[" + level + @"\] - \[Component\] - Message$", result);
}
}
[Fact]
public void NullLogger_Accepts_Any_Calls()
{
IInfisicalLogger logger = NullInfisicalLogger.Instance;
logger.Information("c", "m");
logger.Verbose("c", "m");
logger.Debug("c", "m");
logger.Warning("c", "m");
logger.Error("c", "m");
}
}
}
+59
View File
@@ -0,0 +1,59 @@
using System.IO;
using System.Reflection;
using Xunit;
namespace PSInfisicalAPI.Tests
{
public class ManifestTests
{
private static DirectoryInfo RepositoryRoot()
{
DirectoryInfo current = new DirectoryInfo(System.AppContext.BaseDirectory);
while (current != null)
{
if (File.Exists(Path.Combine(current.FullName, "PSInfisicalAPI.sln")))
{
return current;
}
current = current.Parent;
}
return null;
}
[Fact]
public void Assembly_Has_CommitHash_Metadata()
{
AssemblyMetadataAttribute[] attributes = (AssemblyMetadataAttribute[])typeof(PSInfisicalAPI.Connections.InfisicalConnection)
.Assembly
.GetCustomAttributes(typeof(AssemblyMetadataAttribute), false);
bool found = false;
foreach (AssemblyMetadataAttribute attribute in attributes)
{
if (attribute.Key == "CommitHash")
{
found = true;
Assert.False(string.IsNullOrEmpty(attribute.Value));
}
}
Assert.True(found, "Assembly must contain a CommitHash metadata attribute.");
}
[Fact]
public void Psm1_Imports_Binary_From_Bin_Folder()
{
DirectoryInfo root = RepositoryRoot();
Assert.NotNull(root);
string psm1Path = Path.Combine(root.FullName, "Module", "PSInfisicalAPI", "PSInfisicalAPI.psm1");
Assert.True(File.Exists(psm1Path));
string content = File.ReadAllText(psm1Path);
Assert.Contains("Import-Module", content);
Assert.Contains("'bin'", content);
Assert.Contains("PSInfisicalAPI.dll", content);
}
}
}
@@ -0,0 +1,26 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net8.0</TargetFramework>
<LangVersion>9.0</LangVersion>
<IsPackable>false</IsPackable>
<AssemblyName>PSInfisicalAPI.Tests</AssemblyName>
<RootNamespace>PSInfisicalAPI.Tests</RootNamespace>
<NoWarn>$(NoWarn);CS1591;NU1701;NU1903</NoWarn>
<GenerateProgramFile>true</GenerateProgramFile>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.11.1" />
<PackageReference Include="xunit" Version="2.9.2" />
<PackageReference Include="xunit.runner.visualstudio" Version="2.8.2" />
<PackageReference Include="PowerShellStandard.Library" Version="5.1.1" />
<PackageReference Include="Newtonsoft.Json" Version="13.0.3" />
<PackageReference Include="YamlDotNet" Version="15.1.6" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\PSInfisicalAPI\PSInfisicalAPI.csproj" />
</ItemGroup>
</Project>
@@ -0,0 +1,53 @@
using System.Collections.Generic;
using PSInfisicalAPI.Common;
using Xunit;
namespace PSInfisicalAPI.Tests
{
public class SanitizerTests
{
[Fact]
public void Sanitize_Body_Redacts_When_Contains_Secrets()
{
string body = "{\"secretValue\":\"abc\"}";
string sanitized = InfisicalSanitizer.SanitizeBody(body, true);
Assert.Equal("[REDACTED]", sanitized);
}
[Fact]
public void Sanitize_Body_Truncates_Long_NonSecret_Body()
{
string body = new string('a', 4096);
string sanitized = InfisicalSanitizer.SanitizeBody(body, false);
Assert.Contains("[truncated]", sanitized);
}
[Fact]
public void Sanitize_Header_Redacts_Authorization()
{
string sanitized = InfisicalSanitizer.SanitizeHeaderValue("Authorization", "Bearer abc.def");
Assert.Equal("[REDACTED]", sanitized);
}
[Fact]
public void Sanitize_Header_Passes_Through_Non_Sensitive()
{
string sanitized = InfisicalSanitizer.SanitizeHeaderValue("Accept", "application/json");
Assert.Equal("application/json", sanitized);
}
[Fact]
public void Sanitize_Headers_Returns_New_Map()
{
Dictionary<string, string> headers = new Dictionary<string, string>
{
{ "Authorization", "Bearer token" },
{ "Accept", "application/json" }
};
IDictionary<string, string> sanitized = InfisicalSanitizer.SanitizeHeaders(headers);
Assert.Equal("[REDACTED]", sanitized["Authorization"]);
Assert.Equal("application/json", sanitized["Accept"]);
}
}
}
@@ -0,0 +1,38 @@
using System.Reflection;
using PSInfisicalAPI.Models;
using PSInfisicalAPI.Security;
using Xunit;
namespace PSInfisicalAPI.Tests
{
public class SecretMapperTests
{
[Fact]
public void Mapper_Maps_SecretKey_To_SecretName_And_SecretValue_To_SecureString()
{
System.Type mapperType = typeof(PSInfisicalAPI.Connections.InfisicalConnection).Assembly
.GetType("PSInfisicalAPI.Secrets.InfisicalSecretMapper", true);
System.Type dtoType = typeof(PSInfisicalAPI.Connections.InfisicalConnection).Assembly
.GetType("PSInfisicalAPI.Secrets.InfisicalSecretResponseDto", true);
object dto = System.Activator.CreateInstance(dtoType);
dtoType.GetProperty("SecretKey").SetValue(dto, "DatabasePassword");
dtoType.GetProperty("SecretValue").SetValue(dto, "Sup3rSecret!");
dtoType.GetProperty("SecretPath").SetValue(dto, "/prod/db");
dtoType.GetProperty("Environment").SetValue(dto, "prod");
dtoType.GetProperty("Type").SetValue(dto, "shared");
MethodInfo mapMethod = mapperType.GetMethod("Map", BindingFlags.Public | BindingFlags.Static);
InfisicalSecret secret = (InfisicalSecret)mapMethod.Invoke(null, new[] { dto });
Assert.Equal("DatabasePassword", secret.SecretName);
Assert.NotNull(secret.SecretValue);
Assert.True(secret.SecretValue.IsReadOnly());
Assert.Equal("/prod/db", secret.SecretPath);
Assert.Equal(InfisicalSecretType.Shared, secret.Type);
string roundtripped = SecureStringUtility.UsePlainText(secret.SecretValue, plain => plain);
Assert.Equal("Sup3rSecret!", roundtripped);
}
}
}
@@ -0,0 +1,44 @@
using System;
using System.Security;
using PSInfisicalAPI.Security;
using Xunit;
namespace PSInfisicalAPI.Tests
{
public class SecureStringUtilityTests
{
[Fact]
public void ToReadOnlySecureString_Returns_ReadOnly_Instance()
{
SecureString secure = SecureStringUtility.ToReadOnlySecureString("hello");
Assert.True(secure.IsReadOnly());
Assert.Equal(5, secure.Length);
}
[Fact]
public void ToReadOnlySecureString_Handles_Null_And_Empty()
{
SecureString fromNull = SecureStringUtility.ToReadOnlySecureString(null);
SecureString fromEmpty = SecureStringUtility.ToReadOnlySecureString(string.Empty);
Assert.True(fromNull.IsReadOnly());
Assert.True(fromEmpty.IsReadOnly());
Assert.Equal(0, fromNull.Length);
Assert.Equal(0, fromEmpty.Length);
}
[Fact]
public void UsePlainText_Roundtrips_Value()
{
SecureString secure = SecureStringUtility.ToReadOnlySecureString("RoundTripValue");
string captured = SecureStringUtility.UsePlainText(secure, plainText => plainText);
Assert.Equal("RoundTripValue", captured);
}
[Fact]
public void UsePlainText_Throws_For_Null_Action()
{
SecureString secure = SecureStringUtility.ToReadOnlySecureString("x");
Assert.Throws<ArgumentNullException>(() => SecureStringUtility.UsePlainText<string>(secure, null));
}
}
}
@@ -0,0 +1,42 @@
using System.IO;
using System.Text.RegularExpressions;
using Xunit;
namespace PSInfisicalAPI.Tests
{
public class SourcePolicyTests
{
private static DirectoryInfo RepositoryRoot()
{
DirectoryInfo current = new DirectoryInfo(System.AppContext.BaseDirectory);
while (current != null)
{
if (File.Exists(Path.Combine(current.FullName, "PSInfisicalAPI.sln")))
{
return current;
}
current = current.Parent;
}
return null;
}
[Fact]
public void No_Async_Or_Await_Keywords_In_Production_Source()
{
DirectoryInfo root = RepositoryRoot();
Assert.NotNull(root);
DirectoryInfo src = new DirectoryInfo(Path.Combine(root.FullName, "src", "PSInfisicalAPI"));
Assert.True(src.Exists);
Regex asyncRegex = new Regex(@"\basync\b");
Regex awaitRegex = new Regex(@"\bawait\b");
foreach (FileInfo file in src.EnumerateFiles("*.cs", SearchOption.AllDirectories))
{
string content = File.ReadAllText(file.FullName);
Assert.DoesNotMatch(asyncRegex, content);
Assert.DoesNotMatch(awaitRegex, content);
}
}
}
}
@@ -0,0 +1,66 @@
using System;
using System.Collections.Generic;
using PSInfisicalAPI.Endpoints;
using PSInfisicalAPI.Errors;
using PSInfisicalAPI.Http;
using Xunit;
namespace PSInfisicalAPI.Tests
{
public class UriBuilderTests
{
[Fact]
public void Build_Combines_Base_And_Endpoint()
{
Uri baseUri = new Uri("https://app.infisical.com");
InfisicalEndpointDefinition definition = InfisicalEndpointRegistry.Get(InfisicalEndpointNames.ListSecrets);
Uri uri = InfisicalUriBuilder.Build(baseUri, definition, null, new[]
{
new KeyValuePair<string, string>("environment", "prod"),
new KeyValuePair<string, string>("secretPath", "/")
});
Assert.Equal("https://app.infisical.com/api/v4/secrets?environment=prod&secretPath=%2F", uri.ToString());
}
[Fact]
public void Build_Escapes_SecretName_Path_Segment()
{
InfisicalEndpointDefinition definition = InfisicalEndpointRegistry.Get(InfisicalEndpointNames.RetrieveSecret);
Dictionary<string, string> pathParameters = new Dictionary<string, string> { { "secretName", "Sql Password" } };
string resolved = InfisicalUriBuilder.ResolvePathTemplate(definition.Template, pathParameters);
Assert.Equal("/api/v4/secrets/Sql%20Password", resolved);
}
[Fact]
public void ResolvePathTemplate_Escapes_Slash_And_Colon()
{
Dictionary<string, string> pathParameters = new Dictionary<string, string> { { "secretName", "Path/With:Colon" } };
string resolved = InfisicalUriBuilder.ResolvePathTemplate("/api/v4/secrets/{secretName}", pathParameters);
Assert.Equal("/api/v4/secrets/Path%2FWith%3AColon", resolved);
}
[Fact]
public void Build_Escapes_Query_Parameters()
{
string queryString = InfisicalUriBuilder.BuildQueryString(new[]
{
new KeyValuePair<string, string>("tagSlugs", "tag a"),
new KeyValuePair<string, string>("tagSlugs", "tag/b")
});
Assert.Equal("tagSlugs=tag%20a&tagSlugs=tag%2Fb", queryString);
}
[Fact]
public void Build_Throws_When_Required_Token_Missing()
{
Uri baseUri = new Uri("https://app.infisical.com");
InfisicalEndpointDefinition definition = InfisicalEndpointRegistry.Get(InfisicalEndpointNames.RetrieveSecret);
Assert.Throws<InfisicalConfigurationException>(() => InfisicalUriBuilder.Build(baseUri, definition, null, null));
}
}
}
@@ -0,0 +1,12 @@
using PSInfisicalAPI.Http;
using PSInfisicalAPI.Logging;
namespace PSInfisicalAPI.Authentication
{
public interface IInfisicalAuthProvider
{
string Name { get; }
InfisicalAuthenticationResult Authenticate(InfisicalAuthenticationRequest request, IInfisicalHttpClient httpClient, IInfisicalLogger logger);
}
}
@@ -0,0 +1,14 @@
using System;
using System.Security;
namespace PSInfisicalAPI.Authentication
{
public sealed class InfisicalAuthenticationRequest
{
public Uri BaseUri { get; set; }
public string ApiVersion { get; set; }
public string ClientId { get; set; }
public SecureString ClientSecret { get; set; }
public SecureString PreSuppliedAccessToken { get; set; }
}
}
@@ -0,0 +1,12 @@
using System;
using System.Security;
namespace PSInfisicalAPI.Authentication
{
public sealed class InfisicalAuthenticationResult
{
public SecureString AccessToken { get; set; }
public DateTimeOffset? ExpiresAtUtc { get; set; }
public string TokenType { get; set; }
}
}
@@ -0,0 +1,209 @@
using System;
using System.Collections;
using System.Collections.Generic;
using System.Security;
using System.Text.RegularExpressions;
using PSInfisicalAPI.Logging;
namespace PSInfisicalAPI.Authentication
{
public static class InfisicalEnvironmentResolver
{
public const string Component = "EnvironmentResolver";
private const RegexOptions DefaultRegexOptions = RegexOptions.IgnoreCase | RegexOptions.CultureInvariant;
private static readonly EnvironmentVariableTarget[] ScopeOrder = new[]
{
EnvironmentVariableTarget.Process,
EnvironmentVariableTarget.User,
EnvironmentVariableTarget.Machine
};
public static readonly Regex[] BaseUriPatterns = new[]
{
new Regex(@".*INFISICAL.*API.*URL.*", DefaultRegexOptions),
new Regex(@".*INFISICAL.*BASE.*UR(I|L).*", DefaultRegexOptions),
new Regex(@".*INFISICAL.*HOST.*", DefaultRegexOptions),
new Regex(@".*INFISICAL.*URL.*", DefaultRegexOptions)
};
public static readonly Regex[] OrganizationIdPatterns = new[]
{
new Regex(@".*INFISICAL.*ORG(ANIZATION)?.*ID.*", DefaultRegexOptions)
};
public static readonly Regex[] ProjectIdPatterns = new[]
{
new Regex(@".*INFISICAL.*(PROJECT|WORKSPACE).*ID.*", DefaultRegexOptions)
};
public static readonly Regex[] EnvironmentPatterns = new[]
{
new Regex(@".*INFISICAL.*ENV(IRONMENT)?.*NAME.*", DefaultRegexOptions),
new Regex(@".*INFISICAL.*ENV(IRONMENT)?.*SLUG.*", DefaultRegexOptions),
new Regex(@".*INFISICAL.*ENV(IRONMENT)?.*", DefaultRegexOptions)
};
public static readonly Regex[] ClientIdPatterns = new[]
{
new Regex(@".*INFISICAL.*CLIENT.*ID.*", DefaultRegexOptions),
new Regex(@".*INFISICAL.*(UNIVERSAL.*AUTH|MACHINE.*IDENTITY).*ID.*", DefaultRegexOptions)
};
public static readonly Regex[] ClientSecretPatterns = new[]
{
new Regex(@".*INFISICAL.*CLIENT.*SECRET.*", DefaultRegexOptions),
new Regex(@".*INFISICAL.*(UNIVERSAL.*AUTH|MACHINE.*IDENTITY).*SECRET.*", DefaultRegexOptions)
};
public static readonly Regex[] AccessTokenPatterns = new[]
{
new Regex(@".*INFISICAL.*ACCESS.*TOKEN.*", DefaultRegexOptions),
new Regex(@".*INFISICAL.*AUTH.*TOKEN.*", DefaultRegexOptions),
new Regex(@".*INFISICAL.*TOKEN.*", DefaultRegexOptions)
};
public static readonly Regex[] SecretPathPatterns = new[]
{
new Regex(@".*INFISICAL.*SECRET.*PATH.*", DefaultRegexOptions),
new Regex(@".*INFISICAL.*DEFAULT.*PATH.*", DefaultRegexOptions)
};
public static readonly Regex[] ApiVersionPatterns = new[]
{
new Regex(@".*INFISICAL.*API.*VERSION.*", DefaultRegexOptions)
};
public sealed class ResolutionResult
{
public bool Found { get; set; }
public string Value { get; set; }
public string VariableName { get; set; }
public EnvironmentVariableTarget Scope { get; set; }
}
public static ResolutionResult Resolve(IEnumerable<Regex> patterns)
{
if (patterns == null)
{
return new ResolutionResult { Found = false };
}
List<Regex> patternList = new List<Regex>(patterns);
for (int i = 0; i < ScopeOrder.Length; i++)
{
EnvironmentVariableTarget scope = ScopeOrder[i];
IDictionary entries;
try
{
entries = Environment.GetEnvironmentVariables(scope);
}
catch (SecurityException)
{
continue;
}
catch (PlatformNotSupportedException)
{
continue;
}
if (entries == null)
{
continue;
}
foreach (DictionaryEntry entry in entries)
{
string name = entry.Key as string;
if (string.IsNullOrEmpty(name))
{
continue;
}
string value = entry.Value as string;
if (string.IsNullOrWhiteSpace(value))
{
continue;
}
for (int p = 0; p < patternList.Count; p++)
{
if (patternList[p].IsMatch(name))
{
return new ResolutionResult
{
Found = true,
Value = value,
VariableName = name,
Scope = scope
};
}
}
}
}
return new ResolutionResult { Found = false };
}
public static string ResolveString(string parameterName, IEnumerable<Regex> patterns, string currentValue, IInfisicalLogger logger)
{
if (!string.IsNullOrWhiteSpace(currentValue))
{
return currentValue;
}
ResolutionResult result = Resolve(patterns);
if (!result.Found)
{
return currentValue;
}
if (logger != null)
{
logger.Verbose(Component, string.Format("Resolved {0} from environment variable '{1}' ({2} scope).", parameterName, result.VariableName, result.Scope));
}
return result.Value;
}
public static SecureString ResolveSecureString(string parameterName, IEnumerable<Regex> patterns, SecureString currentValue, IInfisicalLogger logger)
{
if (currentValue != null && currentValue.Length > 0)
{
return currentValue;
}
ResolutionResult result = Resolve(patterns);
if (!result.Found)
{
return currentValue;
}
if (logger != null)
{
logger.Verbose(Component, string.Format("Resolved {0} from environment variable '{1}' ({2} scope).", parameterName, result.VariableName, result.Scope));
}
SecureString secureString = new SecureString();
string plainText = result.Value;
try
{
for (int i = 0; i < plainText.Length; i++)
{
secureString.AppendChar(plainText[i]);
}
}
finally
{
plainText = null;
result.Value = null;
}
secureString.MakeReadOnly();
return secureString;
}
}
}
@@ -0,0 +1,25 @@
using PSInfisicalAPI.Errors;
using PSInfisicalAPI.Http;
using PSInfisicalAPI.Logging;
namespace PSInfisicalAPI.Authentication
{
public sealed class TokenAuthProvider : IInfisicalAuthProvider
{
public string Name { get { return "Token"; } }
public InfisicalAuthenticationResult Authenticate(InfisicalAuthenticationRequest request, IInfisicalHttpClient httpClient, IInfisicalLogger logger)
{
if (request == null || request.PreSuppliedAccessToken == null)
{
throw new InfisicalAuthenticationException("An access token must be supplied for Token authentication.");
}
return new InfisicalAuthenticationResult
{
AccessToken = request.PreSuppliedAccessToken,
TokenType = "Bearer"
};
}
}
}
@@ -0,0 +1,128 @@
using System;
using System.Collections.Generic;
using System.Security;
using PSInfisicalAPI.Endpoints;
using PSInfisicalAPI.Errors;
using PSInfisicalAPI.Http;
using PSInfisicalAPI.Logging;
using PSInfisicalAPI.Security;
using PSInfisicalAPI.Serialization;
namespace PSInfisicalAPI.Authentication
{
public sealed class UniversalAuthProvider : IInfisicalAuthProvider
{
private const string Component = "UniversalAuthProvider";
public string Name { get { return "UniversalAuth"; } }
public InfisicalAuthenticationResult Authenticate(InfisicalAuthenticationRequest request, IInfisicalHttpClient httpClient, IInfisicalLogger logger)
{
if (request == null)
{
throw new ArgumentNullException(nameof(request));
}
if (httpClient == null)
{
throw new ArgumentNullException(nameof(httpClient));
}
IInfisicalLogger log = logger ?? NullInfisicalLogger.Instance;
if (string.IsNullOrEmpty(request.ClientId))
{
throw new InfisicalAuthenticationException("ClientId is required for Universal Auth.");
}
if (request.ClientSecret == null)
{
throw new InfisicalAuthenticationException("ClientSecret is required for Universal Auth.");
}
log.Information(Component, "Attempting to authenticate to Infisical. Please Wait...");
InfisicalEndpointDefinition definition = InfisicalEndpointRegistry.Get(InfisicalEndpointNames.UniversalAuthLogin);
Uri uri = InfisicalUriBuilder.Build(request.BaseUri, definition, null, null);
JsonInfisicalSerializer serializer = new JsonInfisicalSerializer();
InfisicalAuthenticationResult result = SecureStringUtility.UsePlainText(request.ClientSecret, plainSecret =>
{
Dictionary<string, string> bodyObject = new Dictionary<string, string>
{
{ "clientId", request.ClientId },
{ "clientSecret", plainSecret ?? string.Empty }
};
string body = serializer.Serialize(bodyObject);
InfisicalHttpRequest httpRequest = new InfisicalHttpRequest
{
OperationName = "Authenticate",
EndpointName = definition.Name,
Method = definition.Method,
Uri = uri,
Body = body,
ContentType = "application/json",
ContainsSecretMaterialInRequest = definition.ContainsSecretMaterialInRequest,
ContainsSecretMaterialInResponse = definition.ContainsSecretMaterialInResponse,
Headers = new Dictionary<string, string> { { "Accept", "application/json" } }
};
InfisicalHttpResponse response = httpClient.Send(httpRequest);
try
{
if (response.StatusCode < 200 || response.StatusCode >= 300)
{
log.Error(Component, "Infisical authentication failed.");
throw new InfisicalAuthenticationException(string.Concat("Universal Auth login returned status ", response.StatusCode.ToString(System.Globalization.CultureInfo.InvariantCulture), "."));
}
UniversalAuthLoginResponse parsed = serializer.Deserialize<UniversalAuthLoginResponse>(response.Body);
if (parsed == null || string.IsNullOrEmpty(parsed.AccessToken))
{
throw new InfisicalAuthenticationException("Universal Auth login response did not contain an access token.");
}
SecureString accessToken = SecureStringUtility.ToReadOnlySecureString(parsed.AccessToken);
DateTimeOffset? expiresAt = null;
if (parsed.ExpiresIn > 0)
{
expiresAt = DateTimeOffset.UtcNow.AddSeconds(parsed.ExpiresIn);
}
parsed.AccessToken = null;
return new InfisicalAuthenticationResult
{
AccessToken = accessToken,
TokenType = string.IsNullOrEmpty(parsed.TokenType) ? "Bearer" : parsed.TokenType,
ExpiresAtUtc = expiresAt
};
}
finally
{
response.Clear();
}
});
log.Information(Component, "Infisical authentication was successful.");
return result;
}
private sealed class UniversalAuthLoginResponse
{
[Newtonsoft.Json.JsonProperty("accessToken")]
public string AccessToken { get; set; }
[Newtonsoft.Json.JsonProperty("expiresIn")]
public int ExpiresIn { get; set; }
[Newtonsoft.Json.JsonProperty("tokenType")]
public string TokenType { get; set; }
}
}
}
@@ -0,0 +1,213 @@
using System;
using System.Collections.Generic;
using System.Management.Automation;
using System.Security;
using PSInfisicalAPI.Authentication;
using PSInfisicalAPI.Connections;
using PSInfisicalAPI.Errors;
using PSInfisicalAPI.Models;
namespace PSInfisicalAPI.Cmdlets
{
[Cmdlet(VerbsCommunications.Connect, "Infisical", DefaultParameterSetName = "UniversalAuth")]
[OutputType(typeof(InfisicalConnection))]
public sealed class ConnectInfisicalCmdlet : InfisicalCmdletBase
{
private const string ParameterSetUniversalAuth = "UniversalAuth";
private const string ParameterSetToken = "Token";
private const string Component = "ConnectInfisicalCmdlet";
[Parameter]
public Uri BaseUri { get; set; }
[Parameter]
public string OrganizationId { get; set; }
[Parameter]
public string ProjectId { get; set; }
[Parameter]
public string Environment { get; set; }
[Parameter(ParameterSetName = ParameterSetUniversalAuth)]
public string ClientId { get; set; }
[Parameter(ParameterSetName = ParameterSetUniversalAuth)]
public SecureString ClientSecret { get; set; }
[Parameter(ParameterSetName = ParameterSetToken)]
public SecureString AccessToken { get; set; }
[Parameter]
public string SecretPath { get; set; } = "/";
[Parameter]
public string ApiVersion { get; set; } = "v4";
[Parameter]
public SwitchParameter PassThru { get; set; }
protected override void ProcessRecord()
{
try
{
ResolveMissingParametersFromEnvironment();
ValidateRequiredParameters();
IInfisicalAuthProvider provider;
InfisicalAuthenticationRequest request;
InfisicalAuthType authType;
if (string.Equals(ParameterSetName, ParameterSetToken, StringComparison.Ordinal))
{
provider = new TokenAuthProvider();
authType = InfisicalAuthType.Token;
request = new InfisicalAuthenticationRequest
{
BaseUri = BaseUri,
ApiVersion = ApiVersion,
PreSuppliedAccessToken = AccessToken
};
}
else
{
provider = new UniversalAuthProvider();
authType = InfisicalAuthType.UniversalAuth;
request = new InfisicalAuthenticationRequest
{
BaseUri = BaseUri,
ApiVersion = ApiVersion,
ClientId = ClientId,
ClientSecret = ClientSecret
};
}
InfisicalAuthenticationResult authResult = provider.Authenticate(request, HttpClient, Logger);
if (authResult == null || authResult.AccessToken == null)
{
throw new InfisicalAuthenticationException("Authentication did not produce an access token.");
}
InfisicalConnection connection = new InfisicalConnection
{
BaseUri = BaseUri,
ApiVersion = ApiVersion,
AuthType = authType,
OrganizationId = OrganizationId,
ProjectId = ProjectId,
Environment = Environment,
DefaultSecretPath = string.IsNullOrEmpty(SecretPath) ? "/" : SecretPath,
ConnectedAtUtc = DateTimeOffset.UtcNow,
ExpiresAtUtc = authResult.ExpiresAtUtc,
IsConnected = true,
AccessToken = authResult.AccessToken
};
InfisicalSessionManager.SetCurrent(connection);
if (PassThru.IsPresent)
{
WriteObject(connection);
}
}
catch (Exception exception)
{
ThrowTerminatingForException(Component, "Connect", exception);
}
}
private void ResolveMissingParametersFromEnvironment()
{
bool tokenSet = string.Equals(ParameterSetName, ParameterSetToken, StringComparison.Ordinal);
bool needsScan =
BaseUri == null ||
string.IsNullOrWhiteSpace(OrganizationId) ||
string.IsNullOrWhiteSpace(ProjectId) ||
string.IsNullOrWhiteSpace(Environment) ||
(tokenSet && (AccessToken == null || AccessToken.Length == 0)) ||
(!tokenSet && string.IsNullOrWhiteSpace(ClientId)) ||
(!tokenSet && (ClientSecret == null || ClientSecret.Length == 0));
if (!needsScan)
{
return;
}
Logger.Verbose(Component, "Attempting to resolve missing Infisical connection parameters from environment variables. Please Wait...");
if (BaseUri == null)
{
string resolved = InfisicalEnvironmentResolver.ResolveString("BaseUri", InfisicalEnvironmentResolver.BaseUriPatterns, null, Logger);
if (!string.IsNullOrWhiteSpace(resolved))
{
Uri parsed;
if (Uri.TryCreate(resolved, UriKind.Absolute, out parsed))
{
BaseUri = parsed;
}
}
}
OrganizationId = InfisicalEnvironmentResolver.ResolveString("OrganizationId", InfisicalEnvironmentResolver.OrganizationIdPatterns, OrganizationId, Logger);
ProjectId = InfisicalEnvironmentResolver.ResolveString("ProjectId", InfisicalEnvironmentResolver.ProjectIdPatterns, ProjectId, Logger);
Environment = InfisicalEnvironmentResolver.ResolveString("Environment", InfisicalEnvironmentResolver.EnvironmentPatterns, Environment, Logger);
if (tokenSet)
{
AccessToken = InfisicalEnvironmentResolver.ResolveSecureString("AccessToken", InfisicalEnvironmentResolver.AccessTokenPatterns, AccessToken, Logger);
}
else
{
ClientId = InfisicalEnvironmentResolver.ResolveString("ClientId", InfisicalEnvironmentResolver.ClientIdPatterns, ClientId, Logger);
ClientSecret = InfisicalEnvironmentResolver.ResolveSecureString("ClientSecret", InfisicalEnvironmentResolver.ClientSecretPatterns, ClientSecret, Logger);
}
if (!MyInvocation.BoundParameters.ContainsKey("SecretPath"))
{
string resolvedPath = InfisicalEnvironmentResolver.ResolveString("SecretPath", InfisicalEnvironmentResolver.SecretPathPatterns, null, Logger);
if (!string.IsNullOrWhiteSpace(resolvedPath))
{
SecretPath = resolvedPath;
}
}
if (!MyInvocation.BoundParameters.ContainsKey("ApiVersion"))
{
string resolvedVersion = InfisicalEnvironmentResolver.ResolveString("ApiVersion", InfisicalEnvironmentResolver.ApiVersionPatterns, null, Logger);
if (!string.IsNullOrWhiteSpace(resolvedVersion))
{
ApiVersion = resolvedVersion;
}
}
}
private void ValidateRequiredParameters()
{
List<string> missing = new List<string>();
if (BaseUri == null) { missing.Add("BaseUri"); }
if (string.IsNullOrWhiteSpace(OrganizationId)) { missing.Add("OrganizationId"); }
if (string.IsNullOrWhiteSpace(ProjectId)) { missing.Add("ProjectId"); }
if (string.IsNullOrWhiteSpace(Environment)) { missing.Add("Environment"); }
if (string.Equals(ParameterSetName, ParameterSetToken, StringComparison.Ordinal))
{
if (AccessToken == null || AccessToken.Length == 0) { missing.Add("AccessToken"); }
}
else
{
if (string.IsNullOrWhiteSpace(ClientId)) { missing.Add("ClientId"); }
if (ClientSecret == null || ClientSecret.Length == 0) { missing.Add("ClientSecret"); }
}
if (missing.Count > 0)
{
throw new InfisicalConfigurationException(string.Format(
"Required Connect-Infisical parameter(s) not supplied and no matching environment variables were found: {0}.",
string.Join(", ", missing.ToArray())));
}
}
}
}
@@ -0,0 +1,71 @@
using System;
using System.Collections.Generic;
using System.Management.Automation;
using System.Security;
using PSInfisicalAPI.Errors;
using PSInfisicalAPI.Models;
namespace PSInfisicalAPI.Cmdlets
{
[Cmdlet(VerbsData.ConvertTo, "InfisicalSecretDictionary")]
[OutputType(typeof(Dictionary<string, SecureString>))]
public sealed class ConvertToInfisicalSecretDictionaryCmdlet : InfisicalCmdletBase
{
[Parameter(Mandatory = true, ValueFromPipeline = true)]
public InfisicalSecret[] InputObject { get; set; }
[Parameter]
public InfisicalDuplicateKeyBehavior DuplicateKeyBehavior { get; set; } = InfisicalDuplicateKeyBehavior.Error;
private readonly List<InfisicalSecret> _buffer = new List<InfisicalSecret>();
protected override void ProcessRecord()
{
if (InputObject == null) { return; }
foreach (InfisicalSecret secret in InputObject)
{
if (secret != null)
{
_buffer.Add(secret);
}
}
}
protected override void EndProcessing()
{
try
{
Dictionary<string, SecureString> dictionary = new Dictionary<string, SecureString>(StringComparer.OrdinalIgnoreCase);
foreach (InfisicalSecret secret in _buffer)
{
string key = secret.SecretName ?? string.Empty;
if (dictionary.ContainsKey(key))
{
if (DuplicateKeyBehavior == InfisicalDuplicateKeyBehavior.Error)
{
throw new InfisicalConfigurationException(string.Concat("Duplicate secret name encountered: ", key));
}
if (DuplicateKeyBehavior == InfisicalDuplicateKeyBehavior.LastWins)
{
dictionary[key] = secret.SecretValue;
}
continue;
}
dictionary[key] = secret.SecretValue;
}
WriteObject(dictionary);
}
catch (Exception exception)
{
ThrowTerminatingForException("ConvertToInfisicalSecretDictionaryCmdlet", "ConvertToDictionary", exception);
}
}
}
}
@@ -0,0 +1,34 @@
using System;
using System.Management.Automation;
using PSInfisicalAPI.Connections;
namespace PSInfisicalAPI.Cmdlets
{
[Cmdlet(VerbsCommunications.Disconnect, "Infisical")]
[OutputType(typeof(PSObject))]
public sealed class DisconnectInfisicalCmdlet : InfisicalCmdletBase
{
[Parameter]
public SwitchParameter PassThru { get; set; }
protected override void ProcessRecord()
{
try
{
InfisicalSessionManager.Disconnect();
if (PassThru.IsPresent)
{
PSObject status = new PSObject();
status.Properties.Add(new PSNoteProperty("IsConnected", false));
status.Properties.Add(new PSNoteProperty("DisconnectedAtUtc", DateTimeOffset.UtcNow));
WriteObject(status);
}
}
catch (Exception exception)
{
ThrowTerminatingForException("DisconnectInfisicalCmdlet", "Disconnect", exception);
}
}
}
}
@@ -0,0 +1,98 @@
using System;
using System.Collections.Generic;
using System.IO;
using System.Management.Automation;
using System.Text;
using PSInfisicalAPI.Errors;
using PSInfisicalAPI.Exports;
using PSInfisicalAPI.Models;
namespace PSInfisicalAPI.Cmdlets
{
public enum InfisicalExportEncoding
{
UTF8,
UTF8Bom,
Unicode
}
[Cmdlet(VerbsData.Export, "InfisicalSecrets")]
public sealed class ExportInfisicalSecretsCmdlet : InfisicalCmdletBase
{
[Parameter(Mandatory = true, ValueFromPipeline = true)]
public InfisicalSecret[] InputObject { get; set; }
[Parameter(Mandatory = true)]
public InfisicalExportFormat Format { get; set; }
[Parameter]
public FileInfo Path { get; set; }
[Parameter]
public EnvironmentVariableTarget Scope { get; set; } = EnvironmentVariableTarget.Process;
[Parameter]
public SwitchParameter Force { get; set; }
[Parameter]
public InfisicalExportEncoding Encoding { get; set; } = InfisicalExportEncoding.UTF8;
private readonly List<InfisicalSecret> _buffer = new List<InfisicalSecret>();
protected override void ProcessRecord()
{
if (InputObject == null) { return; }
foreach (InfisicalSecret secret in InputObject)
{
if (secret != null)
{
_buffer.Add(secret);
}
}
}
protected override void EndProcessing()
{
try
{
bool requiresPath = Format != InfisicalExportFormat.EnvironmentVariables;
if (requiresPath && Path == null)
{
throw new InfisicalExportException(string.Concat("Path is required for format ", Format.ToString(), "."));
}
if (requiresPath && Path.Exists && !Force.IsPresent)
{
}
InfisicalExportRequest request = new InfisicalExportRequest
{
Secrets = _buffer.ToArray(),
Format = Format,
Path = Path,
Scope = Scope,
Force = Force.IsPresent,
Encoding = ResolveEncoding(Encoding)
};
IInfisicalExporter exporter = InfisicalExporterFactory.Create(Format);
exporter.Export(request);
}
catch (Exception exception)
{
ThrowTerminatingForException("ExportInfisicalSecretsCmdlet", string.Concat("Export-", Format.ToString()), exception);
}
}
private static Encoding ResolveEncoding(InfisicalExportEncoding encoding)
{
switch (encoding)
{
case InfisicalExportEncoding.UTF8Bom: return new UTF8Encoding(true);
case InfisicalExportEncoding.Unicode: return new UnicodeEncoding();
default: return new UTF8Encoding(false);
}
}
}
}
@@ -0,0 +1,58 @@
using System;
using System.Management.Automation;
using PSInfisicalAPI.Connections;
using PSInfisicalAPI.Models;
using PSInfisicalAPI.Secrets;
namespace PSInfisicalAPI.Cmdlets
{
[Cmdlet(VerbsCommon.Get, "InfisicalSecret")]
[OutputType(typeof(InfisicalSecret))]
public sealed class GetInfisicalSecretCmdlet : InfisicalCmdletBase
{
[Parameter(Mandatory = true, ValueFromPipelineByPropertyName = true, Position = 0)]
public string SecretName { get; set; }
[Parameter] public string ProjectId { get; set; }
[Parameter] public string Environment { get; set; }
[Parameter] public string SecretPath { get; set; }
[Parameter] public int? Version { get; set; }
[Parameter] public InfisicalSecretType Type { get; set; } = InfisicalSecretType.Shared;
[Parameter] public bool ViewSecretValue { get; set; } = true;
[Parameter] public bool ExpandSecretReferences { get; set; } = true;
[Parameter] public bool IncludeImports { get; set; } = true;
protected override void ProcessRecord()
{
try
{
InfisicalConnection connection = InfisicalSessionManager.RequireCurrent();
InfisicalRetrieveSecretQuery query = new InfisicalRetrieveSecretQuery
{
SecretName = SecretName,
ProjectId = ProjectId,
Environment = Environment,
SecretPath = SecretPath,
Version = Version,
Type = Type.ToString(),
ViewSecretValue = ViewSecretValue,
ExpandSecretReferences = ExpandSecretReferences,
IncludeImports = IncludeImports
};
InfisicalSecretsClient client = new InfisicalSecretsClient(HttpClient, Logger);
InfisicalSecret secret = client.Retrieve(connection, query);
if (secret != null)
{
WriteObject(secret);
}
}
catch (Exception exception)
{
ThrowTerminatingForException("GetInfisicalSecretCmdlet", "RetrieveSecret", exception);
}
}
}
}
@@ -0,0 +1,74 @@
using System;
using System.Collections;
using System.Collections.Generic;
using System.Management.Automation;
using PSInfisicalAPI.Connections;
using PSInfisicalAPI.Models;
using PSInfisicalAPI.Secrets;
namespace PSInfisicalAPI.Cmdlets
{
[Cmdlet(VerbsCommon.Get, "InfisicalSecrets")]
[OutputType(typeof(InfisicalSecret))]
public sealed class GetInfisicalSecretsCmdlet : InfisicalCmdletBase
{
[Parameter] public string ProjectId { get; set; }
[Parameter] public string Environment { get; set; }
[Parameter] public string SecretPath { get; set; }
[Parameter] public SwitchParameter Recursive { get; set; }
[Parameter] public bool IncludeImports { get; set; } = true;
[Parameter] public SwitchParameter IncludePersonalOverrides { get; set; }
[Parameter] public bool ExpandSecretReferences { get; set; } = true;
[Parameter] public bool ViewSecretValue { get; set; } = true;
[Parameter] public Hashtable MetadataFilter { get; set; }
[Parameter] public string[] TagSlugs { get; set; }
protected override void ProcessRecord()
{
try
{
InfisicalConnection connection = InfisicalSessionManager.RequireCurrent();
InfisicalListSecretsQuery query = new InfisicalListSecretsQuery
{
ProjectId = ProjectId,
Environment = Environment,
SecretPath = SecretPath,
Recursive = Recursive.IsPresent,
IncludeImports = IncludeImports,
IncludePersonalOverrides = IncludePersonalOverrides.IsPresent,
ExpandSecretReferences = ExpandSecretReferences,
ViewSecretValue = ViewSecretValue,
MetadataFilter = ToStringDictionary(MetadataFilter),
TagSlugs = TagSlugs
};
InfisicalSecretsClient client = new InfisicalSecretsClient(HttpClient, Logger);
InfisicalSecret[] secrets = client.List(connection, query);
foreach (InfisicalSecret secret in secrets)
{
WriteObject(secret);
}
}
catch (Exception exception)
{
ThrowTerminatingForException("GetInfisicalSecretsCmdlet", "RetrieveSecrets", exception);
}
}
private static Dictionary<string, string> ToStringDictionary(Hashtable hashtable)
{
if (hashtable == null) { return null; }
Dictionary<string, string> result = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase);
foreach (DictionaryEntry entry in hashtable)
{
if (entry.Key == null) { continue; }
result[entry.Key.ToString()] = entry.Value != null ? entry.Value.ToString() : null;
}
return result;
}
}
}
@@ -0,0 +1,48 @@
using System;
using System.Management.Automation;
using PSInfisicalAPI.Errors;
using PSInfisicalAPI.Http;
using PSInfisicalAPI.Logging;
namespace PSInfisicalAPI.Cmdlets
{
public abstract class InfisicalCmdletBase : PSCmdlet
{
private IInfisicalLogger _logger;
private IInfisicalHttpClient _httpClient;
protected IInfisicalLogger Logger
{
get
{
if (_logger == null)
{
_logger = new PSCmdletLogger(this);
}
return _logger;
}
}
protected IInfisicalHttpClient HttpClient
{
get
{
if (_httpClient == null)
{
_httpClient = new InfisicalHttpClient(Logger);
}
return _httpClient;
}
}
protected void ThrowTerminatingForException(string component, string operation, Exception exception)
{
InfisicalErrorDetails details = InfisicalErrorHandler.BuildDetails(component, operation, exception);
InfisicalErrorHandler.LogFailure(Logger, details);
ErrorRecord record = InfisicalErrorHandler.ToErrorRecord(exception, details);
ThrowTerminatingError(record);
}
}
}
@@ -0,0 +1,42 @@
using System.IO;
namespace PSInfisicalAPI.Common
{
public static class InfisicalPath
{
public static FileInfo CombineFile(params string[] segments)
{
string combined = Path.Combine(segments);
return new FileInfo(combined);
}
public static DirectoryInfo CombineDirectory(params string[] segments)
{
string combined = Path.Combine(segments);
return new DirectoryInfo(combined);
}
public static void EnsureDirectoryExists(DirectoryInfo directory)
{
if (directory == null)
{
return;
}
if (!directory.Exists)
{
directory.Create();
}
}
public static void EnsureParentDirectoryExists(FileInfo file)
{
if (file == null)
{
return;
}
EnsureDirectoryExists(file.Directory);
}
}
}
@@ -0,0 +1,82 @@
using System.Collections.Generic;
namespace PSInfisicalAPI.Common
{
public static class InfisicalSanitizer
{
private static readonly string[] SensitiveTokens = new[]
{
"secretValue",
"secret_value",
"clientSecret",
"client_secret",
"accessToken",
"access_token",
"Authorization",
"Bearer"
};
public static string SanitizeBody(string body, bool containsSecretMaterial)
{
if (string.IsNullOrEmpty(body))
{
return body;
}
if (containsSecretMaterial)
{
return "[REDACTED]";
}
return Truncate(body, 1024);
}
public static string SanitizeHeaderValue(string headerName, string headerValue)
{
if (string.IsNullOrEmpty(headerName))
{
return headerValue;
}
string normalized = headerName.ToLowerInvariant();
if (normalized == "authorization" || normalized.Contains("token") || normalized.Contains("secret"))
{
return "[REDACTED]";
}
return headerValue;
}
public static IDictionary<string, string> SanitizeHeaders(IDictionary<string, string> headers)
{
if (headers == null)
{
return null;
}
Dictionary<string, string> sanitized = new Dictionary<string, string>(headers.Count);
foreach (KeyValuePair<string, string> entry in headers)
{
sanitized[entry.Key] = SanitizeHeaderValue(entry.Key, entry.Value);
}
return sanitized;
}
public static string Truncate(string value, int maxLength)
{
if (string.IsNullOrEmpty(value))
{
return value;
}
if (value.Length <= maxLength)
{
return value;
}
return string.Concat(value.Substring(0, maxLength), "... [truncated]");
}
}
}
@@ -0,0 +1,30 @@
using System;
using System.Security;
using PSInfisicalAPI.Models;
namespace PSInfisicalAPI.Connections
{
public sealed class InfisicalConnection
{
public Uri BaseUri { get; set; }
public string ApiVersion { get; set; }
public InfisicalAuthType AuthType { get; set; }
public string OrganizationId { get; set; }
public string ProjectId { get; set; }
public string Environment { get; set; }
public string DefaultSecretPath { get; set; }
public DateTimeOffset ConnectedAtUtc { get; set; }
public DateTimeOffset? ExpiresAtUtc { get; set; }
public bool IsConnected { get; set; }
internal SecureString AccessToken { get; set; }
public override string ToString()
{
return string.Concat(
"Project=", ProjectId ?? "",
" Environment=", Environment ?? "",
" Connected=", IsConnected ? "true" : "false");
}
}
}
@@ -0,0 +1,47 @@
using PSInfisicalAPI.Errors;
namespace PSInfisicalAPI.Connections
{
public static class InfisicalSessionManager
{
private static readonly object Sync = new object();
private static InfisicalConnection _current;
public static InfisicalConnection Current
{
get { lock (Sync) { return _current; } }
}
public static void SetCurrent(InfisicalConnection connection)
{
lock (Sync)
{
_current = connection;
}
}
public static InfisicalConnection RequireCurrent()
{
InfisicalConnection connection = Current;
if (connection == null || !connection.IsConnected)
{
throw new InfisicalConfigurationException("No active Infisical connection. Call Connect-Infisical first.");
}
return connection;
}
public static void Disconnect()
{
lock (Sync)
{
if (_current != null)
{
_current.AccessToken = null;
_current.IsConnected = false;
_current = null;
}
}
}
}
}
@@ -0,0 +1,14 @@
namespace PSInfisicalAPI.Endpoints
{
public sealed class InfisicalEndpointDefinition
{
public string Name { get; set; }
public string Resource { get; set; }
public string Version { get; set; }
public string Method { get; set; }
public string Template { get; set; }
public bool RequiresAuthorization { get; set; }
public bool ContainsSecretMaterialInRequest { get; set; }
public bool ContainsSecretMaterialInResponse { get; set; }
}
}
@@ -0,0 +1,9 @@
namespace PSInfisicalAPI.Endpoints
{
public static class InfisicalEndpointNames
{
public const string UniversalAuthLogin = "UniversalAuthLogin";
public const string ListSecrets = "ListSecrets";
public const string RetrieveSecret = "RetrieveSecret";
}
}
@@ -0,0 +1,87 @@
using System.Collections.Generic;
using PSInfisicalAPI.Errors;
namespace PSInfisicalAPI.Endpoints
{
public static class InfisicalEndpointRegistry
{
private static readonly Dictionary<string, InfisicalEndpointDefinition> Definitions =
new Dictionary<string, InfisicalEndpointDefinition>
{
{
InfisicalEndpointNames.UniversalAuthLogin,
new InfisicalEndpointDefinition
{
Name = InfisicalEndpointNames.UniversalAuthLogin,
Resource = "Authentication",
Version = "v1",
Method = "POST",
Template = "/api/v1/auth/universal-auth/login",
RequiresAuthorization = false,
ContainsSecretMaterialInRequest = true,
ContainsSecretMaterialInResponse = true
}
},
{
InfisicalEndpointNames.ListSecrets,
new InfisicalEndpointDefinition
{
Name = InfisicalEndpointNames.ListSecrets,
Resource = "Secrets",
Version = "v4",
Method = "GET",
Template = "/api/v4/secrets",
RequiresAuthorization = true,
ContainsSecretMaterialInRequest = false,
ContainsSecretMaterialInResponse = true
}
},
{
InfisicalEndpointNames.RetrieveSecret,
new InfisicalEndpointDefinition
{
Name = InfisicalEndpointNames.RetrieveSecret,
Resource = "Secrets",
Version = "v4",
Method = "GET",
Template = "/api/v4/secrets/{secretName}",
RequiresAuthorization = true,
ContainsSecretMaterialInRequest = false,
ContainsSecretMaterialInResponse = true
}
}
};
public static InfisicalEndpointDefinition Get(string name)
{
if (string.IsNullOrEmpty(name))
{
throw new InfisicalConfigurationException("Endpoint name must be provided.");
}
InfisicalEndpointDefinition definition;
if (!Definitions.TryGetValue(name, out definition))
{
throw new InfisicalConfigurationException(string.Concat("Unknown endpoint name: ", name));
}
return definition;
}
public static bool TryGet(string name, out InfisicalEndpointDefinition definition)
{
if (string.IsNullOrEmpty(name))
{
definition = null;
return false;
}
return Definitions.TryGetValue(name, out definition);
}
public static IEnumerable<InfisicalEndpointDefinition> All()
{
return Definitions.Values;
}
}
}
@@ -0,0 +1,19 @@
namespace PSInfisicalAPI.Errors
{
public sealed class InfisicalErrorDetails
{
public string Component { get; set; }
public string Operation { get; set; }
public string Message { get; set; }
public string ExceptionType { get; set; }
public string InnerExceptionMessage { get; set; }
public int? StatusCode { get; set; }
public string ReasonPhrase { get; set; }
public string ApiErrorCode { get; set; }
public string SanitizedBody { get; set; }
public int? LineNumber { get; set; }
public int? LinePosition { get; set; }
public string EndpointName { get; set; }
public string RequestMethod { get; set; }
}
}
@@ -0,0 +1,122 @@
using System;
using System.Globalization;
using System.Management.Automation;
using PSInfisicalAPI.Logging;
namespace PSInfisicalAPI.Errors
{
public static class InfisicalErrorHandler
{
private const string Component = "ErrorHandler";
public static InfisicalErrorDetails BuildDetails(string component, string operation, Exception exception)
{
InfisicalErrorDetails details = new InfisicalErrorDetails
{
Component = component,
Operation = operation,
Message = exception?.Message,
ExceptionType = exception?.GetType().FullName,
InnerExceptionMessage = exception?.InnerException?.Message
};
InfisicalApiException apiException = exception as InfisicalApiException;
if (apiException != null)
{
details.StatusCode = apiException.StatusCode;
details.ReasonPhrase = apiException.ReasonPhrase;
details.ApiErrorCode = apiException.ApiErrorCode;
details.SanitizedBody = apiException.SanitizedBody;
details.EndpointName = apiException.EndpointName;
details.RequestMethod = apiException.RequestMethod;
}
InfisicalSerializationException serializationException = exception as InfisicalSerializationException;
if (serializationException != null)
{
details.LineNumber = serializationException.LineNumber;
details.LinePosition = serializationException.LinePosition;
}
return details;
}
public static void LogFailure(IInfisicalLogger logger, InfisicalErrorDetails details)
{
if (logger == null || details == null)
{
return;
}
logger.Error(Component, string.Concat("Operation failed: ", details.Operation ?? "Unspecified"));
if (!string.IsNullOrEmpty(details.Component))
{
logger.Error(Component, string.Concat("Error Component: ", details.Component));
}
if (!string.IsNullOrEmpty(details.Message))
{
logger.Error(Component, string.Concat("Error Message: ", details.Message));
}
if (details.StatusCode.HasValue)
{
logger.Error(Component, string.Concat("HTTP Status Code: ", details.StatusCode.Value.ToString(CultureInfo.InvariantCulture)));
}
if (!string.IsNullOrEmpty(details.ApiErrorCode))
{
logger.Error(Component, string.Concat("API Error Code: ", details.ApiErrorCode));
}
if (details.LineNumber.HasValue)
{
logger.Error(Component, string.Concat("Line: ", details.LineNumber.Value.ToString(CultureInfo.InvariantCulture)));
}
if (details.LinePosition.HasValue)
{
logger.Error(Component, string.Concat("Position: ", details.LinePosition.Value.ToString(CultureInfo.InvariantCulture)));
}
}
public static ErrorRecord ToErrorRecord(Exception exception, InfisicalErrorDetails details)
{
ErrorCategory category = MapCategory(exception);
string errorId = exception?.GetType().Name ?? "PSInfisicalAPI.Error";
object target = details?.Component;
ErrorRecord record = new ErrorRecord(exception ?? new InfisicalException("Unspecified error."), errorId, category, target);
if (details != null && !string.IsNullOrEmpty(details.Message))
{
record.ErrorDetails = new ErrorDetails(details.Message);
}
return record;
}
internal static ErrorCategory MapCategory(Exception exception)
{
if (exception is InfisicalAuthenticationException) { return ErrorCategory.AuthenticationError; }
if (exception is InfisicalHttpException) { return ErrorCategory.ConnectionError; }
if (exception is InfisicalSerializationException) { return ErrorCategory.InvalidData; }
if (exception is InfisicalConfigurationException) { return ErrorCategory.InvalidArgument; }
if (exception is InfisicalExportException) { return ErrorCategory.WriteError; }
InfisicalApiException apiException = exception as InfisicalApiException;
if (apiException != null)
{
if (apiException.StatusCode == 401) { return ErrorCategory.AuthenticationError; }
if (apiException.StatusCode == 403) { return ErrorCategory.PermissionDenied; }
if (apiException.StatusCode == 404) { return ErrorCategory.ObjectNotFound; }
if (apiException.StatusCode == 503) { return ErrorCategory.ResourceUnavailable; }
return ErrorCategory.InvalidOperation;
}
return ErrorCategory.NotSpecified;
}
}
}
@@ -0,0 +1,82 @@
using System;
namespace PSInfisicalAPI.Errors
{
public class InfisicalException : Exception
{
public string Component { get; set; }
public string Operation { get; set; }
public InfisicalException() { }
public InfisicalException(string message) : base(message) { }
public InfisicalException(string message, Exception innerException) : base(message, innerException) { }
public InfisicalException(string component, string operation, string message)
: base(message)
{
Component = component;
Operation = operation;
}
public InfisicalException(string component, string operation, string message, Exception innerException)
: base(message, innerException)
{
Component = component;
Operation = operation;
}
}
public class InfisicalApiException : InfisicalException
{
public int StatusCode { get; set; }
public string ReasonPhrase { get; set; }
public string ApiErrorCode { get; set; }
public string SanitizedBody { get; set; }
public string EndpointName { get; set; }
public string RequestMethod { get; set; }
public InfisicalApiException() { }
public InfisicalApiException(string message) : base(message) { }
public InfisicalApiException(string message, Exception innerException) : base(message, innerException) { }
}
public class InfisicalAuthenticationException : InfisicalException
{
public InfisicalAuthenticationException() { }
public InfisicalAuthenticationException(string message) : base(message) { }
public InfisicalAuthenticationException(string message, Exception innerException) : base(message, innerException) { }
}
public class InfisicalHttpException : InfisicalException
{
public InfisicalHttpException() { }
public InfisicalHttpException(string message) : base(message) { }
public InfisicalHttpException(string message, Exception innerException) : base(message, innerException) { }
}
public class InfisicalSerializationException : InfisicalException
{
public int? LineNumber { get; set; }
public int? LinePosition { get; set; }
public InfisicalSerializationException() { }
public InfisicalSerializationException(string message) : base(message) { }
public InfisicalSerializationException(string message, Exception innerException) : base(message, innerException) { }
}
public class InfisicalExportException : InfisicalException
{
public InfisicalExportException() { }
public InfisicalExportException(string message) : base(message) { }
public InfisicalExportException(string message, Exception innerException) : base(message, innerException) { }
}
public class InfisicalConfigurationException : InfisicalException
{
public InfisicalConfigurationException() { }
public InfisicalConfigurationException(string message) : base(message) { }
public InfisicalConfigurationException(string message, Exception innerException) : base(message, innerException) { }
}
}
@@ -0,0 +1,36 @@
using System.IO;
using System.Text;
using PSInfisicalAPI.Common;
using PSInfisicalAPI.Errors;
using PSInfisicalAPI.Models;
namespace PSInfisicalAPI.Exports
{
public sealed class EnvInfisicalExporter : IInfisicalExporter
{
public void Export(InfisicalExportRequest request)
{
if (request == null || request.Secrets == null) { throw new InfisicalExportException("Export request is invalid."); }
if (request.Path == null) { throw new InfisicalExportException("Path is required for ENV export."); }
InfisicalPath.EnsureParentDirectoryExists(request.Path);
StringBuilder builder = new StringBuilder();
foreach (InfisicalSecret secret in request.Secrets)
{
if (secret == null || string.IsNullOrEmpty(secret.SecretName)) { continue; }
secret.UsePlainTextValue(plainValue =>
{
builder.Append(secret.SecretName);
builder.Append('=');
builder.AppendLine(plainValue ?? string.Empty);
});
}
Encoding encoding = request.Encoding ?? new UTF8Encoding(false);
File.WriteAllText(request.Path.FullName, builder.ToString(), encoding);
}
}
}
@@ -0,0 +1,28 @@
using System;
using PSInfisicalAPI.Errors;
using PSInfisicalAPI.Models;
namespace PSInfisicalAPI.Exports
{
public sealed class EnvironmentVariableExporter : IInfisicalExporter
{
public void Export(InfisicalExportRequest request)
{
if (request == null || request.Secrets == null) { throw new InfisicalExportException("Export request is invalid."); }
EnvironmentVariableTarget target = request.Scope;
foreach (InfisicalSecret secret in request.Secrets)
{
if (secret == null || string.IsNullOrEmpty(secret.SecretName)) { continue; }
string name = secret.SecretName;
secret.UsePlainTextValue(plainValue =>
{
Environment.SetEnvironmentVariable(name, plainValue, target);
});
}
}
}
}
@@ -0,0 +1,7 @@
namespace PSInfisicalAPI.Exports
{
public interface IInfisicalExporter
{
void Export(InfisicalExportRequest request);
}
}
@@ -0,0 +1,17 @@
using System;
using System.IO;
using System.Text;
using PSInfisicalAPI.Models;
namespace PSInfisicalAPI.Exports
{
public sealed class InfisicalExportRequest
{
public InfisicalSecret[] Secrets { get; set; }
public InfisicalExportFormat Format { get; set; }
public FileInfo Path { get; set; }
public EnvironmentVariableTarget Scope { get; set; }
public bool Force { get; set; }
public Encoding Encoding { get; set; }
}
}
@@ -0,0 +1,21 @@
using PSInfisicalAPI.Errors;
using PSInfisicalAPI.Models;
namespace PSInfisicalAPI.Exports
{
public static class InfisicalExporterFactory
{
public static IInfisicalExporter Create(InfisicalExportFormat format)
{
switch (format)
{
case InfisicalExportFormat.Json: return new JsonInfisicalExporter();
case InfisicalExportFormat.Yaml: return new YamlInfisicalExporter();
case InfisicalExportFormat.Env: return new EnvInfisicalExporter();
case InfisicalExportFormat.Xml: return new XmlInfisicalExporter();
case InfisicalExportFormat.EnvironmentVariables: return new EnvironmentVariableExporter();
default: throw new InfisicalExportException(string.Concat("Unsupported export format: ", format.ToString()));
}
}
}
}
@@ -0,0 +1,58 @@
using System.Collections.Generic;
using System.IO;
using System.Text;
using Newtonsoft.Json;
using PSInfisicalAPI.Common;
using PSInfisicalAPI.Errors;
using PSInfisicalAPI.Models;
namespace PSInfisicalAPI.Exports
{
public sealed class JsonInfisicalExporter : IInfisicalExporter
{
public void Export(InfisicalExportRequest request)
{
if (request == null || request.Secrets == null) { throw new InfisicalExportException("Export request is invalid."); }
if (request.Path == null) { throw new InfisicalExportException("Path is required for JSON export."); }
InfisicalPath.EnsureParentDirectoryExists(request.Path);
List<Dictionary<string, object>> payload = new List<Dictionary<string, object>>();
foreach (InfisicalSecret secret in request.Secrets)
{
if (secret == null) { continue; }
secret.UsePlainTextValue(plainValue =>
{
Dictionary<string, object> entry = new Dictionary<string, object>();
entry["SecretName"] = secret.SecretName;
entry["SecretValue"] = plainValue;
entry["SecretPath"] = secret.SecretPath;
entry["SecretMetadata"] = MetadataToObject(secret);
payload.Add(entry);
});
}
string serialized = JsonConvert.SerializeObject(payload, Formatting.Indented);
Encoding encoding = request.Encoding ?? new UTF8Encoding(false);
File.WriteAllText(request.Path.FullName, serialized, encoding);
}
private static Dictionary<string, string> MetadataToObject(InfisicalSecret secret)
{
Dictionary<string, string> metadata = new Dictionary<string, string>();
if (secret.SecretMetadata == null) { return metadata; }
foreach (InfisicalSecretMetadata entry in secret.SecretMetadata)
{
if (entry == null || string.IsNullOrEmpty(entry.Key)) { continue; }
metadata[entry.Key] = entry.Value;
}
return metadata;
}
}
}
@@ -0,0 +1,68 @@
using System.IO;
using System.Text;
using System.Xml;
using PSInfisicalAPI.Common;
using PSInfisicalAPI.Errors;
using PSInfisicalAPI.Models;
namespace PSInfisicalAPI.Exports
{
public sealed class XmlInfisicalExporter : IInfisicalExporter
{
public void Export(InfisicalExportRequest request)
{
if (request == null || request.Secrets == null) { throw new InfisicalExportException("Export request is invalid."); }
if (request.Path == null) { throw new InfisicalExportException("Path is required for XML export."); }
InfisicalPath.EnsureParentDirectoryExists(request.Path);
Encoding encoding = request.Encoding ?? new UTF8Encoding(false);
XmlWriterSettings settings = new XmlWriterSettings
{
Indent = true,
Encoding = encoding,
OmitXmlDeclaration = false
};
using (FileStream stream = new FileStream(request.Path.FullName, FileMode.Create, FileAccess.Write))
using (XmlWriter writer = XmlWriter.Create(stream, settings))
{
writer.WriteStartDocument();
writer.WriteStartElement("Secrets");
foreach (InfisicalSecret secret in request.Secrets)
{
if (secret == null) { continue; }
secret.UsePlainTextValue(plainValue =>
{
writer.WriteStartElement("Secret");
writer.WriteElementString("SecretName", secret.SecretName ?? string.Empty);
writer.WriteElementString("SecretValue", plainValue ?? string.Empty);
writer.WriteElementString("SecretPath", secret.SecretPath ?? string.Empty);
writer.WriteStartElement("SecretMetadata");
if (secret.SecretMetadata != null)
{
foreach (InfisicalSecretMetadata entry in secret.SecretMetadata)
{
if (entry == null || string.IsNullOrEmpty(entry.Key)) { continue; }
writer.WriteStartElement("Metadata");
writer.WriteElementString("Key", entry.Key);
writer.WriteElementString("Value", entry.Value ?? string.Empty);
writer.WriteEndElement();
}
}
writer.WriteEndElement();
writer.WriteEndElement();
});
}
writer.WriteEndElement();
writer.WriteEndDocument();
}
}
}
}
@@ -0,0 +1,62 @@
using System.Collections.Generic;
using System.IO;
using System.Text;
using PSInfisicalAPI.Common;
using PSInfisicalAPI.Errors;
using PSInfisicalAPI.Models;
using YamlDotNet.Serialization;
namespace PSInfisicalAPI.Exports
{
public sealed class YamlInfisicalExporter : IInfisicalExporter
{
public void Export(InfisicalExportRequest request)
{
if (request == null || request.Secrets == null) { throw new InfisicalExportException("Export request is invalid."); }
if (request.Path == null) { throw new InfisicalExportException("Path is required for YAML export."); }
InfisicalPath.EnsureParentDirectoryExists(request.Path);
List<Dictionary<string, object>> entries = new List<Dictionary<string, object>>();
foreach (InfisicalSecret secret in request.Secrets)
{
if (secret == null) { continue; }
secret.UsePlainTextValue(plainValue =>
{
Dictionary<string, object> entry = new Dictionary<string, object>();
entry["SecretName"] = secret.SecretName;
entry["SecretValue"] = plainValue;
entry["SecretPath"] = secret.SecretPath;
entry["SecretMetadata"] = MetadataToDictionary(secret);
entries.Add(entry);
});
}
Dictionary<string, object> root = new Dictionary<string, object>();
root["Secrets"] = entries;
ISerializer serializer = new SerializerBuilder().Build();
string serialized = serializer.Serialize(root);
Encoding encoding = request.Encoding ?? new UTF8Encoding(false);
File.WriteAllText(request.Path.FullName, serialized, encoding);
}
private static Dictionary<string, string> MetadataToDictionary(InfisicalSecret secret)
{
Dictionary<string, string> metadata = new Dictionary<string, string>();
if (secret.SecretMetadata == null) { return metadata; }
foreach (InfisicalSecretMetadata entry in secret.SecretMetadata)
{
if (entry == null || string.IsNullOrEmpty(entry.Key)) { continue; }
metadata[entry.Key] = entry.Value;
}
return metadata;
}
}
}
@@ -0,0 +1,7 @@
namespace PSInfisicalAPI.Http
{
public interface IInfisicalHttpClient
{
InfisicalHttpResponse Send(InfisicalHttpRequest request);
}
}
@@ -0,0 +1,142 @@
using System;
using System.Collections.Generic;
using System.IO;
using System.Net;
using System.Text;
using PSInfisicalAPI.Errors;
using PSInfisicalAPI.Logging;
namespace PSInfisicalAPI.Http
{
public sealed class InfisicalHttpClient : IInfisicalHttpClient
{
private const string Component = "HttpClient";
private readonly IInfisicalLogger _logger;
private readonly int _timeoutSeconds;
public InfisicalHttpClient(IInfisicalLogger logger, int timeoutSeconds = 100)
{
_logger = logger ?? NullInfisicalLogger.Instance;
_timeoutSeconds = timeoutSeconds;
}
public InfisicalHttpResponse Send(InfisicalHttpRequest request)
{
if (request == null)
{
throw new ArgumentNullException(nameof(request));
}
if (request.Uri == null)
{
throw new InfisicalHttpException("Request URI must be provided.");
}
string operation = string.IsNullOrEmpty(request.OperationName) ? request.EndpointName : request.OperationName;
_logger.Verbose(Component, string.Concat("Attempting HTTP ", request.Method, " to ", request.Uri.GetLeftPart(UriPartial.Path), ". Please Wait..."));
HttpWebRequest webRequest = (HttpWebRequest)WebRequest.Create(request.Uri);
webRequest.Method = string.IsNullOrEmpty(request.Method) ? "GET" : request.Method.ToUpperInvariant();
webRequest.Accept = "application/json";
webRequest.UserAgent = "PSInfisicalAPI";
webRequest.Timeout = _timeoutSeconds * 1000;
webRequest.ReadWriteTimeout = _timeoutSeconds * 1000;
ApplyHeaders(webRequest, request.Headers);
if (!string.IsNullOrEmpty(request.Body))
{
byte[] bytes = Encoding.UTF8.GetBytes(request.Body);
webRequest.ContentType = string.IsNullOrEmpty(request.ContentType) ? "application/json" : request.ContentType;
webRequest.ContentLength = bytes.Length;
using (Stream requestStream = webRequest.GetRequestStream())
{
requestStream.Write(bytes, 0, bytes.Length);
}
}
InfisicalHttpResponse response = new InfisicalHttpResponse
{
ContainsSecretMaterialInResponse = request.ContainsSecretMaterialInResponse
};
try
{
using (HttpWebResponse webResponse = (HttpWebResponse)webRequest.GetResponse())
{
PopulateResponse(response, webResponse);
}
_logger.Verbose(Component, string.Concat("HTTP ", webRequest.Method, " completed with status ", response.StatusCode.ToString(System.Globalization.CultureInfo.InvariantCulture), "."));
return response;
}
catch (WebException webException)
{
HttpWebResponse errorResponse = webException.Response as HttpWebResponse;
if (errorResponse != null)
{
PopulateResponse(response, errorResponse);
errorResponse.Close();
return response;
}
throw new InfisicalHttpException(string.Concat("HTTP request failed: ", webException.Message), webException);
}
}
private static void ApplyHeaders(HttpWebRequest webRequest, IDictionary<string, string> headers)
{
if (headers == null)
{
return;
}
foreach (KeyValuePair<string, string> entry in headers)
{
if (string.IsNullOrEmpty(entry.Key))
{
continue;
}
string headerName = entry.Key;
string headerValue = entry.Value ?? string.Empty;
if (string.Equals(headerName, "Accept", StringComparison.OrdinalIgnoreCase)) { webRequest.Accept = headerValue; continue; }
if (string.Equals(headerName, "User-Agent", StringComparison.OrdinalIgnoreCase)) { webRequest.UserAgent = headerValue; continue; }
if (string.Equals(headerName, "Content-Type", StringComparison.OrdinalIgnoreCase)) { webRequest.ContentType = headerValue; continue; }
webRequest.Headers[headerName] = headerValue;
}
}
private static void PopulateResponse(InfisicalHttpResponse response, HttpWebResponse webResponse)
{
response.StatusCode = (int)webResponse.StatusCode;
response.ReasonPhrase = webResponse.StatusDescription;
response.Headers = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase);
foreach (string headerName in webResponse.Headers.AllKeys)
{
response.Headers[headerName] = webResponse.Headers[headerName];
}
using (Stream responseStream = webResponse.GetResponseStream())
{
if (responseStream != null)
{
using (StreamReader reader = new StreamReader(responseStream, Encoding.UTF8))
{
response.Body = reader.ReadToEnd();
}
}
else
{
response.Body = string.Empty;
}
}
}
}
}
@@ -0,0 +1,18 @@
using System;
using System.Collections.Generic;
namespace PSInfisicalAPI.Http
{
public sealed class InfisicalHttpRequest
{
public string OperationName { get; set; }
public string EndpointName { get; set; }
public string Method { get; set; }
public Uri Uri { get; set; }
public Dictionary<string, string> Headers { get; set; }
public string Body { get; set; }
public string ContentType { get; set; }
public bool ContainsSecretMaterialInRequest { get; set; }
public bool ContainsSecretMaterialInResponse { get; set; }
}
}
@@ -0,0 +1,22 @@
using System.Collections.Generic;
namespace PSInfisicalAPI.Http
{
public sealed class InfisicalHttpResponse
{
public int StatusCode { get; set; }
public string ReasonPhrase { get; set; }
public string Body { get; set; }
public Dictionary<string, string> Headers { get; set; }
public bool ContainsSecretMaterialInResponse { get; set; }
public void Clear()
{
Body = null;
if (Headers != null)
{
Headers.Clear();
}
}
}
}
@@ -0,0 +1,125 @@
using System;
using System.Collections.Generic;
using System.Text;
using PSInfisicalAPI.Endpoints;
using PSInfisicalAPI.Errors;
namespace PSInfisicalAPI.Http
{
public static class InfisicalUriBuilder
{
public static Uri Build(Uri baseUri, InfisicalEndpointDefinition definition, IDictionary<string, string> pathParameters, IEnumerable<KeyValuePair<string, string>> queryParameters)
{
if (baseUri == null)
{
throw new InfisicalConfigurationException("Base URI must be provided.");
}
if (definition == null)
{
throw new InfisicalConfigurationException("Endpoint definition must be provided.");
}
string template = definition.Template ?? string.Empty;
string resolvedPath = ResolvePathTemplate(template, pathParameters);
UriBuilder uriBuilder = new UriBuilder(baseUri);
uriBuilder.Path = CombinePaths(uriBuilder.Path, resolvedPath);
uriBuilder.Query = BuildQueryString(queryParameters);
return uriBuilder.Uri;
}
public static string BuildQueryString(IEnumerable<KeyValuePair<string, string>> queryParameters)
{
if (queryParameters == null)
{
return string.Empty;
}
StringBuilder builder = new StringBuilder();
bool first = true;
foreach (KeyValuePair<string, string> entry in queryParameters)
{
if (string.IsNullOrEmpty(entry.Key))
{
continue;
}
if (entry.Value == null)
{
continue;
}
if (!first)
{
builder.Append('&');
}
builder.Append(Uri.EscapeDataString(entry.Key));
builder.Append('=');
builder.Append(Uri.EscapeDataString(entry.Value));
first = false;
}
return builder.ToString();
}
public static string ResolvePathTemplate(string template, IDictionary<string, string> pathParameters)
{
if (string.IsNullOrEmpty(template))
{
return string.Empty;
}
if (pathParameters == null || pathParameters.Count == 0)
{
if (template.IndexOf('{') >= 0)
{
throw new InfisicalConfigurationException(string.Concat("Path template '", template, "' requires parameters but none were supplied."));
}
return template;
}
string resolved = template;
foreach (KeyValuePair<string, string> entry in pathParameters)
{
string token = string.Concat("{", entry.Key, "}");
if (resolved.IndexOf(token, StringComparison.Ordinal) < 0)
{
continue;
}
string escaped = Uri.EscapeDataString(entry.Value ?? string.Empty);
resolved = resolved.Replace(token, escaped);
}
if (resolved.IndexOf('{') >= 0)
{
throw new InfisicalConfigurationException(string.Concat("Path template '", template, "' has unresolved tokens."));
}
return resolved;
}
private static string CombinePaths(string left, string right)
{
string l = string.IsNullOrEmpty(left) ? "/" : left;
string r = right ?? string.Empty;
if (l.EndsWith("/", StringComparison.Ordinal) && r.StartsWith("/", StringComparison.Ordinal))
{
return string.Concat(l, r.Substring(1));
}
if (!l.EndsWith("/", StringComparison.Ordinal) && !r.StartsWith("/", StringComparison.Ordinal))
{
return string.Concat(l, "/", r);
}
return string.Concat(l, r);
}
}
}
@@ -0,0 +1,11 @@
namespace PSInfisicalAPI.Logging
{
public interface IInfisicalLogger
{
void Information(string component, string message);
void Verbose(string component, string message);
void Debug(string component, string message);
void Warning(string component, string message);
void Error(string component, string message);
}
}
@@ -0,0 +1,22 @@
using System;
using System.Globalization;
namespace PSInfisicalAPI.Logging
{
public static class InfisicalLogFormatter
{
public static string Format(DateTimeOffset utcTimestamp, InfisicalLogLevel level, string component, string message)
{
string timestamp = utcTimestamp.UtcDateTime.ToString("yyyy-MM-ddTHH:mm:ss.fffffffZ", CultureInfo.InvariantCulture);
string normalizedComponent = string.IsNullOrEmpty(component) ? "Unspecified" : component;
string normalizedMessage = message ?? string.Empty;
return string.Concat("[", timestamp, "] - [", level.ToString(), "] - [", normalizedComponent, "] - ", normalizedMessage);
}
public static string FormatNow(InfisicalLogLevel level, string component, string message)
{
return Format(DateTimeOffset.UtcNow, level, component, message);
}
}
}
@@ -0,0 +1,11 @@
namespace PSInfisicalAPI.Logging
{
public enum InfisicalLogLevel
{
Information,
Verbose,
Debug,
Warning,
Error
}
}
@@ -0,0 +1,15 @@
namespace PSInfisicalAPI.Logging
{
public sealed class NullInfisicalLogger : IInfisicalLogger
{
public static readonly NullInfisicalLogger Instance = new NullInfisicalLogger();
private NullInfisicalLogger() { }
public void Information(string component, string message) { }
public void Verbose(string component, string message) { }
public void Debug(string component, string message) { }
public void Warning(string component, string message) { }
public void Error(string component, string message) { }
}
}
@@ -0,0 +1,51 @@
using System;
using System.Management.Automation;
namespace PSInfisicalAPI.Logging
{
public sealed class PSCmdletLogger : IInfisicalLogger
{
private readonly Cmdlet _cmdlet;
public PSCmdletLogger(Cmdlet cmdlet)
{
_cmdlet = cmdlet ?? throw new ArgumentNullException(nameof(cmdlet));
}
public void Information(string component, string message)
{
string line = InfisicalLogFormatter.FormatNow(InfisicalLogLevel.Information, component, message);
_cmdlet.WriteVerbose(line);
}
public void Verbose(string component, string message)
{
string line = InfisicalLogFormatter.FormatNow(InfisicalLogLevel.Verbose, component, message);
_cmdlet.WriteVerbose(line);
}
public void Debug(string component, string message)
{
string line = InfisicalLogFormatter.FormatNow(InfisicalLogLevel.Debug, component, message);
_cmdlet.WriteDebug(line);
}
public void Warning(string component, string message)
{
string line = InfisicalLogFormatter.FormatNow(InfisicalLogLevel.Warning, component, message);
_cmdlet.WriteWarning(line);
}
public void Error(string component, string message)
{
string line = InfisicalLogFormatter.FormatNow(InfisicalLogLevel.Error, component, message);
ErrorRecord record = new ErrorRecord(
new InvalidOperationException(message ?? string.Empty),
"PSInfisicalAPI.Error",
ErrorCategory.NotSpecified,
component);
record.ErrorDetails = new ErrorDetails(line);
_cmdlet.WriteError(record);
}
}
}
@@ -0,0 +1,8 @@
namespace PSInfisicalAPI.Models
{
public enum InfisicalAuthType
{
UniversalAuth,
Token
}
}
@@ -0,0 +1,9 @@
namespace PSInfisicalAPI.Models
{
public enum InfisicalDuplicateKeyBehavior
{
Error,
FirstWins,
LastWins
}
}
@@ -0,0 +1,11 @@
namespace PSInfisicalAPI.Models
{
public enum InfisicalExportFormat
{
Json,
Yaml,
Env,
Xml,
EnvironmentVariables
}
}
@@ -0,0 +1,42 @@
using System;
using System.Security;
using PSInfisicalAPI.Security;
namespace PSInfisicalAPI.Models
{
public sealed class InfisicalSecret
{
public string Id { get; set; }
public string InternalId { get; set; }
public string Workspace { get; set; }
public string Environment { get; set; }
public int? Version { get; set; }
public InfisicalSecretType Type { get; set; }
public string SecretName { get; set; }
public SecureString SecretValue { get; set; }
public bool SecretValueHidden { get; set; }
public string SecretPath { get; set; }
public string SecretComment { get; set; }
public DateTimeOffset? CreatedAtUtc { get; set; }
public DateTimeOffset? UpdatedAtUtc { get; set; }
public bool IsRotatedSecret { get; set; }
public Guid? RotationId { get; set; }
public InfisicalSecretTag[] Tags { get; set; }
public InfisicalSecretMetadata[] SecretMetadata { get; set; }
public T UsePlainTextValue<T>(Func<string, T> action)
{
return SecureStringUtility.UsePlainText(SecretValue, action);
}
public void UsePlainTextValue(Action<string> action)
{
SecureStringUtility.UsePlainText(SecretValue, action);
}
public override string ToString()
{
return SecretName;
}
}
}
@@ -0,0 +1,8 @@
namespace PSInfisicalAPI.Models
{
public sealed class InfisicalSecretMetadata
{
public string Key { get; set; }
public string Value { get; set; }
}
}
@@ -0,0 +1,10 @@
namespace PSInfisicalAPI.Models
{
public sealed class InfisicalSecretTag
{
public string Id { get; set; }
public string Slug { get; set; }
public string Name { get; set; }
public string Color { get; set; }
}
}
@@ -0,0 +1,8 @@
namespace PSInfisicalAPI.Models
{
public enum InfisicalSecretType
{
Shared,
Personal
}
}
+37
View File
@@ -0,0 +1,37 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>netstandard2.0</TargetFramework>
<LangVersion>9.0</LangVersion>
<AssemblyName>PSInfisicalAPI</AssemblyName>
<RootNamespace>PSInfisicalAPI</RootNamespace>
<GenerateDocumentationFile>false</GenerateDocumentationFile>
<NoWarn>$(NoWarn);CS1591;NU1701</NoWarn>
<TreatWarningsAsErrors>false</TreatWarningsAsErrors>
<Version>$(BuildVersion)</Version>
<AssemblyVersion>$(BuildAssemblyVersion)</AssemblyVersion>
<FileVersion>$(BuildAssemblyVersion)</FileVersion>
<InformationalVersion>$(BuildVersion)</InformationalVersion>
<Company />
<Authors>Alphaeus Mote</Authors>
<Product>PSInfisicalAPI</Product>
<CopyLocalLockFileAssemblies>true</CopyLocalLockFileAssemblies>
<AppendTargetFrameworkToOutputPath>false</AppendTargetFrameworkToOutputPath>
<AppendRuntimeIdentifierToOutputPath>false</AppendRuntimeIdentifierToOutputPath>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="PowerShellStandard.Library" Version="5.1.1" PrivateAssets="all" />
<PackageReference Include="Newtonsoft.Json" Version="13.0.3" />
<PackageReference Include="YamlDotNet" Version="15.1.6" />
</ItemGroup>
<ItemGroup>
<AssemblyAttribute Include="System.Reflection.AssemblyMetadataAttribute">
<_Parameter1>CommitHash</_Parameter1>
<_Parameter2>$(BuildCommitHash)</_Parameter2>
</AssemblyAttribute>
<InternalsVisibleTo Include="PSInfisicalAPI.Tests" />
</ItemGroup>
</Project>
@@ -0,0 +1,58 @@
using System.Collections.Generic;
using Newtonsoft.Json;
namespace PSInfisicalAPI.Secrets
{
internal sealed class InfisicalSecretResponseDto
{
[JsonProperty("id")] public string Id { get; set; }
[JsonProperty("_id")] public string InternalId { get; set; }
[JsonProperty("workspace")] public string Workspace { get; set; }
[JsonProperty("environment")] public string Environment { get; set; }
[JsonProperty("version")] public int? Version { get; set; }
[JsonProperty("type")] public string Type { get; set; }
[JsonProperty("secretKey")] public string SecretKey { get; set; }
[JsonProperty("secretValue")] public string SecretValue { get; set; }
[JsonProperty("secretValueHidden")] public bool SecretValueHidden { get; set; }
[JsonProperty("secretPath")] public string SecretPath { get; set; }
[JsonProperty("secretComment")] public string SecretComment { get; set; }
[JsonProperty("createdAt")] public string CreatedAt { get; set; }
[JsonProperty("updatedAt")] public string UpdatedAt { get; set; }
[JsonProperty("isRotatedSecret")] public bool IsRotatedSecret { get; set; }
[JsonProperty("rotationId")] public string RotationId { get; set; }
[JsonProperty("tags")] public List<InfisicalSecretTagDto> Tags { get; set; }
[JsonProperty("secretMetadata")] public List<InfisicalSecretMetadataDto> SecretMetadata { get; set; }
}
internal sealed class InfisicalSecretTagDto
{
[JsonProperty("id")] public string Id { get; set; }
[JsonProperty("slug")] public string Slug { get; set; }
[JsonProperty("name")] public string Name { get; set; }
[JsonProperty("color")] public string Color { get; set; }
}
internal sealed class InfisicalSecretMetadataDto
{
[JsonProperty("key")] public string Key { get; set; }
[JsonProperty("value")] public string Value { get; set; }
}
internal sealed class InfisicalSecretListResponseDto
{
[JsonProperty("secrets")] public List<InfisicalSecretResponseDto> Secrets { get; set; }
[JsonProperty("imports")] public List<InfisicalSecretImportDto> Imports { get; set; }
}
internal sealed class InfisicalSecretImportDto
{
[JsonProperty("secretPath")] public string SecretPath { get; set; }
[JsonProperty("environment")] public string Environment { get; set; }
[JsonProperty("secrets")] public List<InfisicalSecretResponseDto> Secrets { get; set; }
}
internal sealed class InfisicalSecretSingleResponseDto
{
[JsonProperty("secret")] public InfisicalSecretResponseDto Secret { get; set; }
}
}
@@ -0,0 +1,150 @@
using System;
using System.Collections.Generic;
using System.Globalization;
using PSInfisicalAPI.Models;
using PSInfisicalAPI.Security;
namespace PSInfisicalAPI.Secrets
{
internal static class InfisicalSecretMapper
{
public static InfisicalSecret Map(InfisicalSecretResponseDto dto)
{
if (dto == null)
{
return null;
}
InfisicalSecret secret = new InfisicalSecret
{
Id = dto.Id,
InternalId = dto.InternalId,
Workspace = dto.Workspace,
Environment = dto.Environment,
Version = dto.Version,
Type = ParseType(dto.Type),
SecretName = dto.SecretKey,
SecretValue = SecureStringUtility.ToReadOnlySecureString(dto.SecretValue),
SecretValueHidden = dto.SecretValueHidden,
SecretPath = dto.SecretPath,
SecretComment = dto.SecretComment,
CreatedAtUtc = ParseTimestamp(dto.CreatedAt),
UpdatedAtUtc = ParseTimestamp(dto.UpdatedAt),
IsRotatedSecret = dto.IsRotatedSecret,
RotationId = ParseGuid(dto.RotationId),
Tags = MapTags(dto.Tags),
SecretMetadata = MapMetadata(dto.SecretMetadata)
};
dto.SecretValue = null;
return secret;
}
public static InfisicalSecret[] MapMany(IEnumerable<InfisicalSecretResponseDto> items)
{
if (items == null)
{
return Array.Empty<InfisicalSecret>();
}
List<InfisicalSecret> results = new List<InfisicalSecret>();
foreach (InfisicalSecretResponseDto dto in items)
{
InfisicalSecret mapped = Map(dto);
if (mapped != null)
{
results.Add(mapped);
}
}
return results.ToArray();
}
private static InfisicalSecretType ParseType(string value)
{
if (string.Equals(value, "personal", StringComparison.OrdinalIgnoreCase))
{
return InfisicalSecretType.Personal;
}
return InfisicalSecretType.Shared;
}
private static DateTimeOffset? ParseTimestamp(string value)
{
if (string.IsNullOrEmpty(value))
{
return null;
}
DateTimeOffset parsed;
if (DateTimeOffset.TryParse(value, CultureInfo.InvariantCulture, DateTimeStyles.AssumeUniversal | DateTimeStyles.AdjustToUniversal, out parsed))
{
return parsed;
}
return null;
}
private static Guid? ParseGuid(string value)
{
if (string.IsNullOrEmpty(value))
{
return null;
}
Guid parsed;
if (Guid.TryParse(value, out parsed))
{
return parsed;
}
return null;
}
private static InfisicalSecretTag[] MapTags(List<InfisicalSecretTagDto> tags)
{
if (tags == null || tags.Count == 0)
{
return Array.Empty<InfisicalSecretTag>();
}
InfisicalSecretTag[] mapped = new InfisicalSecretTag[tags.Count];
for (int index = 0; index < tags.Count; index++)
{
InfisicalSecretTagDto tag = tags[index];
mapped[index] = new InfisicalSecretTag
{
Id = tag.Id,
Slug = tag.Slug,
Name = tag.Name,
Color = tag.Color
};
}
return mapped;
}
private static InfisicalSecretMetadata[] MapMetadata(List<InfisicalSecretMetadataDto> metadata)
{
if (metadata == null || metadata.Count == 0)
{
return Array.Empty<InfisicalSecretMetadata>();
}
InfisicalSecretMetadata[] mapped = new InfisicalSecretMetadata[metadata.Count];
for (int index = 0; index < metadata.Count; index++)
{
InfisicalSecretMetadataDto entry = metadata[index];
mapped[index] = new InfisicalSecretMetadata
{
Key = entry.Key,
Value = entry.Value
};
}
return mapped;
}
}
}
@@ -0,0 +1,31 @@
using System.Collections.Generic;
namespace PSInfisicalAPI.Secrets
{
public sealed class InfisicalListSecretsQuery
{
public string ProjectId { get; set; }
public string Environment { get; set; }
public string SecretPath { get; set; }
public bool Recursive { get; set; }
public bool? IncludeImports { get; set; }
public bool IncludePersonalOverrides { get; set; }
public bool? ExpandSecretReferences { get; set; }
public bool? ViewSecretValue { get; set; }
public Dictionary<string, string> MetadataFilter { get; set; }
public string[] TagSlugs { get; set; }
}
public sealed class InfisicalRetrieveSecretQuery
{
public string SecretName { get; set; }
public string ProjectId { get; set; }
public string Environment { get; set; }
public string SecretPath { get; set; }
public int? Version { get; set; }
public string Type { get; set; }
public bool? ViewSecretValue { get; set; }
public bool? ExpandSecretReferences { get; set; }
public bool? IncludeImports { get; set; }
}
}
@@ -0,0 +1,199 @@
using System;
using System.Collections.Generic;
using System.Globalization;
using PSInfisicalAPI.Connections;
using PSInfisicalAPI.Endpoints;
using PSInfisicalAPI.Errors;
using PSInfisicalAPI.Http;
using PSInfisicalAPI.Logging;
using PSInfisicalAPI.Models;
using PSInfisicalAPI.Security;
using PSInfisicalAPI.Serialization;
namespace PSInfisicalAPI.Secrets
{
public sealed class InfisicalSecretsClient
{
private const string Component = "SecretsClient";
private readonly IInfisicalHttpClient _httpClient;
private readonly IInfisicalLogger _logger;
private readonly JsonInfisicalSerializer _serializer;
public InfisicalSecretsClient(IInfisicalHttpClient httpClient, IInfisicalLogger logger)
{
_httpClient = httpClient ?? throw new ArgumentNullException(nameof(httpClient));
_logger = logger ?? NullInfisicalLogger.Instance;
_serializer = new JsonInfisicalSerializer();
}
public InfisicalSecret[] List(InfisicalConnection connection, InfisicalListSecretsQuery query)
{
if (connection == null) { throw new ArgumentNullException(nameof(connection)); }
if (query == null) { throw new ArgumentNullException(nameof(query)); }
InfisicalEndpointDefinition definition = InfisicalEndpointRegistry.Get(InfisicalEndpointNames.ListSecrets);
List<KeyValuePair<string, string>> queryParameters = new List<KeyValuePair<string, string>>();
AddIfNotNull(queryParameters, "workspaceId", FirstNonEmpty(query.ProjectId, connection.ProjectId));
AddIfNotNull(queryParameters, "environment", FirstNonEmpty(query.Environment, connection.Environment));
AddIfNotNull(queryParameters, "secretPath", FirstNonEmpty(query.SecretPath, connection.DefaultSecretPath, "/"));
queryParameters.Add(new KeyValuePair<string, string>("recursive", query.Recursive ? "true" : "false"));
if (query.IncludeImports.HasValue) { queryParameters.Add(new KeyValuePair<string, string>("include_imports", query.IncludeImports.Value ? "true" : "false")); }
if (query.IncludePersonalOverrides) { queryParameters.Add(new KeyValuePair<string, string>("includePersonalOverrides", "true")); }
if (query.ExpandSecretReferences.HasValue) { queryParameters.Add(new KeyValuePair<string, string>("expandSecretReferences", query.ExpandSecretReferences.Value ? "true" : "false")); }
if (query.ViewSecretValue.HasValue) { queryParameters.Add(new KeyValuePair<string, string>("viewSecretValue", query.ViewSecretValue.Value ? "true" : "false")); }
if (query.TagSlugs != null)
{
foreach (string tag in query.TagSlugs)
{
AddIfNotNull(queryParameters, "tagSlugs", tag);
}
}
if (query.MetadataFilter != null)
{
foreach (KeyValuePair<string, string> meta in query.MetadataFilter)
{
AddIfNotNull(queryParameters, string.Concat("metadata[", meta.Key, "]"), meta.Value);
}
}
Uri uri = InfisicalUriBuilder.Build(connection.BaseUri, definition, null, queryParameters);
InfisicalHttpResponse response = ExecuteAuthorized(connection, definition, "RetrieveSecrets", uri, null);
try
{
_logger.Information(Component, "Attempting to retrieve Infisical secrets. Please Wait...");
EnsureSuccess(response, definition);
InfisicalSecretListResponseDto dto = _serializer.Deserialize<InfisicalSecretListResponseDto>(response.Body);
response.Clear();
InfisicalSecret[] mapped = InfisicalSecretMapper.MapMany(dto != null ? dto.Secrets : null);
_logger.Information(Component, "Infisical secrets retrieval was successful.");
return mapped;
}
catch (Exception)
{
_logger.Error(Component, "Infisical secrets retrieval failed.");
throw;
}
}
public InfisicalSecret Retrieve(InfisicalConnection connection, InfisicalRetrieveSecretQuery query)
{
if (connection == null) { throw new ArgumentNullException(nameof(connection)); }
if (query == null) { throw new ArgumentNullException(nameof(query)); }
if (string.IsNullOrEmpty(query.SecretName)) { throw new InfisicalConfigurationException("SecretName is required."); }
InfisicalEndpointDefinition definition = InfisicalEndpointRegistry.Get(InfisicalEndpointNames.RetrieveSecret);
Dictionary<string, string> pathParameters = new Dictionary<string, string> { { "secretName", query.SecretName } };
List<KeyValuePair<string, string>> queryParameters = new List<KeyValuePair<string, string>>();
AddIfNotNull(queryParameters, "workspaceId", FirstNonEmpty(query.ProjectId, connection.ProjectId));
AddIfNotNull(queryParameters, "environment", FirstNonEmpty(query.Environment, connection.Environment));
AddIfNotNull(queryParameters, "secretPath", FirstNonEmpty(query.SecretPath, connection.DefaultSecretPath, "/"));
AddIfNotNull(queryParameters, "type", string.IsNullOrEmpty(query.Type) ? "shared" : query.Type.ToLowerInvariant());
if (query.Version.HasValue) { queryParameters.Add(new KeyValuePair<string, string>("version", query.Version.Value.ToString(CultureInfo.InvariantCulture))); }
if (query.ViewSecretValue.HasValue) { queryParameters.Add(new KeyValuePair<string, string>("viewSecretValue", query.ViewSecretValue.Value ? "true" : "false")); }
if (query.ExpandSecretReferences.HasValue) { queryParameters.Add(new KeyValuePair<string, string>("expandSecretReferences", query.ExpandSecretReferences.Value ? "true" : "false")); }
if (query.IncludeImports.HasValue) { queryParameters.Add(new KeyValuePair<string, string>("include_imports", query.IncludeImports.Value ? "true" : "false")); }
Uri uri = InfisicalUriBuilder.Build(connection.BaseUri, definition, pathParameters, queryParameters);
InfisicalHttpResponse response = ExecuteAuthorized(connection, definition, "RetrieveSecret", uri, null);
try
{
_logger.Information(Component, string.Concat("Attempting to retrieve Infisical secret '", query.SecretName, "'. Please Wait..."));
EnsureSuccess(response, definition);
InfisicalSecretSingleResponseDto dto = _serializer.Deserialize<InfisicalSecretSingleResponseDto>(response.Body);
response.Clear();
InfisicalSecret mapped = InfisicalSecretMapper.Map(dto != null ? dto.Secret : null);
_logger.Information(Component, "Infisical secret retrieval was successful.");
return mapped;
}
catch (Exception)
{
_logger.Error(Component, "Infisical secret retrieval failed.");
throw;
}
}
private InfisicalHttpResponse ExecuteAuthorized(InfisicalConnection connection, InfisicalEndpointDefinition definition, string operationName, Uri uri, string body)
{
Dictionary<string, string> headers = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase);
headers["Accept"] = "application/json";
if (definition.RequiresAuthorization)
{
if (connection.AccessToken == null)
{
throw new InfisicalAuthenticationException("Connection does not contain an access token.");
}
SecureStringUtility.UsePlainText(connection.AccessToken, plainToken =>
{
headers["Authorization"] = string.Concat("Bearer ", plainToken ?? string.Empty);
});
}
InfisicalHttpRequest request = new InfisicalHttpRequest
{
OperationName = operationName,
EndpointName = definition.Name,
Method = definition.Method,
Uri = uri,
Headers = headers,
Body = body,
ContainsSecretMaterialInRequest = definition.ContainsSecretMaterialInRequest,
ContainsSecretMaterialInResponse = definition.ContainsSecretMaterialInResponse
};
return _httpClient.Send(request);
}
private static void EnsureSuccess(InfisicalHttpResponse response, InfisicalEndpointDefinition definition)
{
if (response.StatusCode >= 200 && response.StatusCode < 300)
{
return;
}
InfisicalApiException exception = new InfisicalApiException(string.Concat("Infisical API returned ", response.StatusCode.ToString(CultureInfo.InvariantCulture), " (", response.ReasonPhrase ?? string.Empty, ")."));
exception.StatusCode = response.StatusCode;
exception.ReasonPhrase = response.ReasonPhrase;
exception.EndpointName = definition.Name;
exception.RequestMethod = definition.Method;
exception.SanitizedBody = definition.ContainsSecretMaterialInResponse ? "[REDACTED]" : response.Body;
response.Clear();
throw exception;
}
private static void AddIfNotNull(List<KeyValuePair<string, string>> list, string key, string value)
{
if (!string.IsNullOrEmpty(value))
{
list.Add(new KeyValuePair<string, string>(key, value));
}
}
private static string FirstNonEmpty(params string[] values)
{
if (values == null) { return null; }
foreach (string value in values)
{
if (!string.IsNullOrEmpty(value))
{
return value;
}
}
return null;
}
}
}
@@ -0,0 +1,76 @@
using System;
using System.Runtime.InteropServices;
using System.Security;
namespace PSInfisicalAPI.Security
{
public static class SecureStringUtility
{
public static SecureString ToReadOnlySecureString(string value)
{
SecureString secureString = new SecureString();
if (!string.IsNullOrEmpty(value))
{
foreach (char character in value)
{
secureString.AppendChar(character);
}
}
secureString.MakeReadOnly();
return secureString;
}
public static SecureString EmptyReadOnly()
{
SecureString secureString = new SecureString();
secureString.MakeReadOnly();
return secureString;
}
public static T UsePlainText<T>(SecureString secureString, Func<string, T> action)
{
if (secureString == null)
{
throw new ArgumentNullException(nameof(secureString));
}
if (action == null)
{
throw new ArgumentNullException(nameof(action));
}
IntPtr pointer = IntPtr.Zero;
try
{
pointer = Marshal.SecureStringToBSTR(secureString);
string plainText = Marshal.PtrToStringBSTR(pointer);
return action(plainText);
}
finally
{
if (pointer != IntPtr.Zero)
{
Marshal.ZeroFreeBSTR(pointer);
}
}
}
public static void UsePlainText(SecureString secureString, Action<string> action)
{
if (action == null)
{
throw new ArgumentNullException(nameof(action));
}
UsePlainText<bool>(secureString, plainText =>
{
action(plainText);
return true;
});
}
}
}
@@ -0,0 +1,8 @@
namespace PSInfisicalAPI.Serialization
{
public interface IInfisicalSerializer
{
string Serialize<T>(T value);
T Deserialize<T>(string value);
}
}
@@ -0,0 +1,62 @@
using Newtonsoft.Json;
using Newtonsoft.Json.Serialization;
using PSInfisicalAPI.Errors;
namespace PSInfisicalAPI.Serialization
{
public sealed class JsonInfisicalSerializer : IInfisicalSerializer
{
private readonly JsonSerializerSettings _settings;
public JsonInfisicalSerializer(bool useCamelCase = true, bool indent = false)
{
_settings = new JsonSerializerSettings
{
NullValueHandling = NullValueHandling.Ignore,
Formatting = indent ? Formatting.Indented : Formatting.None,
DateParseHandling = DateParseHandling.DateTimeOffset
};
if (useCamelCase)
{
_settings.ContractResolver = new CamelCasePropertyNamesContractResolver();
}
}
public string Serialize<T>(T value)
{
try
{
return JsonConvert.SerializeObject(value, _settings);
}
catch (JsonException jsonException)
{
throw new InfisicalSerializationException(string.Concat("JSON serialization failed: ", jsonException.Message), jsonException);
}
}
public T Deserialize<T>(string value)
{
if (string.IsNullOrEmpty(value))
{
return default(T);
}
try
{
return JsonConvert.DeserializeObject<T>(value, _settings);
}
catch (JsonReaderException readerException)
{
InfisicalSerializationException exception = new InfisicalSerializationException(string.Concat("JSON deserialization failed: ", readerException.Message), readerException);
exception.LineNumber = readerException.LineNumber;
exception.LinePosition = readerException.LinePosition;
throw exception;
}
catch (JsonException jsonException)
{
throw new InfisicalSerializationException(string.Concat("JSON deserialization failed: ", jsonException.Message), jsonException);
}
}
}
}