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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
40 changes: 40 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -113,7 +113,47 @@
test_command="dotnet test LibGit2Sharp.sln --configuration Release -p:TargetFrameworks=${{ matrix.tfm }} --logger "GitHubActions" -p:ExtraDefine=LEAKS_IDENTIFYING"
docker run -t --rm --platform linux/${{ matrix.arch }} -v "$PWD:/app" -w /app -e OPENSSL_ENABLE_SHA1_SIGNATURES=1 gittools/build-images:${{ matrix.distro }}-sdk-${{ matrix.sdk }} sh -c "$git_command && $test_command"

ssh-validation:
name: SSH Validation / ${{ matrix.os }}
needs: [build]
if: github.event_name != 'schedule'
runs-on: ${{ matrix.os }}
strategy:
matrix:
os: [windows-2022, ubuntu-22.04]
fail-fast: false
steps:
- name: Checkout
uses: actions/checkout@v6
- name: Install .NET SDK
uses: actions/setup-dotnet@v5
with:
dotnet-version: 8.0.x
- name: Download package artifact
uses: actions/download-artifact@v8
with:
name: NuGet packages
path: staging
- name: Determine package version
id: pkg
shell: bash
run: |
PKG=$(find staging -name 'Octopus.LibGit2Sharp.*.nupkg' ! -name '*NativeBinaries*' | head -n 1)
if [ -z "$PKG" ]; then
echo "::error::Octopus.LibGit2Sharp package not found under staging/"
exit 1
fi
VERSION=$(basename "$PKG" | sed -E 's/^Octopus\.LibGit2Sharp\.([0-9].*)\.nupkg$/\1/')
echo "version=$VERSION" >> "$GITHUB_OUTPUT"
echo "Found Octopus.LibGit2Sharp $VERSION"
- name: Add local package source
shell: bash
run: dotnet nuget add source "$GITHUB_WORKSPACE/staging" --name local-build
- name: Run SSH validation
shell: pwsh
run: dotnet run --project SshCloneTestApp -c Release "/p:LibGit2SharpVersion=${{ steps.pkg.outputs.version }}"

nuget-push:

Check warning

Code scanning / CodeQL

Workflow does not contain permissions Medium

Actions job or workflow does not limit the permissions of the GITHUB_TOKEN. Consider setting an explicit permissions block, using the following as a minimal starting point: {contents: read}
name: Octopus NuGet Push
needs: [build, test, test-linux]
# && github.ref == 'refs/heads/octopus/master'
Expand Down
2 changes: 1 addition & 1 deletion LibGit2Sharp/LibGit2Sharp.csproj
Original file line number Diff line number Diff line change
Expand Up @@ -30,7 +30,7 @@
</PropertyGroup>

<ItemGroup>
<PackageReference Include="Octopus.LibGit2Sharp.NativeBinaries" Version="2.0.323-octopus.4" PrivateAssets="none" />
<PackageReference Include="Octopus.LibGit2Sharp.NativeBinaries" Version="2.0.323-octopus.1" PrivateAssets="none" />
<PackageReference Include="MinVer" Version="6.0.0" PrivateAssets="all" />
</ItemGroup>

Expand Down
143 changes: 143 additions & 0 deletions SshCloneTestApp/AuthProbe.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,143 @@
using System;
using System.Collections.Generic;
using System.IO;
using LibGit2Sharp;

namespace SshCloneTestApp;

/// <summary>The classified result of one clone attempt.</summary>
public enum Outcome
{
/// <summary>Authentication was rejected — the SSH pipeline worked end to end. PASS.</summary>
AuthFailure,

/// <summary>The key could not be parsed or its algorithm is unsupported by the backend. FAIL.</summary>
KeyParseOrUnsupported,

/// <summary>The clone unexpectedly succeeded with a throwaway key. FAIL.</summary>
UnexpectedSuccess,

/// <summary>The failure matched no known signature. FAIL — needs investigation/calibration.</summary>
Unknown,
}

/// <summary>The outcome of a probe plus a human-readable detail string.</summary>
public sealed record ProbeResult(Outcome Outcome, string Detail)
{
public bool IsPass => Outcome == Outcome.AuthFailure;
}

/// <summary>
/// Attempts an SSH clone with an in-memory key and classifies the result.
/// </summary>
public static class AuthProbe
{
// Signatures (matched case-insensitively) indicating the key could not be loaded or the
// algorithm is unsupported by the active crypto backend. CHECKED FIRST, because a
// backend may wrap a parse failure inside a generic "failed to authenticate" message.
private static readonly string[] ParseOrUnsupportedSignatures =
{
"extract public key",
"unable to extract",
"unsupported",
"unimplemented",
"invalid privatekey",
"unable to parse",
"failed to initialize ssh",
"could not load",
"wrong passphrase",
};

// Signatures indicating authentication was attempted and rejected (the expected outcome).
private static readonly string[] AuthFailureSignatures =
{
"authentication",
"authenticate",
"too many redirects or authentication replays",
"permission denied",
"combination invalid",
"username/publickey",
"username does not match",
"callback returned an invalid",
};

/// <summary>Clones <paramref name="url"/> with the given in-memory key and classifies the result.</summary>
public static ProbeResult Probe(string url, SshKeyGenerator.GeneratedKey key)
{
var options = new CloneOptions
{
FetchOptions =
{
CredentialsProvider = (_, userFromUrl, _) => new SshKeyMemoryCredentials
{
Username = string.IsNullOrEmpty(userFromUrl) ? "git" : userFromUrl,
PublicKey = key.PublicKey,
PrivateKey = key.PrivateKey,
Passphrase = string.Empty,
},
CertificateCheck = (_, _, _) => true, // accept the host key; part of "the process working"
},
};

string destination = Path.Combine(Path.GetTempPath(), "octossh-" + Path.GetRandomFileName());

try
{
Repository.Clone(url, destination, options);
return new ProbeResult(Outcome.UnexpectedSuccess,
"Clone succeeded with a throwaway key — the key must not be authorized.");
}
catch (Exception ex)
{
string message = Flatten(ex);
string lower = message.ToLowerInvariant();

foreach (var sig in ParseOrUnsupportedSignatures)
{
if (lower.Contains(sig))
{
return new ProbeResult(Outcome.KeyParseOrUnsupported, message);
}
}

foreach (var sig in AuthFailureSignatures)
{
if (lower.Contains(sig))
{
return new ProbeResult(Outcome.AuthFailure, message);
}
}

return new ProbeResult(Outcome.Unknown, message);
}
finally
{
TryDelete(destination);
}
}

private static string Flatten(Exception ex)
{
var parts = new List<string>();
for (Exception? e = ex; e != null; e = e.InnerException)
{
parts.Add($"{e.GetType().Name}: {e.Message}");
}
return string.Join(" | ", parts);
}

private static void TryDelete(string path)
{
try
{
if (Directory.Exists(path))
{
Directory.Delete(path, recursive: true);
}
}
catch
{
// best-effort cleanup of the (empty/partial) clone target
}
}
}
31 changes: 31 additions & 0 deletions SshCloneTestApp/KeySpec.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
using System.Collections.Generic;

namespace SshCloneTestApp;

/// <summary>
/// Describes one SSH key type the harness exercises.
/// </summary>
/// <param name="Label">Human-readable identifier used in output and key comments.</param>
/// <param name="SshKeygenType">The value passed to <c>ssh-keygen -t</c>.</param>
/// <param name="Bits">Value for <c>ssh-keygen -b</c>, or null to omit the flag.</param>
/// <param name="UsePemFormat">When true, emit a classic PEM key (<c>-m PEM</c>) for broad
/// libssh2 compatibility. ED25519 has no PEM form and must use the native OpenSSH format.</param>
/// <param name="SupportedOnWindows">False for key types the libssh2 WinCNG backend cannot
/// handle (ED25519), which the harness skips on Windows.</param>
public sealed record KeySpec(
string Label,
string SshKeygenType,
int? Bits,
bool UsePemFormat,
bool SupportedOnWindows)
{
/// <summary>
/// The full key-type matrix. ED25519 is unsupported by the libssh2 WinCNG backend.
/// </summary>
public static IReadOnlyList<KeySpec> All { get; } = new[]
{
new KeySpec("rsa-4096", "rsa", 4096, UsePemFormat: true, SupportedOnWindows: true),
new KeySpec("ecdsa-nistp256", "ecdsa", 256, UsePemFormat: true, SupportedOnWindows: true),
new KeySpec("ed25519", "ed25519", null, UsePemFormat: false, SupportedOnWindows: false),
};
}
76 changes: 76 additions & 0 deletions SshCloneTestApp/Program.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,76 @@
using System;
using System.Collections.Generic;
using System.Runtime.InteropServices;
using LibGit2Sharp;

namespace SshCloneTestApp;

public static class Program
{
private const string RepoUrl = "git@github.com:OctopusDeploy/libgit2sharp.git";

public static int Main()
{
Console.WriteLine($"libgit2 version : {GlobalSettings.Version}");
Console.WriteLine($"features : {GlobalSettings.Version.Features}");
Console.WriteLine($"repository url : {RepoUrl}");
Console.WriteLine($"os : {RuntimeInformation.OSDescription}");
Console.WriteLine();
Console.WriteLine("Each key is freshly generated and unknown to the server, so an");
Console.WriteLine("AUTHENTICATION FAILURE is the expected (passing) outcome: it proves the");
Console.WriteLine("SSH transport, host-key exchange and in-memory key handling all worked.");
Console.WriteLine();

bool isWindows = RuntimeInformation.IsOSPlatform(OSPlatform.Windows);

var rows = new List<(string Label, string Status, string Detail)>();
bool allPassed = true;

foreach (var spec in KeySpec.All)
{
if (isWindows && !spec.SupportedOnWindows)
{
Console.WriteLine($"=== {spec.Label}: SKIPPED — WinCNG has no {spec.SshKeygenType} support ===");
Console.WriteLine();
rows.Add((spec.Label, "SKIPPED", "WinCNG backend has no support for this key type"));
continue;
}

Console.WriteLine($"=== {spec.Label} ===");
try
{
var key = SshKeyGenerator.Generate(spec);
Console.WriteLine($"generated key : {spec.Label} ({key.Comment})");

var result = AuthProbe.Probe(RepoUrl, key);
string status = result.IsPass ? "PASS" : "FAIL";
if (!result.IsPass)
{
allPassed = false;
}

Console.WriteLine($"outcome : {result.Outcome} -> {status}");
Console.WriteLine($"detail : {result.Detail}");
rows.Add((spec.Label, $"{status} ({result.Outcome})", result.Detail));
}
catch (Exception ex)
{
allPassed = false;
Console.Error.WriteLine($"harness error : {ex.GetType().Name}: {ex.Message}");
rows.Add((spec.Label, "FAIL (harness error)", ex.Message));
}

Console.WriteLine();
}

Console.WriteLine("==================== SUMMARY ====================");
foreach (var row in rows)
{
Console.WriteLine($" {row.Label,-16} {row.Status}");
}
Console.WriteLine("================================================");
Console.WriteLine(allPassed ? "RESULT: PASS" : "RESULT: FAIL");

return allPassed ? 0 : 1;
}
}
18 changes: 18 additions & 0 deletions SshCloneTestApp/SshCloneTestApp.csproj
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
<Project Sdk="Microsoft.NET.Sdk">

<PropertyGroup>
<TargetFramework>net8.0</TargetFramework>
<OutputType>Exe</OutputType>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
<RootNamespace>SshCloneTestApp</RootNamespace>
<!-- This harness validates the *published* package, so the committed state references
the package only. CI supplies the exact version: /p:LibGit2SharpVersion=<version>.
For a local run, pack the library and pass the same property. -->
</PropertyGroup>

<ItemGroup>
<PackageReference Include="Octopus.LibGit2Sharp" Version="$(LibGit2SharpVersion)" />
</ItemGroup>

</Project>
Loading
Loading