From 64e4c60907bc63569d2eddf2beb2532043f21fd4 Mon Sep 17 00:00:00 2001 From: Andy | ZephrFish Date: Thu, 9 Oct 2025 17:11:05 +0100 Subject: [PATCH] SCCM updates Add SCCM integration with automatic discovery and content library resolution: - Implement SCCM share discovery (SCCMContentLib$, SCCM$, etc.) - Add content library file hash resolution using DataLib index - Support for INI-based content hash lookups and file mapping - LRU cache for efficient repeated hash resolutions - Detection rules for SCCM deployment packages and content files - Hash resolution logic borrowed from CMLoot for accurate file identification New components: - SCCMDiscovery.cs: Automatic SCCM share enumeration - SCCMContentLibResolver.cs: Content library hash resolution - SCCMFileMapping.cs: File path to content hash mapping - LRUCache.cs: Caching layer for performance - KeepSCCMContentFiles.toml: Detection rule for SCCM content Integration points: - ShareFinder: SCCM share detection and prioritization - TreeWalker: SCCM-aware file enumeration - FileClassifier: SCCM content identification --- SnaffCore/Classifiers/FileClassifier.cs | 199 ++++ SnaffCore/Config/ClassifierOptions.cs | 4 +- SnaffCore/SCCM/LRUCache.cs | 180 +++ SnaffCore/SCCM/SCCMContentLibResolver.cs | 1052 +++++++++++++++++ SnaffCore/SCCM/SCCMDiscovery.cs | 406 +++++++ SnaffCore/SCCM/SCCMFileMapping.cs | 160 +++ SnaffCore/ShareFind/ShareFinder.cs | 97 +- SnaffCore/SnaffCon.cs | 54 +- SnaffCore/SnaffCore.csproj | 10 +- SnaffCore/TreeWalk/TreeWalker.cs | 16 + Snaffler/Config.cs | 123 +- Snaffler/Properties/Resources.Designer.cs | 2 +- .../KeepSCCMContentFiles.toml | 65 + Snaffler/SnaffleRunner.cs | 47 + Snaffler/Snaffler.csproj | 3 +- Snaffler/app.config | 7 +- 16 files changed, 2361 insertions(+), 64 deletions(-) create mode 100644 SnaffCore/SCCM/LRUCache.cs create mode 100644 SnaffCore/SCCM/SCCMContentLibResolver.cs create mode 100644 SnaffCore/SCCM/SCCMDiscovery.cs create mode 100644 SnaffCore/SCCM/SCCMFileMapping.cs create mode 100644 Snaffler/SnaffRules/DefaultRules/FileRules/Keep/Infrastructure/DeploymentAutomation/KeepSCCMContentFiles.toml diff --git a/SnaffCore/Classifiers/FileClassifier.cs b/SnaffCore/Classifiers/FileClassifier.cs index 011dc584..cf823595 100644 --- a/SnaffCore/Classifiers/FileClassifier.cs +++ b/SnaffCore/Classifiers/FileClassifier.cs @@ -8,6 +8,8 @@ using System.Text; using static SnaffCore.Config.Options; using SnaffCore.Classifiers.EffectiveAccess; +using SnaffCore.SCCM; +using SnaffCore.TreeWalk; namespace SnaffCore.Classifiers { @@ -110,6 +112,13 @@ public bool ClassifyFile(FileInfo fileInfo) }; // if the file was list-only, don't bother sending a result back to the user. if (!fileResult.RwStatus.CanRead && !fileResult.RwStatus.CanModify && !fileResult.RwStatus.CanWrite) { return false; }; + + // Check if this is an SCCM DataLib .INI file and resolve it to actual content files + if (IsSCCMDataLibIniFile(fileInfo)) + { + ResolveSCCMIniFile(fileInfo); + } + Mq.FileResult(fileResult); return false; case MatchAction.CheckForKeys: @@ -367,5 +376,195 @@ public List x509Match(FileInfo fileInfo) } return matchReasons; } + + private bool IsSCCMDataLibIniFile(FileInfo fileInfo) + { + BlockingMq Mq = BlockingMq.GetMq(); + // Check if this is a .INI file in a DataLib directory structure + if (!fileInfo.Extension.Equals(".INI", StringComparison.OrdinalIgnoreCase)) + { + return false; + } + + string fullPath = fileInfo.FullName; + + // Check if path contains DataLib (case-insensitive) + if (fullPath.IndexOf("DataLib", StringComparison.OrdinalIgnoreCase) == -1) + { + return false; + } + + return true; + } + + private void ResolveSCCMIniFile(FileInfo iniFile) + { + BlockingMq Mq = BlockingMq.GetMq(); + try + { + // Find the ContentLib root directory + string fullPath = iniFile.FullName; + string contentLibRoot = FindContentLibRoot(fullPath); + + if (string.IsNullOrEmpty(contentLibRoot)) + { + Mq.Trace($"[SCCM INI Resolution] Could not find ContentLib root for: {fullPath}"); + return; + } + + // Read the INI file and extract hash + string iniContent = File.ReadAllText(iniFile.FullName); + var hashMatch = System.Text.RegularExpressions.Regex.Match( + iniContent, + @"Hash[^=]*=([A-Fa-f0-9]+)", + System.Text.RegularExpressions.RegexOptions.IgnoreCase + ); + + if (!hashMatch.Success) + { + Mq.Trace($"[SCCM INI Resolution] No hash found in: {iniFile.Name}"); + return; + } + + string hashValue = hashMatch.Groups[1].Value; + string originalFilename = Path.GetFileNameWithoutExtension(iniFile.Name); + + // Add mapping for display purposes + SCCMFileMapping.AddMapping(hashValue, originalFilename); + + // Extract package ID and version from path + string packageId = ExtractPackageIdFromPath(iniFile.FullName); + string version = ExtractVersionFromPath(iniFile.FullName); + + // Store metadata for the INI file itself + SCCMFileMapping.AddMetadata(iniFile.FullName, new SCCMFileMetadata + { + OriginalName = originalFilename, + PackageId = packageId, + Version = version, + ContentType = "DataLib-INI" + }); + + // Try to find the actual content file in FileLib + string fileLibPath = Path.Combine(contentLibRoot, "FileLib"); + if (!Directory.Exists(fileLibPath)) + { + Mq.Trace($"[SCCM INI Resolution] FileLib not found at: {fileLibPath}"); + return; + } + + // FileLib structure: FileLib/{first-4-chars}/{full-hash} + string hashFolder = hashValue.Length >= 4 ? hashValue.Substring(0, 4) : hashValue; + string contentPath = Path.Combine(fileLibPath, hashFolder, hashValue); + + if (File.Exists(contentPath)) + { + // Store mapping and metadata + SCCMFileMapping.AddPathMapping(contentPath, originalFilename); + SCCMFileMapping.AddMetadata(contentPath, new SCCMFileMetadata + { + OriginalName = originalFilename, + PackageId = packageId, + Version = version, + Hash = hashValue, + ContentType = "FileLib" + }); + + // Queue the content file for scanning + FileInfo contentFileInfo = new FileInfo(contentPath); + TreeWalker treeWalker = SnaffCon.GetTreeWalker(); + treeWalker.ProcessSCCMFile(contentPath); + + Mq.Degub($"[SCCM INI Resolution] Resolved: {originalFilename} -> {contentPath}"); + } + else + { + Mq.Trace($"[SCCM INI Resolution] Content file not found: {contentPath}"); + } + } + catch (Exception ex) + { + Mq.Trace($"[SCCM INI Resolution] Error resolving {iniFile.FullName}: {ex.Message}"); + } + } + + private string FindContentLibRoot(string filePath) + { + try + { + // Look for SCCMContentLib or ContentLib directory in the path + string[] pathParts = filePath.Split(new[] { '\\', '/' }, StringSplitOptions.RemoveEmptyEntries); + + for (int i = 0; i < pathParts.Length; i++) + { + if (pathParts[i].Equals("SCCMContentLib", StringComparison.OrdinalIgnoreCase) || + pathParts[i].Equals("ContentLib", StringComparison.OrdinalIgnoreCase)) + { + // Reconstruct path up to and including ContentLib directory + string[] rootParts = new string[i + 1]; + Array.Copy(pathParts, rootParts, i + 1); + + // Handle UNC paths + if (filePath.StartsWith("\\\\")) + { + return "\\\\" + string.Join("\\", rootParts); + } + else + { + return string.Join("\\", rootParts); + } + } + } + + return null; + } + catch + { + return null; + } + } + + private string ExtractPackageIdFromPath(string filePath) + { + try + { + // Path format: ...DataLib/{PackageID}/{Version}/file.INI + string dirPath = Path.GetDirectoryName(filePath); + if (string.IsNullOrEmpty(dirPath)) + return null; + + // Get parent directory (version) + string versionDir = Path.GetDirectoryName(dirPath); + if (string.IsNullOrEmpty(versionDir)) + return null; + + // Get grandparent directory (package ID) + string packageDir = Path.GetFileName(versionDir); + return packageDir; + } + catch + { + return null; + } + } + + private string ExtractVersionFromPath(string filePath) + { + try + { + // Path format: ...DataLib/{PackageID}/{Version}/file.INI + string dirPath = Path.GetDirectoryName(filePath); + if (string.IsNullOrEmpty(dirPath)) + return null; + + // Get directory name (version) + string version = Path.GetFileName(dirPath); + return version; + } + catch + { + return null; + } + } } } \ No newline at end of file diff --git a/SnaffCore/Config/ClassifierOptions.cs b/SnaffCore/Config/ClassifierOptions.cs index 42346fd4..c04efb6a 100644 --- a/SnaffCore/Config/ClassifierOptions.cs +++ b/SnaffCore/Config/ClassifierOptions.cs @@ -1,4 +1,4 @@ -using SnaffCore.Classifiers; +using SnaffCore.Classifiers; using System; using System.Collections.Generic; using System.Linq; @@ -159,4 +159,4 @@ private bool IsInterest(ClassifierRule classifier) return true; } } -} \ No newline at end of file +} diff --git a/SnaffCore/SCCM/LRUCache.cs b/SnaffCore/SCCM/LRUCache.cs new file mode 100644 index 00000000..c80e27da --- /dev/null +++ b/SnaffCore/SCCM/LRUCache.cs @@ -0,0 +1,180 @@ +using System; +using System.Collections.Generic; +using System.Threading; + +namespace SnaffCore.SCCM +{ + public class LRUCache + { + private readonly int _maxSize; + private readonly Dictionary> _cache; + private readonly LinkedList _lru; + private readonly ReaderWriterLockSlim _lock; + private long _approximateMemoryUsage; + private readonly long _maxMemoryBytes; + + private class CacheItem + { + public TKey Key { get; set; } + public TValue Value { get; set; } + public long Size { get; set; } + public DateTime LastAccessed { get; set; } + } + + public LRUCache(int maxSize = 10000, long maxMemoryMB = 100) + { + _maxSize = maxSize; + _maxMemoryBytes = maxMemoryMB * 1024 * 1024; + _cache = new Dictionary>(maxSize); + _lru = new LinkedList(); + _lock = new ReaderWriterLockSlim(); + _approximateMemoryUsage = 0; + } + + public bool TryGet(TKey key, out TValue value) + { + _lock.EnterUpgradeableReadLock(); + try + { + if (_cache.TryGetValue(key, out var node)) + { + _lock.EnterWriteLock(); + try + { + + _lru.Remove(node); + _lru.AddFirst(node); + node.Value.LastAccessed = DateTime.UtcNow; + } + finally + { + _lock.ExitWriteLock(); + } + + value = node.Value.Value; + return true; + } + + value = default(TValue); + return false; + } + finally + { + _lock.ExitUpgradeableReadLock(); + } + } + + public void Add(TKey key, TValue value, long sizeInBytes = 0) + { + _lock.EnterWriteLock(); + try + { + + if (_cache.TryGetValue(key, out var existingNode)) + { + _approximateMemoryUsage -= existingNode.Value.Size; + _lru.Remove(existingNode); + _cache.Remove(key); + } + + + if (sizeInBytes == 0) + { + sizeInBytes = EstimateSize(value); + } + + + while (_cache.Count >= _maxSize || _approximateMemoryUsage + sizeInBytes > _maxMemoryBytes) + { + if (_lru.Last == null) break; + + var lruItem = _lru.Last.Value; + _cache.Remove(lruItem.Key); + _approximateMemoryUsage -= lruItem.Size; + _lru.RemoveLast(); + } + + + var cacheItem = new CacheItem + { + Key = key, + Value = value, + Size = sizeInBytes, + LastAccessed = DateTime.UtcNow + }; + + var node = _lru.AddFirst(cacheItem); + _cache[key] = node; + _approximateMemoryUsage += sizeInBytes; + } + finally + { + _lock.ExitWriteLock(); + } + } + + public void Clear() + { + _lock.EnterWriteLock(); + try + { + _cache.Clear(); + _lru.Clear(); + _approximateMemoryUsage = 0; + } + finally + { + _lock.ExitWriteLock(); + } + } + + public int Count + { + get + { + _lock.EnterReadLock(); + try + { + return _cache.Count; + } + finally + { + _lock.ExitReadLock(); + } + } + } + + public long MemoryUsageBytes + { + get + { + _lock.EnterReadLock(); + try + { + return _approximateMemoryUsage; + } + finally + { + _lock.ExitReadLock(); + } + } + } + + private long EstimateSize(TValue value) + { + + if (value is string str) + { + return str.Length * 2; + } + + + return 64; + } + + public void Dispose() + { + _lock?.Dispose(); + } + } +} \ No newline at end of file diff --git a/SnaffCore/SCCM/SCCMContentLibResolver.cs b/SnaffCore/SCCM/SCCMContentLibResolver.cs new file mode 100644 index 00000000..c7fe3608 --- /dev/null +++ b/SnaffCore/SCCM/SCCMContentLibResolver.cs @@ -0,0 +1,1052 @@ +using System; +using System.Collections.Generic; +using System.IO; +using System.Linq; +using System.Text; +using System.Threading; +using System.Threading.Tasks; +using SnaffCore.Concurrency; + +namespace SnaffCore.SCCM +{ + public class SCCMContentLibResolver + { + private BlockingMq Mq { get; set; } + private bool _debug; + + + private readonly string[] SCCMSharePatterns = new string[] + { + "SCCMContentLib$", + "SCCMContentLib", + "SMS_DP$", + "SMS_DistributionPoint$", + "ContentLib$", + "SMSPKGD$", + "SMSPKGE$", + "SMSPKGF$", + "SMSSIG$", + "SMS_CPSC$", + "SMS_SITE$", + "SMS_PKG$", + "SMSPKG$", + "SCCMContentLib_", + "PkgLib$", + "DataLib$", + "FileLib$" + }; + + + private LRUCache _fileLibCache; + + + private const int MAX_RECURSION_DEPTH = 10; + private const int MAX_CACHE_SIZE = 50000; + private const long MAX_CACHE_MEMORY_MB = 100; + private const int MAX_PARALLEL_PACKAGES = 4; + + + private CancellationTokenSource _cancellationTokenSource; + + public SCCMContentLibResolver(bool debug = false) + { + Mq = BlockingMq.GetMq(); + _debug = debug; + _fileLibCache = new LRUCache(MAX_CACHE_SIZE, MAX_CACHE_MEMORY_MB); + _cancellationTokenSource = new CancellationTokenSource(); + } + + public bool IsSCCMShare(string sharePath) + { + + string shareNameOnly = sharePath; + + + if (sharePath.StartsWith(@"\\")) + { + var parts = sharePath.TrimStart('\\').Split('\\'); + if (parts.Length >= 2) + { + shareNameOnly = parts[1]; + } + } + else + { + + shareNameOnly = Path.GetFileName(sharePath.TrimEnd('\\', '/')); + } + + + foreach (var pattern in SCCMSharePatterns) + { + if (shareNameOnly.Equals(pattern, StringComparison.OrdinalIgnoreCase) || + shareNameOnly.Equals(pattern.TrimEnd('$'), StringComparison.OrdinalIgnoreCase)) + { + return true; + } + } + + + if (shareNameOnly.Equals("ADMIN$", StringComparison.OrdinalIgnoreCase)) + { + + return CheckAdminShareForSCCM(sharePath); + } + + return false; + } + + public List ResolveSCCMContentPaths(string sharePath) + { + List resolvedPaths = new List(); + + + LoadFileLibMappings(sharePath); + + try + { + if (_debug) + { + Mq.Degub($"Resolving SCCM ContentLib: {sharePath}"); + } + + + string dataLibPath = Path.Combine(sharePath, "DataLib"); + if (!DirectoryExists(dataLibPath)) + { + + dataLibPath = FindDataLibPath(sharePath); + if (string.IsNullOrEmpty(dataLibPath)) + { + Mq.Degub($"[SCCM] No DataLib found in {sharePath}"); + return resolvedPaths; + } + } + + if (_debug) + { + Mq.Degub($"Found DataLib at: {dataLibPath}"); + } + + + + + + + var dataLibFiles = GetAllFilesRecursive(dataLibPath, 0); + int processedCount = 0; + + if (_debug) + { + Mq.Degub($"Found {dataLibFiles.Count} files in DataLib"); + } + + + string fileLibPath = FindFileLibPath(sharePath); + bool hasFileLib = !string.IsNullOrEmpty(fileLibPath) && DirectoryExists(fileLibPath); + + if (_debug && hasFileLib) + { + Mq.Degub($"FileLib found at: {fileLibPath}"); + } + + foreach (var file in dataLibFiles) + { + try + { + string fileName = Path.GetFileName(file); + + + if (fileName.EndsWith(".INI", StringComparison.OrdinalIgnoreCase)) + { + + string iniContent = File.ReadAllText(file); + + + string originalFilename = fileName.Substring(0, fileName.Length - 4); + + // Extract package ID and version from path (DataLib/{PackageID}/{Version}/) + string packageId = ExtractPackageIdFromPath(file); + string version = ExtractVersionFromPath(file); + + var hashMatch = System.Text.RegularExpressions.Regex.Match(iniContent, @"Hash[^=]*=([A-F0-9]+)", System.Text.RegularExpressions.RegexOptions.IgnoreCase); + if (hashMatch.Success) + { + string hashValue = hashMatch.Groups[1].Value; + + + SCCMFileMapping.AddMapping(hashValue, originalFilename); + + if (hasFileLib) + { + + string hashFolder = hashValue.Length >= 4 ? hashValue.Substring(0, 4) : hashValue; + string contentPath = Path.Combine(fileLibPath, hashFolder, hashValue); + + if (File.Exists(contentPath)) + { + + SCCMFileMapping.AddPathMapping(contentPath, originalFilename); + + // Store metadata for this resolved file + SCCMFileMapping.AddMetadata(contentPath, new SCCMFileMetadata + { + OriginalName = originalFilename, + PackageId = packageId, + Version = version, + Hash = hashValue, + ContentType = "FileLib" + }); + + resolvedPaths.Add(contentPath); + processedCount++; + + if (_debug && processedCount <= 10) + { + Mq.Degub($"Resolved: {originalFilename} -> FileLib/{hashFolder}/{hashValue}"); + } + } + else if (_debug && processedCount == 0) + { + Mq.Degub($"Hash {hashValue} not found in FileLib at {contentPath}"); + } + } + } + + // Also scan the INI file itself for interesting content + SCCMFileMapping.AddMetadata(file, new SCCMFileMetadata + { + OriginalName = originalFilename, + PackageId = packageId, + Version = version, + ContentType = "DataLib-INI" + }); + + resolvedPaths.Add(file); + processedCount++; + } + else + { + + + resolvedPaths.Add(file); + processedCount++; + } + } + catch (Exception ex) + { + if (_debug) + { + Mq.Degub($"Error processing DataLib file {file}: {ex.Message}"); + } + } + } + + if (_debug) + { + Mq.Degub($"Processed {processedCount} items from DataLib"); + } + + + ProcessPkgLib(sharePath, resolvedPaths); + + + ProcessAdditionalSCCMLocations(sharePath, resolvedPaths); + + + if (resolvedPaths.Count == 0) + { + if (_debug) + { + Mq.Degub($"No files resolved from ContentLib at {sharePath}"); + } + } + else + { + if (_debug) + { + Mq.Degub($"Resolved {resolvedPaths.Count} files from ContentLib"); + } + } + + + CheckSMSPKGFolders(sharePath, resolvedPaths); + + } + catch (Exception ex) + { + Mq.Error($"Error resolving ContentLib: {ex.Message}"); + if (_debug) + { + Mq.Error(ex.ToString()); + } + } + + return resolvedPaths; + } + + public Dictionary> GroupFilesByDirectory(List filePaths) + { + Dictionary> directoryMap = new Dictionary>(StringComparer.OrdinalIgnoreCase); + + foreach (var filePath in filePaths) + { + try + { + string directory = Path.GetDirectoryName(filePath); + if (string.IsNullOrEmpty(directory)) + continue; + + if (!directoryMap.ContainsKey(directory)) + { + directoryMap[directory] = new List(); + } + + directoryMap[directory].Add(filePath); + } + catch (Exception ex) + { + if (_debug) + { + Mq.Degub($"Error grouping file {filePath}: {ex.Message}"); + } + } + } + + return directoryMap; + } + + + private void LoadFileLibMappings(string sharePath) + { + try + { + string fileLibPath = FindFileLibPath(sharePath); + if (string.IsNullOrEmpty(fileLibPath)) + { + Mq.Degub("[SCCM] No FileLib found for filename resolution"); + return; + } + + if (_debug) + { + Mq.Degub($"Loading FileLib mappings from: {fileLibPath}"); + } + + + var hashFolders = GetDirectories(fileLibPath); + int mappingCount = 0; + object lockObj = new object(); + + + var parallelOptions = new ParallelOptions + { + MaxDegreeOfParallelism = MAX_PARALLEL_PACKAGES, + CancellationToken = _cancellationTokenSource.Token + }; + + try + { + Parallel.ForEach(hashFolders, parallelOptions, hashFolder => + { + string hashValue = Path.GetFileName(hashFolder); + + + var iniFiles = Directory.EnumerateFiles(hashFolder, "*.INI").Take(5); + + foreach (var iniFile in iniFiles) + { + try + { + + string content = File.ReadAllText(iniFile); + + + string originalName = ExtractOriginalFileNameOptimized(content); + + if (!string.IsNullOrEmpty(originalName)) + { + _fileLibCache.Add(hashValue, originalName); + + lock (lockObj) + { + mappingCount++; + } + + if (_debug && mappingCount % 100 == 0) + { + Mq.Degub($"[SCCM] Loaded {mappingCount} mappings so far..."); + } + } + } + catch (Exception ex) + { + if (_debug) + { + Mq.Degub($"[SCCM] Error parsing INI file {iniFile}: {ex.Message}"); + } + } + } + }); + } + catch (OperationCanceledException) + { + if (_debug) + { + Mq.Degub("FileLib loading cancelled"); + } + } + + if (_debug) + { + Mq.Degub($"Loaded {mappingCount} FileLib mappings"); + } + } + catch (Exception ex) + { + if (_debug) + { + Mq.Degub($"Error loading FileLib mappings: {ex.Message}"); + } + } + } + + private string ExtractOriginalFileNameOptimized(string iniContent) + { + + using (var reader = new StringReader(iniContent)) + { + string line; + while ((line = reader.ReadLine()) != null) + { + + if (line.Length < 10) continue; + + + if (line.StartsWith("FileName=", StringComparison.OrdinalIgnoreCase)) + { + return ExtractFileNameFromLine(line, 9); + } + else if (line.StartsWith("OriginalName=", StringComparison.OrdinalIgnoreCase)) + { + return ExtractFileNameFromLine(line, 13); + } + else if (line.StartsWith("SourcePath=", StringComparison.OrdinalIgnoreCase)) + { + return ExtractFileNameFromLine(line, 11); + } + else if (line.StartsWith("RelativePath=", StringComparison.OrdinalIgnoreCase)) + { + return ExtractFileNameFromLine(line, 13); + } + } + } + + return null; + } + + private string ExtractFileNameFromLine(string line, int prefixLength) + { + if (line.Length <= prefixLength) return null; + + string value = line.Substring(prefixLength).Trim(); + + + if (value.Contains("\\")) + { + int lastSlash = value.LastIndexOf('\\'); + if (lastSlash >= 0 && lastSlash < value.Length - 1) + { + value = value.Substring(lastSlash + 1); + } + } + + return string.IsNullOrEmpty(value) ? null : value; + } + + private string ExtractOriginalFileName(string iniContent) + { + + string[] patterns = new string[] + { + "FileName=", + "OriginalName=", + "SourcePath=", + "RelativePath=" + }; + + var lines = iniContent.Split(new[] { '\r', '\n' }, StringSplitOptions.RemoveEmptyEntries); + + foreach (var line in lines) + { + foreach (var pattern in patterns) + { + if (line.StartsWith(pattern, StringComparison.OrdinalIgnoreCase)) + { + string value = line.Substring(pattern.Length).Trim(); + + + if (value.Contains("\\")) + { + value = Path.GetFileName(value); + } + + if (!string.IsNullOrEmpty(value)) + { + return value; + } + } + } + } + + return null; + } + + + private void ProcessPkgLib(string sharePath, List resolvedPaths) + { + try + { + string pkgLibPath = FindPkgLibPath(sharePath); + if (string.IsNullOrEmpty(pkgLibPath)) + { + if (_debug) + { + Mq.Degub("No PkgLib found"); + } + return; + } + + if (_debug) + { + Mq.Degub($"Processing PkgLib at: {pkgLibPath}"); + } + + + string dataLibPath = FindDataLibPath(sharePath); + if (string.IsNullOrEmpty(dataLibPath)) + { + if (_debug) + { + Mq.Degub("No DataLib found, skipping PkgLib content resolution"); + } + return; + } + + + + var iniFiles = Directory.GetFiles(pkgLibPath, "*.INI", SearchOption.TopDirectoryOnly); + int contentCount = 0; + + foreach (var iniFile in iniFiles) + { + try + { + + string iniContent = File.ReadAllText(iniFile); + string packageId = Path.GetFileNameWithoutExtension(iniFile); + + + var contentHashes = ExtractContentHashes(iniContent); + + if (_debug && contentHashes.Count > 0) + { + Mq.Degub($"Package {packageId} references {contentHashes.Count} content items"); + } + + + foreach (var hash in contentHashes) + { + + + string hashFolder = hash.Length >= 4 ? hash.Substring(0, 4) : hash; + string contentPath = Path.Combine(dataLibPath, hashFolder, hash); + + + if (File.Exists(contentPath)) + { + resolvedPaths.Add(contentPath); + contentCount++; + + if (_debug && contentCount <= 5) + { + Mq.Degub($"Resolved content: {hash} from package {packageId}"); + } + } + else + { + + contentPath = Path.Combine(dataLibPath, hash); + if (File.Exists(contentPath)) + { + resolvedPaths.Add(contentPath); + contentCount++; + } + else if (_debug && contentCount == 0) + { + + Mq.Degub($"Content hash {hash} not found in DataLib"); + } + } + } + } + catch (Exception ex) + { + if (_debug) + { + Mq.Degub($"Error processing PkgLib INI {iniFile}: {ex.Message}"); + } + } + } + + if (_debug) + { + Mq.Degub($"Resolved {contentCount} files from {iniFiles.Length} PkgLib packages"); + } + } + catch (Exception ex) + { + if (_debug) + { + Mq.Degub($"Error processing PkgLib: {ex.Message}"); + } + } + } + + private void ProcessAdditionalSCCMLocations(string sharePath, List resolvedPaths) + { + try + { + + string parentPath = Path.GetDirectoryName(sharePath); + if (string.IsNullOrEmpty(parentPath)) + { + parentPath = sharePath; + } + + string[] additionalShares = new string[] + { + "SMSSIG$", + "SMS_CPSC$", + "SMS_SITE$", + "SMS_PKG$", + "REMINST", + "WSUS", + "UpdateServicesPackages" + }; + + foreach (var share in additionalShares) + { + string additionalPath = Path.Combine(parentPath, share); + + if (DirectoryExists(additionalPath)) + { + if (_debug) + { + Mq.Degub($"Found additional share: {share}"); + } + + + var additionalFiles = GetAllFilesRecursive(additionalPath, 0); + + + // Add all files - let Snaffler's classifiers determine what's interesting + foreach (var file in additionalFiles) + { + resolvedPaths.Add(file); + } + } + } + } + catch (Exception ex) + { + if (_debug) + { + Mq.Degub($"Error processing additional locations: {ex.Message}"); + } + } + } + + private string FindFileLibPath(string sharePath) + { + string[] possiblePaths = new string[] + { + Path.Combine(sharePath, "FileLib"), + Path.Combine(sharePath, "SCCMContentLib", "FileLib"), + Path.Combine(sharePath, "ContentLib", "FileLib"), + Path.Combine(sharePath, "SMS", "PKG", "FileLib"), + Path.Combine(sharePath, "SMS_DP", "ContentLib", "FileLib") + }; + + foreach (var path in possiblePaths) + { + if (DirectoryExists(path)) + { + return path; + } + } + + return null; + } + + private string FindPkgLibPath(string sharePath) + { + string[] possiblePaths = new string[] + { + Path.Combine(sharePath, "PkgLib"), + Path.Combine(sharePath, "SCCMContentLib", "PkgLib"), + Path.Combine(sharePath, "ContentLib", "PkgLib"), + Path.Combine(sharePath, "SMS", "PKG", "PkgLib"), + Path.Combine(sharePath, "SMS_DP", "ContentLib", "PkgLib") + }; + + foreach (var path in possiblePaths) + { + if (DirectoryExists(path)) + { + return path; + } + } + + return null; + } + + private string FindDataLibPath(string sharePath) + { + string[] possiblePaths = new string[] + { + Path.Combine(sharePath, "DataLib"), + Path.Combine(sharePath, "SCCMContentLib", "DataLib"), + Path.Combine(sharePath, "SMS", "PKG", "DataLib"), + Path.Combine(sharePath, "SMS_DP", "ContentLib", "DataLib"), + Path.Combine(sharePath, "Program Files", "Microsoft Configuration Manager", "CMContentLib", "DataLib") + }; + + foreach (var path in possiblePaths) + { + if (DirectoryExists(path)) + { + return path; + } + } + + return null; + } + + private bool CheckAdminShareForSCCM(string sharePath) + { + string[] sccmIndicators = new string[] + { + "SCCMContentLib", + "SMS", + "SMSPKG", + "Microsoft Configuration Manager" + }; + + try + { + foreach (var indicator in sccmIndicators) + { + string testPath = Path.Combine(sharePath, indicator); + if (DirectoryExists(testPath)) + { + return true; + } + } + } + catch + { + + } + + return false; + } + + private void CheckSMSPKGFolders(string sharePath, List resolvedPaths) + { + string[] smspkgPatterns = new string[] + { + "SMSPKGD$", + "SMSPKGE$", + "SMSPKGF$" + }; + + foreach (var pattern in smspkgPatterns) + { + string smspkgPath = Path.Combine(sharePath, "..", pattern); + if (DirectoryExists(smspkgPath)) + { + if (_debug) + { + Mq.Degub($"Found legacy package share: {pattern}"); + } + + var packages = GetDirectories(smspkgPath); + foreach (var package in packages) + { + var files = GetAllFilesRecursive(package); + resolvedPaths.AddRange(files); + } + } + } + } + + private List GetAllFilesRecursive(string path, int currentDepth = 0) + { + List files = new List(); + + + if (_cancellationTokenSource.Token.IsCancellationRequested) + { + if (_debug) + { + Mq.Degub("Operation cancelled by user"); + } + return files; + } + + + if (currentDepth >= MAX_RECURSION_DEPTH) + { + if (_debug) + { + Mq.Degub($"Max recursion depth {MAX_RECURSION_DEPTH} reached at: {path}"); + } + return files; + } + + try + { + + files.AddRange(Directory.EnumerateFiles(path)); + + + foreach (var subdir in Directory.EnumerateDirectories(path)) + { + files.AddRange(GetAllFilesRecursive(subdir, currentDepth + 1)); + } + } + catch (UnauthorizedAccessException ex) + { + if (_debug) + { + Mq.Degub($"[SCCM] Access denied in recursive enumeration of {path}: {ex.Message}"); + } + } + catch (Exception ex) + { + if (_debug) + { + Mq.Degub($"[SCCM] Error in recursive enumeration of {path}: {ex.Message}"); + } + } + + return files; + } + + private List GetDirectories(string path) + { + List directories = new List(); + + try + { + + directories.AddRange(Directory.GetDirectories(path)); + } + catch (UnauthorizedAccessException ex) + { + if (_debug) + { + Mq.Degub($"[SCCM] Access denied enumerating directories in {path}: {ex.Message}"); + } + } + catch (DirectoryNotFoundException ex) + { + if (_debug) + { + Mq.Degub($"[SCCM] Directory not found {path}: {ex.Message}"); + } + } + catch (Exception ex) + { + if (_debug) + { + Mq.Degub($"[SCCM] Error enumerating directories in {path}: {ex.Message}"); + } + } + + return directories; + } + + private bool DirectoryExists(string path) + { + try + { + + return Directory.Exists(path); + } + catch + { + + return false; + } + } + + public void CancelOperations() + { + try + { + _cancellationTokenSource?.Cancel(); + if (_debug) + { + Mq.Degub("Cancellation requested for ongoing operations"); + } + } + catch (Exception ex) + { + if (_debug) + { + Mq.Degub($"Error during cancellation: {ex.Message}"); + } + } + } + + public string GetCacheStatistics() + { + if (_fileLibCache != null) + { + return $"Cache: {_fileLibCache.Count} items, {_fileLibCache.MemoryUsageBytes / 1024}KB memory"; + } + return "Cache: Not initialized"; + } + + private List ExtractContentHashes(string iniContent) + { + List hashes = new List(); + + try + { + + + string[] patterns = new string[] + { + "Content=", + "ContentID=", + "Hash=", + "DataLib=", + "ContentHash=", + "FileHash=" + }; + + using (var reader = new StringReader(iniContent)) + { + string line; + while ((line = reader.ReadLine()) != null) + { + foreach (var pattern in patterns) + { + if (line.StartsWith(pattern, StringComparison.OrdinalIgnoreCase)) + { + string value = line.Substring(pattern.Length).Trim(); + + + if (!string.IsNullOrEmpty(value) && + value.Length >= 32 && + value.Length <= 64 && + IsHexString(value)) + { + hashes.Add(value); + } + } + } + } + } + } + catch (Exception ex) + { + if (_debug) + { + Mq.Degub($"Error extracting content hashes: {ex.Message}"); + } + } + + return hashes; + } + + private bool IsHexString(string value) + { + foreach (char c in value) + { + if (!((c >= '0' && c <= '9') || + (c >= 'a' && c <= 'f') || + (c >= 'A' && c <= 'F'))) + { + return false; + } + } + return true; + } + + private string ExtractPackageIdFromPath(string filePath) + { + try + { + // Path format: ...DataLib/{PackageID}/{Version}/file.INI + string dirPath = Path.GetDirectoryName(filePath); + if (string.IsNullOrEmpty(dirPath)) + return null; + + // Get parent directory (version) + string versionDir = Path.GetDirectoryName(dirPath); + if (string.IsNullOrEmpty(versionDir)) + return null; + + // Get grandparent directory (package ID) + string packageDir = Path.GetFileName(versionDir); + return packageDir; + } + catch + { + return null; + } + } + + private string ExtractVersionFromPath(string filePath) + { + try + { + // Path format: ...DataLib/{PackageID}/{Version}/file.INI + string dirPath = Path.GetDirectoryName(filePath); + if (string.IsNullOrEmpty(dirPath)) + return null; + + // Get directory name (version) + string version = Path.GetFileName(dirPath); + return version; + } + catch + { + return null; + } + } + + public void Dispose() + { + try + { + _cancellationTokenSource?.Cancel(); + _cancellationTokenSource?.Dispose(); + _fileLibCache?.Clear(); + _fileLibCache?.Dispose(); + } + catch (Exception ex) + { + if (_debug) + { + Mq.Degub($"Error during disposal: {ex.Message}"); + } + } + } + + } +} \ No newline at end of file diff --git a/SnaffCore/SCCM/SCCMDiscovery.cs b/SnaffCore/SCCM/SCCMDiscovery.cs new file mode 100644 index 00000000..93b262b9 --- /dev/null +++ b/SnaffCore/SCCM/SCCMDiscovery.cs @@ -0,0 +1,406 @@ +using System; +using System.Collections.Generic; +using System.DirectoryServices; +using System.DirectoryServices.Protocols; +using System.Linq; +using System.Net; +using SnaffCore.Concurrency; + +namespace SnaffCore.SCCM +{ + public class SCCMDiscovery + { + private BlockingMq Mq { get; set; } + private readonly string _domain; + private readonly string _username; + private readonly string _password; + private readonly bool _debug; + private readonly int _ldapPort; + + public SCCMDiscovery(string domain, string username = null, string password = null, + bool debug = false, int ldapPort = 389) + { + Mq = BlockingMq.GetMq(); + _domain = domain; + _username = username; + _password = password; + _debug = debug; + _ldapPort = ldapPort; + } + + public List DiscoverSCCMServers() + { + var servers = new List(); + + try + { + Mq.Info($"[SCCM Discovery] Querying domain: {_domain}"); + + servers.AddRange(QueryViaSMS_SiteSystemServer()); + servers.AddRange(QueryViaServiceConnectionPoint()); + servers.AddRange(QueryViaComputerNamePatterns()); + + var uniqueServers = servers + .GroupBy(s => s.Hostname.ToLower()) + .Select(g => g.First()) + .ToList(); + + Mq.Info($"[SCCM Discovery] Found {uniqueServers.Count} unique SCCM servers"); + + return uniqueServers; + } + catch (Exception ex) + { + Mq.Error($"[SCCM Discovery] Error: {ex.Message}"); + if (_debug) + { + Mq.Degub($"Stack trace: {ex.StackTrace}"); + } + return servers; + } + } + + private List QueryViaSMS_SiteSystemServer() + { + var servers = new List(); + + try + { + var ldapPath = BuildLdapPath("CN=System Management,CN=System"); + + using (var entry = new DirectoryEntry(ldapPath, _username, _password)) + using (var searcher = new DirectorySearcher(entry)) + { + searcher.Filter = "(objectClass=mSSMSSite)"; + searcher.PropertiesToLoad.Add("mSSMSSiteCode"); + searcher.PropertiesToLoad.Add("cn"); + searcher.SearchScope = System.DirectoryServices.SearchScope.Subtree; + + var results = searcher.FindAll(); + + if (_debug) + { + Mq.Degub($"[SCCM] Found {results.Count} SCCM site(s) via SMS query"); + } + + foreach (SearchResult result in results) + { + var siteCode = result.Properties["mSSMSSiteCode"]?[0]?.ToString(); + var siteName = result.Properties["cn"]?[0]?.ToString(); + + if (!string.IsNullOrEmpty(siteCode)) + { + servers.AddRange(FindDistributionPointsForSite(siteCode, siteName)); + } + } + } + } + catch (Exception ex) + { + if (_debug) + { + Mq.Degub($"[SCCM] SMS_SiteSystemServer query failed: {ex.Message}"); + } + } + + return servers; + } + + private List FindDistributionPointsForSite(string siteCode, string siteName) + { + var servers = new List(); + + try + { + var ldapPath = BuildLdapPath(""); + + using (var entry = new DirectoryEntry(ldapPath, _username, _password)) + using (var searcher = new DirectorySearcher(entry)) + { + searcher.Filter = "(&(objectClass=mSSMSSiteSystemServer)(mSSMSDefaultMP=*))"; + searcher.PropertiesToLoad.Add("mSSMSSiteName"); + searcher.PropertiesToLoad.Add("cn"); + searcher.PropertiesToLoad.Add("dNSHostName"); + searcher.PropertiesToLoad.Add("mSSMSRolesConfigured"); + searcher.SearchScope = System.DirectoryServices.SearchScope.Subtree; + + var results = searcher.FindAll(); + + foreach (SearchResult result in results) + { + var hostname = result.Properties["dNSHostName"]?[0]?.ToString(); + var cn = result.Properties["cn"]?[0]?.ToString(); + var roles = result.Properties["mSSMSRolesConfigured"]; + + if (string.IsNullOrEmpty(hostname) && !string.IsNullOrEmpty(cn)) + { + hostname = cn.Split(',')[0].Replace("CN=", ""); + } + + if (!string.IsNullOrEmpty(hostname)) + { + string role = "Distribution Point"; + if (roles != null && roles.Count > 0) + { + var rolesList = new List(); + foreach (var r in roles) + { + rolesList.Add(r.ToString()); + } + role = string.Join(", ", rolesList); + } + + servers.Add(new SCCMServer + { + Hostname = hostname, + SiteCode = siteCode, + SiteName = siteName, + Role = role, + DiscoveryMethod = "LDAP-SiteSystem" + }); + + if (_debug) + { + Mq.Degub($"[SCCM] Found DP: {hostname} (Site: {siteCode})"); + } + } + } + } + } + catch (Exception ex) + { + if (_debug) + { + Mq.Degub($"[SCCM] Distribution Point query failed: {ex.Message}"); + } + } + + return servers; + } + + private List QueryViaServiceConnectionPoint() + { + var servers = new List(); + + try + { + var ldapPath = BuildLdapPath(""); + + using (var entry = new DirectoryEntry(ldapPath, _username, _password)) + using (var searcher = new DirectorySearcher(entry)) + { + searcher.Filter = "(&(objectClass=serviceConnectionPoint)(cn=SMS-MP-*))"; + searcher.PropertiesToLoad.Add("serviceBindingInformation"); + searcher.PropertiesToLoad.Add("cn"); + searcher.SearchScope = System.DirectoryServices.SearchScope.Subtree; + + var results = searcher.FindAll(); + + if (_debug && results.Count > 0) + { + Mq.Degub($"[SCCM] Found {results.Count} service connection points"); + } + + foreach (SearchResult result in results) + { + var bindingInfo = result.Properties["serviceBindingInformation"]; + if (bindingInfo != null && bindingInfo.Count > 0) + { + foreach (var info in bindingInfo) + { + var infoStr = info.ToString(); + if (infoStr.Contains("://")) + { + var hostname = ExtractHostnameFromUrl(infoStr); + if (!string.IsNullOrEmpty(hostname)) + { + servers.Add(new SCCMServer + { + Hostname = hostname, + Role = "Management Point", + DiscoveryMethod = "LDAP-ServiceConnectionPoint" + }); + + if (_debug) + { + Mq.Degub($"[SCCM] Found MP: {hostname}"); + } + } + } + } + } + } + } + } + catch (Exception ex) + { + if (_debug) + { + Mq.Degub($"[SCCM] Service Connection Point query failed: {ex.Message}"); + } + } + + return servers; + } + + private List QueryViaComputerNamePatterns() + { + var servers = new List(); + + try + { + var ldapPath = BuildLdapPath(""); + + using (var entry = new DirectoryEntry(ldapPath, _username, _password)) + using (var searcher = new DirectorySearcher(entry)) + { + searcher.Filter = "(|(cn=*SCCM*)(cn=*SMS*)(description=*SCCM*)(description=*SMS*)(description=*Configuration Manager*))"; + searcher.PropertiesToLoad.Add("dNSHostName"); + searcher.PropertiesToLoad.Add("cn"); + searcher.PropertiesToLoad.Add("description"); + searcher.SearchScope = System.DirectoryServices.SearchScope.Subtree; + + var results = searcher.FindAll(); + + if (_debug && results.Count > 0) + { + Mq.Degub($"[SCCM] Found {results.Count} potential servers by name pattern"); + } + + foreach (SearchResult result in results) + { + var hostname = result.Properties["dNSHostName"]?[0]?.ToString(); + var description = result.Properties["description"]?[0]?.ToString(); + + if (!string.IsNullOrEmpty(hostname)) + { + servers.Add(new SCCMServer + { + Hostname = hostname, + Role = "Potential SCCM Server", + Description = description, + DiscoveryMethod = "LDAP-NamePattern" + }); + } + } + } + } + catch (Exception ex) + { + if (_debug) + { + Mq.Degub($"[SCCM] Name pattern query failed: {ex.Message}"); + } + } + + return servers; + } + + private string BuildLdapPath(string additionalPath) + { + var dcComponents = _domain.Split('.') + .Select(part => $"DC={part}"); + var dcString = string.Join(",", dcComponents); + + if (string.IsNullOrEmpty(additionalPath)) + { + return $"LDAP://{dcString}"; + } + + return $"LDAP://{additionalPath},{dcString}"; + } + + + private string ExtractHostnameFromUrl(string url) + { + try + { + var uri = new Uri(url); + return uri.Host; + } + catch + { + var parts = url.Split(new[] { "://" }, StringSplitOptions.None); + if (parts.Length >= 2) + return parts[1].Split(':')[0]; + return null; + } + } + + + public List ValidateServers(List servers) + { + var validatedServers = new List(); + + Mq.Info($"[SCCM] Validating {servers.Count} server(s)..."); + + foreach (var server in servers) + { + try + { + var addresses = Dns.GetHostAddresses(server.Hostname); + server.IPAddress = addresses.FirstOrDefault()?.ToString(); + + var sharePath = $"\\\\{server.Hostname}\\SCCMContentLib$"; + + if (System.IO.Directory.Exists(sharePath)) + { + server.SCCMContentLibAccessible = true; + validatedServers.Add(server); + + if (_debug) + { + Mq.Degub($"[SCCM] Validated: {server.Hostname}"); + } + } + } + catch (Exception ex) + { + if (_debug) + { + Mq.Degub($"[SCCM] Validation failed for {server.Hostname}: {ex.Message}"); + } + } + } + + Mq.Info($"[SCCM] {validatedServers.Count}/{servers.Count} server(s) validated"); + + return validatedServers; + } + + public List BuildTargetPaths(List servers) + { + var targets = new List(); + + foreach (var server in servers) + { + targets.Add($"\\\\{server.Hostname}\\SCCMContentLib$"); + + if (!string.IsNullOrEmpty(server.SiteCode)) + { + targets.Add($"\\\\{server.Hostname}\\SMS_{server.SiteCode}"); + targets.Add($"\\\\{server.Hostname}\\SMSPKG{server.SiteCode}"); + } + } + + return targets.Distinct().ToList(); + } + } + + public class SCCMServer + { + public string Hostname { get; set; } + public string IPAddress { get; set; } + public string SiteCode { get; set; } + public string SiteName { get; set; } + public string Role { get; set; } + public string Description { get; set; } + public string DiscoveryMethod { get; set; } + public bool SCCMContentLibAccessible { get; set; } + + public override string ToString() + { + return $"{Hostname} ({Role}) - Site: {SiteCode ?? "Unknown"}"; + } + } +} diff --git a/SnaffCore/SCCM/SCCMFileMapping.cs b/SnaffCore/SCCM/SCCMFileMapping.cs new file mode 100644 index 00000000..9d2cb264 --- /dev/null +++ b/SnaffCore/SCCM/SCCMFileMapping.cs @@ -0,0 +1,160 @@ +using System; +using System.Collections.Generic; +using System.IO; + +namespace SnaffCore.SCCM +{ + public class SCCMFileMetadata + { + public string OriginalName { get; set; } + public string PackageId { get; set; } + public string Version { get; set; } + public string Hash { get; set; } + public string ContentType { get; set; } // "FileLib", "DataLib", "PkgLib", "Legacy" + } + + public static class SCCMFileMapping + { + private static Dictionary _hashToFilename = new Dictionary(StringComparer.OrdinalIgnoreCase); + private static Dictionary _pathToOriginalName = new Dictionary(StringComparer.OrdinalIgnoreCase); + private static Dictionary _fileMetadata = new Dictionary(StringComparer.OrdinalIgnoreCase); + private static object _lock = new object(); + + public static void AddMapping(string hashValue, string originalFilename) + { + if (string.IsNullOrEmpty(hashValue) || string.IsNullOrEmpty(originalFilename)) + return; + + lock (_lock) + { + _hashToFilename[hashValue] = originalFilename; + } + } + + public static void AddPathMapping(string fullPath, string originalName) + { + if (string.IsNullOrEmpty(fullPath) || string.IsNullOrEmpty(originalName)) + return; + + lock (_lock) + { + _pathToOriginalName[fullPath.ToLower()] = originalName; + } + } + + public static string GetOriginalFilename(string hashValue) + { + if (string.IsNullOrEmpty(hashValue)) + return null; + + lock (_lock) + { + return _hashToFilename.TryGetValue(hashValue, out string filename) ? filename : null; + } + } + + public static string GetOriginalFilenameFromPath(string fullPath) + { + if (string.IsNullOrEmpty(fullPath)) + return null; + + lock (_lock) + { + if (_pathToOriginalName.TryGetValue(fullPath.ToLower(), out string filename)) + return filename; + } + + + string fileName = Path.GetFileName(fullPath); + if (!string.IsNullOrEmpty(fileName)) + { + + string hashValue = Path.GetFileNameWithoutExtension(fileName); + return GetOriginalFilename(hashValue); + } + + return null; + } + + public static string FormatPathWithOriginalName(string fullPath) + { + string originalName = GetOriginalFilenameFromPath(fullPath); + + if (!string.IsNullOrEmpty(originalName)) + { + + return $"{fullPath}({originalName})"; + } + + return fullPath; + } + + public static bool IsSCCMHashFile(string fullPath) + { + if (string.IsNullOrEmpty(fullPath)) + return false; + + + if (!fullPath.Contains("FileLib") && !fullPath.Contains("DataLib")) + return false; + + + string fileName = Path.GetFileNameWithoutExtension(fullPath); + if (fileName.Length >= 32 && fileName.Length <= 64) + { + foreach (char c in fileName) + { + if (!((c >= '0' && c <= '9') || + (c >= 'a' && c <= 'f') || + (c >= 'A' && c <= 'F'))) + { + return false; + } + } + return true; + } + + return false; + } + + public static void Clear() + { + lock (_lock) + { + _hashToFilename.Clear(); + _pathToOriginalName.Clear(); + _fileMetadata.Clear(); + } + } + + public static string GetStatistics() + { + lock (_lock) + { + return $"Hash mappings: {_hashToFilename.Count}, Path mappings: {_pathToOriginalName.Count}, Metadata entries: {_fileMetadata.Count}"; + } + } + + public static void AddMetadata(string fullPath, SCCMFileMetadata metadata) + { + if (string.IsNullOrEmpty(fullPath) || metadata == null) + return; + + lock (_lock) + { + _fileMetadata[fullPath.ToLower()] = metadata; + } + } + + public static SCCMFileMetadata GetMetadata(string fullPath) + { + if (string.IsNullOrEmpty(fullPath)) + return null; + + lock (_lock) + { + return _fileMetadata.TryGetValue(fullPath.ToLower(), out SCCMFileMetadata metadata) ? metadata : null; + } + } + } +} \ No newline at end of file diff --git a/SnaffCore/ShareFind/ShareFinder.cs b/SnaffCore/ShareFind/ShareFinder.cs index 4d1395c1..d0f7ecad 100644 --- a/SnaffCore/ShareFind/ShareFinder.cs +++ b/SnaffCore/ShareFind/ShareFinder.cs @@ -1,4 +1,4 @@ -using SnaffCore.Classifiers; +using SnaffCore.Classifiers; using SnaffCore.Concurrency; using SnaffCore.TreeWalk; using System; @@ -10,6 +10,7 @@ using SnaffCore.ActiveDirectory; using SnaffCore.Classifiers.EffectiveAccess; using static SnaffCore.Config.Options; +using SnaffCore.SCCM; namespace SnaffCore.ShareFind @@ -19,6 +20,7 @@ public class ShareFinder private BlockingMq Mq { get; set; } private BlockingStaticTaskScheduler TreeTaskScheduler { get; set; } private TreeWalker TreeWalker { get; set; } + private SCCMContentLibResolver SCCMResolver { get; set; } //private EffectivePermissions effectivePermissions { get; set; } = new EffectivePermissions(MyOptions.CurrentUser); public ShareFinder() @@ -26,6 +28,7 @@ public ShareFinder() Mq = BlockingMq.GetMq(); TreeTaskScheduler = SnaffCon.GetTreeTaskScheduler(); TreeWalker = SnaffCon.GetTreeWalker(); + SCCMResolver = new SCCMContentLibResolver(MyOptions.LogLevelString == "debug" || MyOptions.LogLevelString == "trace"); } internal void GetComputerShares(string computer) @@ -183,19 +186,89 @@ internal void GetComputerShares(string computer) if (MyOptions.ScanFoundShares) { - Mq.Trace("Creating a TreeWalker task for " + shareResult.SharePath); - TreeTaskScheduler.New(() => + if (SCCMResolver.IsSCCMShare(shareResult.SharePath)) { - try + Mq.Trace("Creating a TreeWalker task for SCCM share " + shareResult.SharePath); + + TreeTaskScheduler.New(() => { - TreeWalker.WalkTree(shareResult.SharePath); - } - catch (Exception e) + try + { + var resolvedPaths = SCCMResolver.ResolveSCCMContentPaths(shareResult.SharePath); + + if (resolvedPaths.Count > 0) + { + Mq.Degub($"Processing {resolvedPaths.Count} SCCM files with directory classifiers"); + + // Group files by directory for directory-level classification + var directoryMap = SCCMResolver.GroupFilesByDirectory(resolvedPaths); + + foreach (var directory in directoryMap.Keys) + { + bool dirMatched = false; + + // Apply directory classifiers + foreach (ClassifierRule dirRule in MyOptions.DirClassifiers) + { + DirClassifier dirClassifier = new DirClassifier(dirRule); + DirResult result = dirClassifier.ClassifyDir(directory); + if (result != null) // Directory matched a rule + { + // Directory matched a discard rule, skip its files + dirMatched = true; + break; + } + } + + // If directory wasn't filtered out, process its files + if (!dirMatched) + { + foreach (var filePath in directoryMap[directory]) + { + TreeWalker.ProcessSCCMFile(filePath); + } + } + } + } + else + { + Mq.Degub("No SCCM files resolved, using standard tree walk"); + TreeWalker.WalkTree(shareResult.SharePath); + } + } + catch (Exception e) + { + Mq.Error("Exception in SCCM ContentLib processing for share " + shareResult.SharePath); + Mq.Trace(e.ToString()); + Mq.Degub("SCCM resolution error, using standard tree walk"); + try + { + TreeWalker.WalkTree(shareResult.SharePath); + } + catch (Exception ex) + { + Mq.Error("Fallback tree walk also failed: " + ex.Message); + Mq.Trace(ex.ToString()); + } + } + }); + } + else + { + Mq.Trace("Creating a TreeWalker task for " + shareResult.SharePath); + TreeTaskScheduler.New(() => { - Mq.Error("Exception in TreeWalker task for share " + shareResult.SharePath); - Mq.Error(e.ToString()); - } - }); + try + { + TreeWalker.WalkTree(shareResult.SharePath); + } + catch (Exception e) + { + Mq.Error("Exception in TreeWalker task for share " + shareResult.SharePath); + Mq.Error(e.ToString()); + } + }); + } } Mq.ShareResult(shareResult); } @@ -364,4 +437,4 @@ private enum ShareType : uint StypeSpecial = 0x80000000 } } -} \ No newline at end of file +} diff --git a/SnaffCore/SnaffCon.cs b/SnaffCore/SnaffCon.cs index 5334939d..0009a727 100644 --- a/SnaffCore/SnaffCon.cs +++ b/SnaffCore/SnaffCon.cs @@ -5,6 +5,7 @@ using SnaffCore.ShareFind; using SnaffCore.TreeWalk; using SnaffCore.FileScan; +using SnaffCore.SCCM; using System; using System.Collections.Generic; using System.Diagnostics; @@ -92,13 +93,16 @@ public void Execute() statusUpdateTimer.Start(); - // If we want to hunt for user IDs, we need data from the running user's domain. - // Future - walk trusts if ( MyOptions.DomainUserRules) { DomainUserDiscovery(); } + if (MyOptions.ShareFinderEnabled && !string.IsNullOrEmpty(MyOptions.TargetDomain)) + { + SCCMServerDiscovery(); + } + // Explicit folder setting overrides DFS if (MyOptions.PathTargets.Count != 0 && (MyOptions.DfsShareDiscovery || MyOptions.DfsOnly)) { @@ -249,6 +253,52 @@ private void DomainUserDiscovery() PrepDomainUserRules(); } + private void SCCMServerDiscovery() + { + try + { + List servers = new List(); + + var discovery = new SCCMDiscovery( + MyOptions.TargetDomain, + username: null, + password: null, + debug: MyOptions.LogLevelString == "debug" || MyOptions.LogLevelString == "degub" || MyOptions.LogLevelString == "trace", + ldapPort: 389 + ); + + servers = discovery.DiscoverSCCMServers(); + + if (servers.Count == 0) + { + Mq.Degub("[SCCM] No SCCM servers found in domain"); + return; + } + + Mq.Degub($"[SCCM] Auto-discovered {servers.Count} SCCM server(s) in domain"); + + var targetPaths = discovery.BuildTargetPaths(servers); + + Mq.Degub($"[SCCM] Adding {targetPaths.Count} SCCM shares to scan queue"); + + foreach (var path in targetPaths) + { + if (!MyOptions.PathTargets.Contains(path)) + { + MyOptions.PathTargets.Add(path); + } + } + } + catch (Exception ex) + { + Mq.Degub($"[SCCM] Discovery error: {ex.Message}"); + if (MyOptions.LogLevelString == "debug" || MyOptions.LogLevelString == "degub" || MyOptions.LogLevelString == "trace") + { + Mq.Degub($"[SCCM] Stack trace: {ex.StackTrace}"); + } + } + } + public void PrepDomainUserRules() { try diff --git a/SnaffCore/SnaffCore.csproj b/SnaffCore/SnaffCore.csproj index fa0fc804..9d65e6c0 100644 --- a/SnaffCore/SnaffCore.csproj +++ b/SnaffCore/SnaffCore.csproj @@ -1,4 +1,4 @@ - + @@ -9,7 +9,7 @@ Properties SnaffCore SnaffCore - v4.5.1 + v4.8 512 true @@ -88,6 +88,10 @@ + + + + @@ -95,4 +99,4 @@ - \ No newline at end of file + diff --git a/SnaffCore/TreeWalk/TreeWalker.cs b/SnaffCore/TreeWalk/TreeWalker.cs index c3cf3fc3..feca11d8 100644 --- a/SnaffCore/TreeWalk/TreeWalker.cs +++ b/SnaffCore/TreeWalk/TreeWalker.cs @@ -23,6 +23,22 @@ public TreeWalker() FileScanner = SnaffCon.GetFileScanner(); } + public void ProcessSCCMFile(string filePath) + { + FileTaskScheduler.New(() => + { + try + { + FileScanner.ScanFile(filePath); + } + catch (Exception e) + { + Mq.Error($"Exception in FileScanner task for {filePath}"); + Mq.Trace(e.ToString()); + } + }); + } + public void WalkTree(string currentDir) { // Walks a tree checking files and generating results as it goes. diff --git a/Snaffler/Config.cs b/Snaffler/Config.cs index e9aedb21..81dac351 100644 --- a/Snaffler/Config.cs +++ b/Snaffler/Config.cs @@ -1,6 +1,7 @@ using CommandLineParser.Arguments; using Nett; using NLog; +using SnaffCore.Classifiers; using SnaffCore.Concurrency; using SnaffCore.Config; using System; @@ -74,7 +75,8 @@ private static Options ParseImpl(string[] args) "Path for output file. You probably want this if you're not using -s."); ValueArgument verboseArg = new ValueArgument('v', "verbosity", "Controls verbosity level, options are Trace (most verbose), Debug (less verbose), Info (less verbose still, default), and Data (results only). e.g '-v debug' "); - SwitchArgument helpArg = new SwitchArgument('h', "help", "Displays this help.", false); + // Note: Removed custom help arg as CommandLineParser library may provide its own + // SwitchArgument helpArg = new SwitchArgument('h', "help", "Displays this help.", false); SwitchArgument stdOutArg = new SwitchArgument('s', "stdout", "Enables outputting results to stdout as soon as they're found. You probably want this if you're not using -o.", false); @@ -106,32 +108,41 @@ private static Options ParseImpl(string[] args) ValueArgument logType = new ValueArgument('t', "logtype", "Type of log you would like to output. Currently supported options are plain and JSON. Defaults to plain."); ValueArgument timeOutArg = new ValueArgument('e', "timeout", "Interval between status updates (in minutes) also acts as a timeout for AD data to be gathered via LDAP. Turn this knob up if you aren't getting any computers from AD when you run Snaffler through a proxy or other slow link. Default = 5"); - // list of letters i haven't used yet: gnqw CommandLineParser.CommandLineParser parser = new CommandLineParser.CommandLineParser(); - parser.Arguments.Add(timeOutArg); - parser.Arguments.Add(configFileArg); - parser.Arguments.Add(outFileArg); - parser.Arguments.Add(helpArg); - parser.Arguments.Add(stdOutArg); - parser.Arguments.Add(snaffleArg); - parser.Arguments.Add(snaffleSizeArg); - parser.Arguments.Add(dirTargetArg); - parser.Arguments.Add(interestLevel); - parser.Arguments.Add(domainArg); - parser.Arguments.Add(verboseArg); - parser.Arguments.Add(domainControllerArg); - parser.Arguments.Add(maxGrepSizeArg); - parser.Arguments.Add(grepContextArg); - parser.Arguments.Add(domainUserArg); - parser.Arguments.Add(tsvArg); - parser.Arguments.Add(dfsArg); - parser.Arguments.Add(findSharesOnlyArg); - parser.Arguments.Add(maxThreadsArg); - parser.Arguments.Add(compTargetArg); - parser.Arguments.Add(ruleDirArg); - parser.Arguments.Add(logType); - parser.Arguments.Add(compExclusionArg); + parser.ShowUsageOnEmptyCommandline = false; + try + { + parser.Arguments.Add(timeOutArg); + parser.Arguments.Add(configFileArg); + parser.Arguments.Add(outFileArg); + // parser.Arguments.Add(helpArg); // Commented out - library provides default + parser.Arguments.Add(stdOutArg); + parser.Arguments.Add(snaffleArg); + parser.Arguments.Add(snaffleSizeArg); + parser.Arguments.Add(dirTargetArg); + parser.Arguments.Add(interestLevel); + parser.Arguments.Add(domainArg); + parser.Arguments.Add(verboseArg); + parser.Arguments.Add(domainControllerArg); + parser.Arguments.Add(maxGrepSizeArg); + parser.Arguments.Add(grepContextArg); + parser.Arguments.Add(domainUserArg); + parser.Arguments.Add(tsvArg); + parser.Arguments.Add(dfsArg); + parser.Arguments.Add(findSharesOnlyArg); + parser.Arguments.Add(maxThreadsArg); + parser.Arguments.Add(compTargetArg); + parser.Arguments.Add(ruleDirArg); + parser.Arguments.Add(logType); + parser.Arguments.Add(compExclusionArg); + } + catch (ArgumentException ex) + { + Mq.Error("Error setting up command line parser: " + ex.Message); + Mq.Error("This is likely a bug in Snaffler. Please report it."); + throw; + } // extra check to handle builtin behaviour from cmd line arg parser if ((args.Contains("--help") || args.Contains("/?") || args.Contains("help") || args.Contains("-h") || args.Length == 0)) @@ -149,6 +160,19 @@ private static Options ParseImpl(string[] args) try { parser.ParseCommandLine(args); + } + catch (ArgumentException ex) + { + Mq.Error("Error parsing command line arguments: " + ex.Message); + Mq.Error("Possible causes:"); + Mq.Error(" - Duplicate arguments (e.g., using -d twice)"); + Mq.Error(" - Invalid argument format"); + Mq.Error("Use --help to see valid arguments."); + return null; + } + + try + { if (timeOutArg.Parsed && !String.IsNullOrWhiteSpace(timeOutArg.Value)) { @@ -243,7 +267,7 @@ private static Options ParseImpl(string[] args) { compTargets.AddRange(File.ReadLines(compTargetArg.Value).Select(line => line.Trim())); } - else if (compTargetArg.Value.Contains(",")) + else if (compTargetArg.Value.Contains(',')) { compTargets.AddRange(compTargetArg.Value.Split(',').Select(x => x.Trim())); } @@ -416,7 +440,7 @@ private static Options ParseImpl(string[] args) { // get all the embedded toml file resources string[] resourceNames = Assembly.GetExecutingAssembly().GetManifestResourceNames(); - StringBuilder sb = new StringBuilder(); + List allRules = new List(); foreach (string resourceName in resourceNames) { @@ -426,32 +450,51 @@ private static Options ParseImpl(string[] args) continue; } string ruleFile = ReadResource(resourceName); - sb.AppendLine(ruleFile); + + // Parse each TOML file individually + try + { + RuleSet ruleSet = Toml.ReadString(ruleFile, settings); + if (ruleSet.ClassifierRules != null) + { + allRules.AddRange(ruleSet.ClassifierRules); + } + } + catch (Exception e) + { + Mq.Error($"Failed to parse rule file {resourceName}: {e.Message}"); + } } - string bulktoml = sb.ToString(); - - // deserialise the toml to an actual ruleset - RuleSet ruleSet = Toml.ReadString(bulktoml, settings); - // stick the rules in our config! - parsedConfig.ClassifierRules = ruleSet.ClassifierRules; + parsedConfig.ClassifierRules = allRules; } else { string[] tomlfiles = Directory.GetFiles(parsedConfig.RuleDir, "*.toml", SearchOption.AllDirectories); - StringBuilder sb = new StringBuilder(); + List allRules = new List(); + foreach (string tomlfile in tomlfiles) { string tomlstring = File.ReadAllText(tomlfile); - sb.AppendLine(tomlstring); + + // Parse each TOML file individually + try + { + RuleSet ruleSet = Toml.ReadString(tomlstring, settings); + if (ruleSet.ClassifierRules != null) + { + allRules.AddRange(ruleSet.ClassifierRules); + } + } + catch (Exception e) + { + Mq.Error($"Failed to parse rule file {tomlfile}: {e.Message}"); + } } - string bulktoml = sb.ToString(); - // deserialise the toml to an actual ruleset - RuleSet ruleSet = Toml.ReadString(bulktoml, settings); // stick the rules in our config! - parsedConfig.ClassifierRules = ruleSet.ClassifierRules; + parsedConfig.ClassifierRules = allRules; } } diff --git a/Snaffler/Properties/Resources.Designer.cs b/Snaffler/Properties/Resources.Designer.cs index 01383f00..152c05a6 100644 --- a/Snaffler/Properties/Resources.Designer.cs +++ b/Snaffler/Properties/Resources.Designer.cs @@ -19,7 +19,7 @@ namespace Snaffler.Properties { // class via a tool like ResGen or Visual Studio. // To add or remove a member, edit your .ResX file then rerun ResGen // with the /str option, or rebuild your VS project. - [global::System.CodeDom.Compiler.GeneratedCodeAttribute("System.Resources.Tools.StronglyTypedResourceBuilder", "16.0.0.0")] + [global::System.CodeDom.Compiler.GeneratedCodeAttribute("System.Resources.Tools.StronglyTypedResourceBuilder", "17.0.0.0")] [global::System.Diagnostics.DebuggerNonUserCodeAttribute()] [global::System.Runtime.CompilerServices.CompilerGeneratedAttribute()] public class Resources { diff --git a/Snaffler/SnaffRules/DefaultRules/FileRules/Keep/Infrastructure/DeploymentAutomation/KeepSCCMContentFiles.toml b/Snaffler/SnaffRules/DefaultRules/FileRules/Keep/Infrastructure/DeploymentAutomation/KeepSCCMContentFiles.toml new file mode 100644 index 00000000..e90cea8c --- /dev/null +++ b/Snaffler/SnaffRules/DefaultRules/FileRules/Keep/Infrastructure/DeploymentAutomation/KeepSCCMContentFiles.toml @@ -0,0 +1,65 @@ +[[ClassifierRules]] +EnumerationScope = "FileEnumeration" +RuleName = "KeepSCCMDeploymentScripts" +MatchAction = "Snaffle" +Description = "Finds SCCM deployment scripts that may contain credentials or sensitive configs" +MatchLocation = "FilePath" +WordListType = "Regex" +MatchLength = 0 +WordList = ["\\\\(DataLib|FileLib|PkgLib|SMSPKG)\\\\.*\\.(ps1|cmd|bat|vbs)$"] +Triage = "Red" + +[[ClassifierRules]] +EnumerationScope = "FileEnumeration" +RuleName = "KeepSCCMConfigFiles" +MatchAction = "Snaffle" +Description = "Finds SCCM configuration files in DataLib and SMSPKG (excludes PkgLib metadata)" +MatchLocation = "FilePath" +WordListType = "Regex" +MatchLength = 0 +WordList = ["\\\\(DataLib|SMSPKG)\\\\.*\\.(xml|config|ini|json)$", "\\\\FileLib\\\\.*\\.(xml|config|json)$"] +Triage = "Yellow" + +[[ClassifierRules]] +EnumerationScope = "FileEnumeration" +RuleName = "KeepSCCMUnattendFiles" +MatchAction = "Snaffle" +Description = "Finds unattended installation files in SCCM packages" +MatchLocation = "FilePath" +WordListType = "Regex" +MatchLength = 0 +WordList = ["\\\\(DataLib|FileLib|PkgLib|SMSPKG)\\\\.*(?i)(unattend|autounattend|sysprep|answer)\\.(xml|txt|ini)$"] +Triage = "Red" + +[[ClassifierRules]] +EnumerationScope = "FileEnumeration" +RuleName = "KeepSCCMCertificates" +MatchAction = "Snaffle" +Description = "Finds certificates in SCCM packages" +MatchLocation = "FilePath" +WordListType = "Regex" +MatchLength = 0 +WordList = ["\\\\(DataLib|FileLib|PkgLib|SMSPKG)\\\\.*\\.(cer|pem|pfx|p12|key|crt)$"] +Triage = "Red" + +[[ClassifierRules]] +EnumerationScope = "FileEnumeration" +RuleName = "KeepSCCMRegistryFiles" +MatchAction = "Snaffle" +Description = "Finds registry files in SCCM packages that may contain settings" +MatchLocation = "FilePath" +WordListType = "Regex" +MatchLength = 0 +WordList = ["\\\\(DataLib|FileLib|PkgLib|SMSPKG)\\\\.*\\.reg$"] +Triage = "Yellow" + +[[ClassifierRules]] +EnumerationScope = "FileEnumeration" +RuleName = "KeepSCCMInstallers" +MatchAction = "Relay" +Description = "Finds installer packages in SCCM ContentLib" +MatchLocation = "FilePath" +WordListType = "Regex" +MatchLength = 0 +WordList = ["\\\\(DataLib|FileLib|PkgLib|SMSPKG)\\\\.*\\.(msi|msp|mst)$"] +Triage = "Green" \ No newline at end of file diff --git a/Snaffler/SnaffleRunner.cs b/Snaffler/SnaffleRunner.cs index 690ea974..6049a925 100644 --- a/Snaffler/SnaffleRunner.cs +++ b/Snaffler/SnaffleRunner.cs @@ -446,6 +446,53 @@ public string FileResultLogFromMessage(SnafflerMessage message) string filepath = message.FileResult.FileInfo.FullName; + if (SnaffCore.SCCM.SCCMFileMapping.IsSCCMHashFile(filepath)) + { + var metadata = SnaffCore.SCCM.SCCMFileMapping.GetMetadata(filepath); + if (metadata != null) + { + // Format with full metadata: path(Package:ID|version|original.exe) + string metaInfo = metadata.OriginalName ?? "Unknown"; + if (!string.IsNullOrEmpty(metadata.PackageId)) + { + metaInfo = $"Pkg:{metadata.PackageId}"; + if (!string.IsNullOrEmpty(metadata.Version)) + { + metaInfo += $"|v{metadata.Version}"; + } + metaInfo += $"|{metadata.OriginalName}"; + } + filepath = $"{filepath}({metaInfo})"; + } + else + { + // Fallback to simple name resolution + string originalName = SnaffCore.SCCM.SCCMFileMapping.GetOriginalFilenameFromPath(filepath); + if (!string.IsNullOrEmpty(originalName)) + { + filepath = $"{filepath}({originalName})"; + } + } + } + else + { + // Check if it has metadata even if not a hash file (e.g., INI files) + var metadata = SnaffCore.SCCM.SCCMFileMapping.GetMetadata(filepath); + if (metadata != null && !string.IsNullOrEmpty(metadata.PackageId)) + { + string metaInfo = $"Pkg:{metadata.PackageId}"; + if (!string.IsNullOrEmpty(metadata.Version)) + { + metaInfo += $"|v{metadata.Version}"; + } + if (!string.IsNullOrEmpty(metadata.ContentType)) + { + metaInfo += $"|{metadata.ContentType}"; + } + filepath = $"{filepath}({metaInfo})"; + } + } + string matchcontext = ""; if (message.FileResult.TextResult != null) { diff --git a/Snaffler/Snaffler.csproj b/Snaffler/Snaffler.csproj index f84fdba9..4ca1eb6a 100644 --- a/Snaffler/Snaffler.csproj +++ b/Snaffler/Snaffler.csproj @@ -112,4 +112,5 @@ - \ No newline at end of file + + diff --git a/Snaffler/app.config b/Snaffler/app.config index 51278a45..0b6d5742 100644 --- a/Snaffler/app.config +++ b/Snaffler/app.config @@ -1,3 +1,4 @@ - - - + + + +