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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
128 changes: 128 additions & 0 deletions src/Netclaw.Security.Tests/ShellApprovalMatcherTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -645,6 +645,7 @@ public sealed class ShellApprovalMatcherPathExtractionTests
/// runners instead of hiding the gap behind an early-return.
/// </summary>
public static bool IsPosix => !OperatingSystem.IsWindows();
public static bool IsMacOs => OperatingSystem.IsMacOS();

[SlopwatchSuppress("SW001", "This theory verifies Bash parser path scopes, which do not apply to the Windows shell parser.")]
[Theory(SkipUnless = nameof(IsPosix), Skip = "POSIX-only path semantics")]
Expand Down Expand Up @@ -1185,6 +1186,133 @@ public void Redirect_to_symlink_target_fails_closed()
}
}

[Fact(SkipUnless = nameof(IsPosix), Skip = "POSIX-only path semantics")]
public void Redirect_through_symlink_below_working_directory_fails_closed()
{
var root = Path.Combine(Path.GetTempPath(), $"netclaw-redirect-escape-{Guid.NewGuid():N}");
var projectDirectory = Path.Combine(root, "project");
var externalDirectory = Path.Combine(root, "external");
var linkDirectory = Path.Combine(projectDirectory, "link");
Directory.CreateDirectory(projectDirectory);
Directory.CreateDirectory(externalDirectory);
Directory.CreateSymbolicLink(linkDirectory, externalDirectory);

try
{
var arguments = Args("git status > link/result.log", projectDirectory);

Assert.Empty(_matcher.ExtractCandidates(
new ToolName("shell_execute"),
arguments));
Assert.True(_matcher.IsMessy(
new ToolName("shell_execute"),
arguments));
}
finally
{
Directory.Delete(linkDirectory);
Directory.Delete(root, recursive: true);
}
}

[Fact(SkipUnless = nameof(IsPosix), Skip = "POSIX-only path semantics")]
public void Redirect_through_symlink_before_parent_segment_fails_closed()
{
var root = Path.Combine(Path.GetTempPath(), $"netclaw-redirect-parent-{Guid.NewGuid():N}");
var projectDirectory = Path.Combine(root, "project");
var externalDirectory = Path.Combine(root, "external", "nested");
var linkDirectory = Path.Combine(projectDirectory, "link");
Directory.CreateDirectory(projectDirectory);
Directory.CreateDirectory(externalDirectory);
Directory.CreateSymbolicLink(linkDirectory, externalDirectory);

try
{
var arguments = Args("git status > link/../result.log", projectDirectory);

Assert.Empty(_matcher.ExtractCandidates(
new ToolName("shell_execute"),
arguments));
Assert.True(_matcher.IsMessy(
new ToolName("shell_execute"),
arguments));
}
finally
{
Directory.Delete(linkDirectory);
Directory.Delete(root, recursive: true);
}
}

[Fact(SkipUnless = nameof(IsPosix), Skip = "POSIX-only path semantics")]
public void Redirect_from_symlink_working_directory_fails_closed()
{
var root = Path.Combine(Path.GetTempPath(), $"netclaw-redirect-cwd-{Guid.NewGuid():N}");
var realProjectDirectory = Path.Combine(root, "real-project");
var linkProjectDirectory = Path.Combine(root, "link-project");
Directory.CreateDirectory(realProjectDirectory);
Directory.CreateSymbolicLink(linkProjectDirectory, realProjectDirectory);

try
{
var arguments = Args("git status > result.log", linkProjectDirectory);

Assert.Empty(_matcher.ExtractCandidates(
new ToolName("shell_execute"),
arguments));
Assert.True(_matcher.IsMessy(
new ToolName("shell_execute"),
arguments));
}
finally
{
Directory.Delete(linkProjectDirectory);
Directory.Delete(root, recursive: true);
}
}

[SlopwatchSuppress("SW001", "This test verifies macOS system root aliases and runs on the macOS CI lane.")]
[Fact(SkipUnless = nameof(IsMacOs), Skip = "macOS root-alias semantics")]
public void Redirect_to_macos_root_alias_itself_fails_closed()
{
var arguments = Args("git status > /var", Path.GetTempPath());

Assert.Empty(_matcher.ExtractCandidates(
new ToolName("shell_execute"),
arguments));
Assert.True(_matcher.IsMessy(
new ToolName("shell_execute"),
arguments));
}

[SlopwatchSuppress("SW001", "These tests verify macOS system root aliases and run on the macOS CI lane.")]
[Theory(SkipUnless = nameof(IsMacOs), Skip = "macOS root-alias semantics")]
[InlineData("git status > result.log", false)]
[InlineData("git status > ../result.log", true)]
public void Relative_redirect_from_macos_root_alias_handles_parent_traversal(
string command,
bool expectedMessy)
{
var arguments = Args(command, "/tmp");

Assert.Equal(expectedMessy, _matcher.IsMessy(
new ToolName("shell_execute"),
arguments));
if (expectedMessy)
{
Assert.Empty(_matcher.ExtractCandidates(
new ToolName("shell_execute"),
arguments));
}
else
{
var candidate = Assert.Single(_matcher.ExtractCandidates(
new ToolName("shell_execute"),
arguments));
Assert.Equal("/tmp", candidate.Directory);
}
}

[Fact(SkipUnless = nameof(IsPosix), Skip = "POSIX-only path semantics")]
public void ExtractCandidates_prefers_explicit_path_arg_over_cd_attribution()
{
Expand Down
193 changes: 185 additions & 8 deletions src/Netclaw.Security/IToolApprovalMatcher.cs
Original file line number Diff line number Diff line change
Expand Up @@ -111,6 +111,7 @@ public sealed class ShellApprovalMatcher : IToolApprovalMatcher
public static readonly ShellApprovalMatcher Instance = new();

private static readonly ShellCommandAnalyzer Analyzer = ShellCommandAnalyzer.Bash;
private static readonly string[] MacOsSystemRootAliases = ["/etc", "/tmp", "/var"];

public string GetApprovalModeKey(ToolName toolName, IDictionary<string, object?>? arguments)
=> toolName.Value;
Expand Down Expand Up @@ -300,7 +301,10 @@ private static IReadOnlyList<ApprovalCandidate> ExtractCandidatesViaBashAnalysis

foreach (var redirect in occurrence.Redirects)
{
var redirectDirectories = ResolveRedirectDirectories(redirect);
var redirectDirectories = ResolveRedirectDirectories(
redirect,
clause,
clauseWorkingDirectory);
if (redirectDirectories is null)
return null;

Expand Down Expand Up @@ -464,14 +468,24 @@ or NotSupportedException
: null;

private static IReadOnlyList<string>? ResolveRedirectDirectories(
ShellSyntaxTree.RedirectAnalysis redirect)
ShellSyntaxTree.RedirectAnalysis redirect,
ShellSyntaxTree.Clause clause,
string? workingDirectory)
{
if (!redirect.IsPathRelevant)
return [];

if (!redirect.IsComplete)
return null;

if (ContainsSymlinkBeforeLexicalNormalization(
clause,
redirect.RedirectIndex,
workingDirectory))
{
return null;
}

if (redirect.Target.Kind == ShellSyntaxTree.ShellValueDomainKind.Pattern)
{
var coveringDirectory = redirect.Target.CoveringDirectory;
Expand All @@ -497,9 +511,11 @@ or NotSupportedException
try
{
var normalizedTarget = PathUtility.Normalize(target);
var pathRoot = Path.GetPathRoot(normalizedTarget);
if (string.IsNullOrWhiteSpace(pathRoot)
|| PathUtility.ContainsSymlinkSegment(pathRoot, normalizedTarget))
var symlinkBoundary = ResolveSymlinkBoundary(
normalizedTarget,
allowMacOsAliasEquality: false);
if (symlinkBoundary is null
|| PathUtility.ContainsSymlinkSegment(symlinkBoundary, normalizedTarget))
{
return null;
}
Expand All @@ -520,6 +536,169 @@ or UnauthorizedAccessException
return directories;
}

private static bool ContainsSymlinkBeforeLexicalNormalization(
ShellSyntaxTree.Clause clause,
int redirectIndex,
string? workingDirectory)
{
if (redirectIndex < 0)
return true;

ShellSyntaxTree.ClauseElement? redirectElement = null;
var currentRedirectIndex = 0;
foreach (var element in clause.Elements)
{
if (element.Role != ShellSyntaxTree.ClauseElementRole.Redirect)
continue;

if (currentRedirectIndex == redirectIndex)
{
redirectElement = element;
break;
}

currentRedirectIndex++;
}

if (redirectElement is null
|| !redirectElement.IsPath
|| redirectElement.Kind == ShellSyntaxTree.ArgKind.DynamicSkip
|| string.IsNullOrWhiteSpace(redirectElement.Value))
{
return true;
}

try
{
var authoredPath = redirectElement.Value;
var rooted = Path.IsPathRooted(authoredPath);
if (!rooted && string.IsNullOrWhiteSpace(workingDirectory))
return true;

if (rooted && authoredPath.StartsWith("//", StringComparison.Ordinal))
return true;

var root = rooted ? Path.GetPathRoot(authoredPath) : null;
if (rooted && string.IsNullOrWhiteSpace(root))
return true;

var remainder = rooted
? authoredPath[root!.Length..]
: authoredPath;
var segments = remainder.Split(
Path.DirectorySeparatorChar,
StringSplitOptions.RemoveEmptyEntries);
var currentPath = root!;
if (!rooted)
{
currentPath = PathUtility.Normalize(workingDirectory!);
var workingDirectoryBoundary = ResolveSymlinkBoundary(
currentPath,
allowMacOsAliasEquality: true);
if (workingDirectoryBoundary is null
|| PathUtility.ContainsSymlinkSegment(
workingDirectoryBoundary,
currentPath))
{
return true;
}

if ((authoredPath == "~"
|| authoredPath.StartsWith("~/", StringComparison.Ordinal))
&& segments.Contains("..", StringComparer.Ordinal))
{
return true;
}

if (OperatingSystem.IsMacOS()
&& segments.Contains("..", StringComparer.Ordinal)
&& MacOsSystemRootAliases.Any(alias =>
PathUtility.IsNormalizedWithinRoot(currentPath, alias)))
{
return true;
}
}
var firstSegment = 0;

// A parent segment after a symlink has different semantics from
// lexical Path.GetFullPath normalization. Only skip macOS's stable
// root alias when no later parent traversal can observe its target.
if (rooted
&& OperatingSystem.IsMacOS()
&& segments.Length > 1
&& !segments.Contains("..", StringComparer.Ordinal))
{
foreach (var alias in MacOsSystemRootAliases)
{
if (segments[0].AsSpan().Equals(
alias.AsSpan(1),
StringComparison.Ordinal))
{
currentPath = alias;
firstSegment = 1;
break;
}
}
}

for (var index = firstSegment; index < segments.Length; index++)
{
var segment = segments[index];
if (segment == ".")
continue;

if (segment == "..")
{
currentPath = Path.GetDirectoryName(currentPath) ?? currentPath;
continue;
}

currentPath = Path.Combine(currentPath, segment);
if (!File.Exists(currentPath) && !Directory.Exists(currentPath))
continue;

if ((File.GetAttributes(currentPath) & FileAttributes.ReparsePoint) != 0)
return true;
}

return false;
}
catch (Exception ex) when (ex is ArgumentException
or IOException
or NotSupportedException
or PathTooLongException
or UnauthorizedAccessException
or System.Security.SecurityException)
{
return true;
}
}

private static string? ResolveSymlinkBoundary(
string normalizedPath,
bool allowMacOsAliasEquality)
{
var pathRoot = Path.GetPathRoot(normalizedPath);
if (string.IsNullOrWhiteSpace(pathRoot))
return null;

if (OperatingSystem.IsMacOS())
{
// macOS publishes these stable root aliases as symlinks into
// /private. They are outside application-controlled scope and
// appear in ordinary temp and system paths. Start below the alias
// so later, writable symlink segments still fail closed.
foreach (var alias in MacOsSystemRootAliases)
{
if ((allowMacOsAliasEquality || normalizedPath.Length > alias.Length)
&& PathUtility.IsNormalizedWithinRoot(normalizedPath, alias))
return alias;
}
}

return pathRoot;
}

/// <summary>
/// Splits a POSIX command into approval-unit strings via BashParser:
/// one unit per statement, with consecutive <c>|</c> clauses folded into
Expand Down Expand Up @@ -787,9 +966,7 @@ public bool IsMessy(ToolName toolName, IDictionary<string, object?>? arguments)
return true;
}

return analysis.Commands
.SelectMany(static command => command.Redirects)
.Any(static redirect => ResolveRedirectDirectories(redirect) is null);
return false;
}

private static bool IsSideEffectCommand(ShellSyntaxTree.CommandOccurrence occurrence)
Expand Down
Loading