mirror of
https://github.com/peass-ng/PEASS-ng.git
synced 2026-11-07 09:25:57 -10:00
Add current-user AI storage permission audits (#773)
This commit is contained in:
1 parent
95505dc15b
commit
fd830f0c26
7 files changed
+300
No files matched your search
@@ -0,0 +1,59 @@
|
||||
# Title: Interesting Files - AI Storage Permissions
|
||||
# ID: IF_AI_storage_permissions
|
||||
# Author: PEASS-ng contributors
|
||||
# Last Update: 11-10-2026
|
||||
# Description: Review current-user AI storage directory permissions without reading stored data
|
||||
# License: GNU GPL
|
||||
# Version: 1.0
|
||||
# Mitre: T1083
|
||||
# Functions Used: print_2title
|
||||
# Global Variables: $HOME, $SEARCH_IN_FOLDER
|
||||
# Initial Functions:
|
||||
# Generated Global Variables: $ai_mode, $ai_shared, $ai_relative, $ai_link, $ai_tool, $ai_found, $ai_path, $ai_ancestor
|
||||
# Fat linpeas: 0
|
||||
# Small linpeas: 0
|
||||
|
||||
if [ -z "$SEARCH_IN_FOLDER" ] && [ -n "$HOME" ]; then
|
||||
print_2title "Current-user AI storage directory permissions" "T1083"
|
||||
(
|
||||
case "$HOME" in /*) ;; *) exit ;; esac
|
||||
printf '%s\n' "Directory modes only; review ACLs separately. Stored data may include private conversations and configuration."
|
||||
ai_found=0
|
||||
while IFS='|' read -r ai_tool ai_relative; do
|
||||
ai_path="$HOME/$ai_relative"
|
||||
# Check every ancestor before inspecting the directory. Do not follow links.
|
||||
ai_ancestor="$ai_path"
|
||||
ai_link=0
|
||||
while [ "$ai_ancestor" != / ] && [ -n "$ai_ancestor" ]; do
|
||||
if [ -L "$ai_ancestor" ]; then ai_link=1; break; fi
|
||||
ai_ancestor=${ai_ancestor%/*}
|
||||
done
|
||||
if [ "$ai_link" = 1 ]; then
|
||||
printf ' %s: skipped symbolic link in directory path\n' "$ai_tool"
|
||||
continue
|
||||
fi
|
||||
[ -d "$ai_path" ] || continue
|
||||
ai_found=1
|
||||
ai_mode=$(LC_ALL=C ls -ld "$ai_path" 2>/dev/null | awk '{print $1}')
|
||||
if [ -z "$ai_mode" ]; then
|
||||
printf ' %s: directory permissions unavailable\n' "$ai_tool"
|
||||
continue
|
||||
fi
|
||||
printf ' %s: %s (%s)\n' "$ai_tool" "$ai_path" "$ai_mode"
|
||||
ai_shared=$(printf '%s' "$ai_mode" | cut -c5-10)
|
||||
case "$ai_shared" in
|
||||
*[rwxst]*) printf ' Review group/other access to potentially sensitive AI storage.\n' ;;
|
||||
esac
|
||||
done <<'AI_STORAGE_ROOTS'
|
||||
Codex|.codex
|
||||
Claude Code|.claude
|
||||
Cursor CLI|.cursor
|
||||
Cursor desktop (Linux)|.config/Cursor
|
||||
Cursor desktop (macOS)|Library/Application Support/Cursor
|
||||
Antigravity CLI|.gemini/antigravity-cli
|
||||
Grok|.grok
|
||||
AI_STORAGE_ROOTS
|
||||
[ "$ai_found" = 1 ] || printf '%s\n' " No supported storage directories found."
|
||||
)
|
||||
echo ""
|
||||
fi
|
||||
@@ -0,0 +1,64 @@
|
||||
import os
|
||||
import shlex
|
||||
import subprocess
|
||||
import tempfile
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
class AIStoragePermissionsTests(unittest.TestCase):
|
||||
module = (Path(__file__).resolve().parents[1] / "builder" / "linpeas_parts"
|
||||
/ "9_interesting_files" / "30_AI_storage_permissions.sh")
|
||||
|
||||
def run_audit(self, home, search_in_folder=""):
|
||||
script = ("print_2title() { printf '%s\\n' \"$1\"; }\n"
|
||||
+ ". " + shlex.quote(str(self.module)))
|
||||
env = dict(os.environ, HOME=str(home), SEARCH_IN_FOLDER=search_in_folder)
|
||||
return subprocess.run(["sh", "-c", script], env=env, capture_output=True,
|
||||
text=True, timeout=5, check=True).stdout
|
||||
|
||||
def test_private_and_shared_directories_without_content_disclosure(self):
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
home = Path(tmp).resolve() / "home with spaces"
|
||||
private = home / ".codex"
|
||||
shared = home / ".grok"
|
||||
private.mkdir(parents=True)
|
||||
shared.mkdir()
|
||||
private.chmod(0o700)
|
||||
shared.chmod(0o770)
|
||||
(shared / "private-data.txt").write_text("SECRET_FIXTURE_DO_NOT_PRINT")
|
||||
# Opening this pipe would block: the audit must never enumerate/read bodies.
|
||||
os.mkfifo(shared / "stored-data-pipe")
|
||||
output = self.run_audit(home)
|
||||
self.assertIn(str(private), output)
|
||||
self.assertIn(str(shared), output)
|
||||
self.assertEqual(output.count("Review group/other access"), 1)
|
||||
self.assertNotIn("SECRET_FIXTURE_DO_NOT_PRINT", output)
|
||||
self.assertNotIn("private-data.txt", output)
|
||||
self.assertNotIn("stored-data-pipe", output)
|
||||
|
||||
def test_symlinked_root_and_ancestor_are_skipped(self):
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
home = Path(tmp).resolve() / "home"
|
||||
outside = Path(tmp).resolve() / "outside"
|
||||
home.mkdir()
|
||||
(outside / "antigravity-cli").mkdir(parents=True)
|
||||
(home / ".codex").symlink_to(outside, target_is_directory=True)
|
||||
(home / ".gemini").symlink_to(outside, target_is_directory=True)
|
||||
output = self.run_audit(home)
|
||||
self.assertEqual(output.count("skipped symbolic link"), 2)
|
||||
self.assertNotIn(str(outside), output)
|
||||
|
||||
def test_folder_scoped_scan_does_not_inspect_home(self):
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
home = Path(tmp).resolve()
|
||||
(home / ".claude").mkdir()
|
||||
self.assertEqual(self.run_audit(home, str(home)), "")
|
||||
|
||||
def test_missing_roots_are_reported(self):
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
self.assertIn("No supported storage directories found", self.run_audit(Path(tmp).resolve()))
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -0,0 +1,79 @@
|
||||
using Microsoft.VisualStudio.TestTools.UnitTesting;
|
||||
using System;
|
||||
using System.IO;
|
||||
using System.Security.AccessControl;
|
||||
using System.Security.Principal;
|
||||
using winPEAS.Info.FilesInfo;
|
||||
|
||||
namespace winPEAS.Tests
|
||||
{
|
||||
[TestClass]
|
||||
public class AIStorageAuditTests
|
||||
{
|
||||
[TestMethod]
|
||||
public void ReparseAncestorStopsInspectionBeforeRedirectedChildren()
|
||||
{
|
||||
string profile = @"C:\Users\example";
|
||||
string path = Path.Combine(profile, ".gemini", "antigravity-cli");
|
||||
bool checkedChild = false;
|
||||
bool result = AIStorageAudit.HasReparsePoint(path, candidate =>
|
||||
{
|
||||
if (candidate == path || candidate == Path.Combine(profile, ".gemini"))
|
||||
checkedChild = true;
|
||||
return candidate == profile ? FileAttributes.ReparsePoint : FileAttributes.Directory;
|
||||
});
|
||||
Assert.IsTrue(result);
|
||||
Assert.IsFalse(checkedChild);
|
||||
}
|
||||
|
||||
[TestMethod]
|
||||
public void BroadReadAndWriteAllowsRequireReview()
|
||||
{
|
||||
var security = new DirectorySecurity();
|
||||
security.AddAccessRule(new FileSystemAccessRule(
|
||||
new SecurityIdentifier(WellKnownSidType.WorldSid, null),
|
||||
FileSystemRights.ReadData, AccessControlType.Allow));
|
||||
security.AddAccessRule(new FileSystemAccessRule(
|
||||
new SecurityIdentifier(WellKnownSidType.BuiltinUsersSid, null),
|
||||
FileSystemRights.WriteData, AccessControlType.Allow));
|
||||
Assert.AreEqual(2, AIStorageAudit.GetBroadAccessRules(security).Count);
|
||||
}
|
||||
|
||||
[TestMethod]
|
||||
public void DeniesAdministratorsAndInheritOnlyRulesAreNotBroadRootAllows()
|
||||
{
|
||||
var security = new DirectorySecurity();
|
||||
security.AddAccessRule(new FileSystemAccessRule(
|
||||
new SecurityIdentifier(WellKnownSidType.WorldSid, null),
|
||||
FileSystemRights.ReadData, AccessControlType.Deny));
|
||||
security.AddAccessRule(new FileSystemAccessRule(
|
||||
new SecurityIdentifier(WellKnownSidType.BuiltinAdministratorsSid, null),
|
||||
FileSystemRights.FullControl, AccessControlType.Allow));
|
||||
security.AddAccessRule(new FileSystemAccessRule(
|
||||
new SecurityIdentifier(WellKnownSidType.AuthenticatedUserSid, null),
|
||||
FileSystemRights.ReadData, InheritanceFlags.ContainerInherit,
|
||||
PropagationFlags.InheritOnly, AccessControlType.Allow));
|
||||
Assert.AreEqual(0, AIStorageAudit.GetBroadAccessRules(security).Count);
|
||||
}
|
||||
|
||||
[TestMethod]
|
||||
public void DirectoryCheckDoesNotReadStoredFiles()
|
||||
{
|
||||
string path = Path.Combine(Path.GetTempPath(), Guid.NewGuid().ToString("N"));
|
||||
Directory.CreateDirectory(path);
|
||||
try
|
||||
{
|
||||
using (var file = new FileStream(Path.Combine(path, "private-data"),
|
||||
FileMode.CreateNew, FileAccess.ReadWrite, FileShare.None))
|
||||
{
|
||||
Assert.IsFalse(AIStorageAudit.HasReparsePoint(path));
|
||||
AIStorageAudit.GetBroadAccessRules(Directory.GetAccessControl(path));
|
||||
}
|
||||
}
|
||||
finally
|
||||
{
|
||||
Directory.Delete(path, true);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -106,6 +106,7 @@
|
||||
<Compile Include="NsclientConfigCatalogTests.cs" />
|
||||
<Compile Include="SqlSetupConfigurationIndicatorTests.cs" />
|
||||
<Compile Include="ArgumentParsingTests.cs" />
|
||||
<Compile Include="AIStorageAuditTests.cs" />
|
||||
<Compile Include="AutoLogonTests.cs" />
|
||||
<Compile Include="FixedBuildIndicatorTests.cs" />
|
||||
<Compile Include="HMailAndInstalledAppsTests.cs" />
|
||||
|
||||
@@ -127,6 +127,7 @@ namespace winPEAS.Checks
|
||||
SuperPutty.PrintInfo,
|
||||
PrintOffice365EndpointsSyncedByOneDrive,
|
||||
PrintCloudCreds,
|
||||
PrintAIStoragePermissions,
|
||||
PrintUnattendFiles,
|
||||
PrintSAMBackups,
|
||||
PrintNtdsZipBackups,
|
||||
@@ -156,6 +157,38 @@ namespace winPEAS.Checks
|
||||
}.ForEach(action => CheckRunner.Run(action, isDebug));
|
||||
}
|
||||
|
||||
void PrintAIStoragePermissions()
|
||||
{
|
||||
Beaprint.MainPrint("Current-user AI storage directory permissions", "T1083");
|
||||
Beaprint.InfoPrint("Stored data may include private conversations and configuration. Broad allow ACEs require review; deny ACEs and effective access are not evaluated.");
|
||||
bool found = false;
|
||||
foreach (var root in AIStorageAudit.GetCurrentUserRoots())
|
||||
{
|
||||
try
|
||||
{
|
||||
if (!Directory.Exists(root.Value))
|
||||
continue;
|
||||
if (AIStorageAudit.HasReparsePoint(root.Value))
|
||||
{
|
||||
Beaprint.InfoPrint(root.Key + ": skipped reparse point in directory path");
|
||||
continue;
|
||||
}
|
||||
found = true;
|
||||
Beaprint.InfoPrint(string.Format("{0}: {1}", root.Key, root.Value));
|
||||
var rules = AIStorageAudit.GetBroadAccessRules(Directory.GetAccessControl(root.Value));
|
||||
foreach (string rule in rules)
|
||||
Beaprint.BadPrint(" Review broad directory access: " + rule);
|
||||
}
|
||||
catch (Exception ex) when (ex is IOException || ex is UnauthorizedAccessException
|
||||
|| ex is System.Security.SecurityException)
|
||||
{
|
||||
Beaprint.InfoPrint(root.Key + ": directory permissions unavailable");
|
||||
}
|
||||
}
|
||||
if (!found)
|
||||
Beaprint.NotFoundPrint();
|
||||
}
|
||||
|
||||
void PrintCloudCreds()
|
||||
{
|
||||
try
|
||||
|
||||
@@ -0,0 +1,63 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.IO;
|
||||
using System.Security.AccessControl;
|
||||
using System.Security.Principal;
|
||||
|
||||
namespace winPEAS.Info.FilesInfo
|
||||
{
|
||||
internal static class AIStorageAudit
|
||||
{
|
||||
internal static Dictionary<string, string> GetCurrentUserRoots()
|
||||
{
|
||||
var roots = new Dictionary<string, string>();
|
||||
string profile = Environment.GetFolderPath(Environment.SpecialFolder.UserProfile);
|
||||
if (!string.IsNullOrEmpty(profile))
|
||||
{
|
||||
roots.Add("Codex", Path.Combine(profile, ".codex"));
|
||||
roots.Add("Claude Code", Path.Combine(profile, ".claude"));
|
||||
roots.Add("Cursor CLI", Path.Combine(profile, ".cursor"));
|
||||
roots.Add("Antigravity CLI", Path.Combine(profile, ".gemini", "antigravity-cli"));
|
||||
roots.Add("Grok", Path.Combine(profile, ".grok"));
|
||||
}
|
||||
string appData = Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData);
|
||||
if (!string.IsNullOrEmpty(appData))
|
||||
roots.Add("Cursor desktop", Path.Combine(appData, "Cursor"));
|
||||
return roots;
|
||||
}
|
||||
|
||||
internal static bool HasReparsePoint(string path, Func<string, FileAttributes> readAttributes = null)
|
||||
{
|
||||
readAttributes = readAttributes ?? File.GetAttributes;
|
||||
// Inspect ancestors first so a junction cannot redirect the root check.
|
||||
string parent = Path.GetDirectoryName(path);
|
||||
if (!string.IsNullOrEmpty(parent) && HasReparsePoint(parent, readAttributes))
|
||||
return true;
|
||||
return (readAttributes(path) & FileAttributes.ReparsePoint) != 0;
|
||||
}
|
||||
|
||||
internal static List<string> GetBroadAccessRules(DirectorySecurity security)
|
||||
{
|
||||
var findings = new List<string>();
|
||||
FileSystemRights relevant = FileSystemRights.ReadData | FileSystemRights.WriteData
|
||||
| FileSystemRights.AppendData | FileSystemRights.Delete
|
||||
| FileSystemRights.DeleteSubdirectoriesAndFiles | FileSystemRights.ChangePermissions
|
||||
| FileSystemRights.TakeOwnership;
|
||||
foreach (FileSystemAccessRule rule in security.GetAccessRules(true, true, typeof(SecurityIdentifier)))
|
||||
{
|
||||
if (rule.AccessControlType != AccessControlType.Allow
|
||||
|| (rule.PropagationFlags & PropagationFlags.InheritOnly) != 0
|
||||
|| (rule.FileSystemRights & relevant) == 0)
|
||||
continue;
|
||||
var sid = (SecurityIdentifier)rule.IdentityReference;
|
||||
if (sid.IsWellKnown(WellKnownSidType.WorldSid)
|
||||
|| sid.IsWellKnown(WellKnownSidType.AuthenticatedUserSid)
|
||||
|| sid.IsWellKnown(WellKnownSidType.BuiltinUsersSid)
|
||||
|| sid.IsWellKnown(WellKnownSidType.BuiltinGuestsSid))
|
||||
findings.Add(string.Format("{0}: {1}", sid.Value, rule.FileSystemRights));
|
||||
}
|
||||
// Allow ACEs are review candidates, not an effective-access calculation.
|
||||
return findings;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1191,6 +1191,7 @@
|
||||
<Compile Include="Checks\CloudInfo.cs" />
|
||||
<Compile Include="Checks\FileAnalysis.cs" />
|
||||
<Compile Include="Checks\FilesInfo.cs" />
|
||||
<Compile Include="Info\FilesInfo\AIStorageAudit.cs" />
|
||||
<Compile Include="Checks\Globals.cs" />
|
||||
<Compile Include="Checks\ISystemCheck.cs" />
|
||||
<Compile Include="Checks\EventsInfo.cs" />
|
||||
|
||||
Reference in new issue
Block a user