diff --git a/README.md b/README.md index fd3dcb7..5a219e5 100644 --- a/README.md +++ b/README.md @@ -7,6 +7,7 @@ It helps you enforce consistent file upload rules by checking: - File size limits - File signatures (magic numbers) to detect spoofed types - Specification conformance for Office Open XML / Open Document Formats (`.docx`, `.xlsx`, `.pptx`, `.odt`) +- Malware scan result using a varity of scanners (_requires the addition of a specific ByteGuard.FileValidator scanner package_) > ⚠️ **Important:** This library should be part of a **defense-in-depth** strategy. It does not replace antivirus scanning, sandboxing, or other security controls. @@ -17,6 +18,7 @@ It does not replace antivirus scanning, sandboxing, or other security controls. - ✅ Validate files by **size** - ✅ Validate files by **signature (_magic-numbers_)** - ✅ Validate files by **specification conformance** for archive-based formats (_Open XML and Open Document Formats_) +- ✅ **Ensure no malware** through a variety of antimalware scanners - ✅ Validate using file path, `Stream`, or `byte[]` - ✅ Configure which file types to support - ✅ Configure whether to **throw exceptions** or simply return a boolean @@ -25,13 +27,15 @@ It does not replace antivirus scanning, sandboxing, or other security controls. ## Getting Started ### Installation -This package is published and installed via NuGet. +This package is published and installed via [NuGet](https://www.nuget.org/packages/ByteGuard.FileValidator). Reference the package in your project: ```bash dotnet add package ByteGuard.FileValidator ``` +### Antimalware scanners +In order to use the antimalware scanning capabilities, ensure you have a ByteGuard.FileValidator antimalware package referenced as well. Youo can find the relevant scanner package on NuGet under the namespace `ByteGuard.FileValidator.Scanners`. ## Usage ### Basic validation @@ -39,9 +43,10 @@ dotnet add package ByteGuard.FileValidator ```csharp var configuration = new FileValidatorConfiguration { - SupportedFileTypes = [FileExtensions.Pdf, FileExtensions.Jpg, FileExtensions.Png], - FileSizeLimit = ByteSize.MegaBytes(25), - ThrowExceptionOnInvalidFile = false + SupportedFileTypes = [FileExtensions.Pdf, FileExtensions.Jpg, FileExtensions.Png], + FileSizeLimit = ByteSize.MegaBytes(25), + ThrowExceptionOnInvalidFile = false, + AntimalwareScanner = new SpecificAntimalwareScanner(scannerOptions) }; var fileValidator = new FileValidator(configuration); @@ -52,10 +57,11 @@ var isValid = fileValidator.IsValidFile("example.pdf", fileStream); ```csharp var configuration = new FileValidatorConfigurationBuilder() - .AllowFileTypes(FileExtensions.Pdf, FileExtensions.Jpg, FileExtensions.Png) - .SetFileSizeLimit(ByteSize.MegaBytes(25)) - .SetThrowExceptionOnInvalidFile(false) - .Build(); + .AllowFileTypes(FileExtensions.Pdf, FileExtensions.Jpg, FileExtensions.Png) + .SetFileSizeLimit(ByteSize.MegaBytes(25)) + .SetThrowExceptionOnInvalidFile(false) + .AddAntimalwareScanner(new SpecificAntimalwareScanner(scannerOptions)) + .Build(); var fileValidator = new FileValidator(configuration); var isValid = fileValidator.IsValidFile("example.pdf", fileStream); @@ -72,6 +78,7 @@ The `FileValidator` class provides methods to validate specific aspects of a fil > 2. File size validation > 3. Signature (magic-number) validation > 4. Optional Open XML / Open Document Format specification conformance validation (for supported types) +> 5. Optional antimalware scanning with a compatible scanning package ```csharp bool isExtensionValid = fileValidator.IsValidFileType(fileName); @@ -79,6 +86,7 @@ bool isFileSizeValid = fileValidator.HasValidSize(fileStream); bool isSignatureValid = fileValidator.HasValidSignature(fileName, fileStream); bool isOpenXmlValid = fileValidator.IsValidOpenXmlDocument(fileName, fileStream); bool isOpenDocumentFormatValid = fileValidator.IsValidOpenDocumentFormat(fileName, fileStream); +bool isMalwareClean = fileValidator.IsMalwareClean(fileName, fileStream); ``` ### Example @@ -92,7 +100,8 @@ public async Task Upload(IFormFile file) { SupportedFileTypes = [FileExtensions.Pdf, FileExtensions.Docx], FileSizeLimit = ByteSize.MegaBytes(10), - ThrowExceptionOnInvalidFile = false + ThrowExceptionOnInvalidFile = false, + AntimalwareScanner = new SpecificAntimalwareScanner(scannerOptions) }; var validator = new FileValidator(configuration); @@ -132,9 +141,10 @@ The following file extensions are supported by the `FileValidator`: `IsValidFile` always validates: -- File extension (against `SupportedFileTypes`) -- File size (against `FileSizeLimit`) -- File signature (magic number) +- File extension (_against `SupportedFileTypes`_) +- File size (_against `FileSizeLimit`_) +- File signature (_magic number_) +- Malware scan result (_if an antimalware scanner has been configured_) For some formats, additional checks are performed: @@ -143,11 +153,13 @@ For some formats, additional checks are performed: - File size - Signature - Specification conformance + - Malware scan result - **Other binary formats** (e.g. images, audio, video such as `.jpg`, `.png`, `.mp3`, `.mp4`): - Extension - File size - Signature + - Malware scan result ## Configuration Options @@ -158,6 +170,7 @@ The `FileValidatorConfiguration` supports: | `SupportedFileTypes` | Yes | N/A | A list of allowed file extensions (e.g., `.pdf`, `.jpg`).
Use the predefined constants in `FileExtensions` for supported types. | | `FileSizeLimit` | Yes | N/A | Maximum permitted size of files.
Use the static `ByteSize` class provided with this package, to simplify your limit. | | `ThrowExceptionOnInvalidFile` | No | `true` | Whether to throw an exception on invalid files or return `false`. | +| `AntimalwareScanner` | No | N/A | An antimalware scanner used to scan the given file for potential malware. | ### Exceptions @@ -171,6 +184,7 @@ When `ThrowExceptionOnInvalidFile` is set to `true`, validation functions will t | `InvalidSignatureException` | Thrown when the file's signature does not match the expected signature for its type. | | `InvalidOpenXmlFormatException` | Thrown when the internal structure of an Open XML file is invalid (`.docx`, `.xlsx`, `.pptx`, etc.). | | `InvalidOpenDocumentFormatException` | Thrown when the specification conformance of an Open Document Format file is invalid (`.odt`, etc.). | +| `MalwareDetectedException` | Thrown when the configured antimalware scanner detected malware in the file from a scan result. | ## When to use this package diff --git a/src/ByteGuard.FileValidator/ByteGuard.FileValidator.csproj b/src/ByteGuard.FileValidator/ByteGuard.FileValidator.csproj index 8e23ca1..aedc7cc 100644 --- a/src/ByteGuard.FileValidator/ByteGuard.FileValidator.csproj +++ b/src/ByteGuard.FileValidator/ByteGuard.FileValidator.csproj @@ -1,7 +1,7 @@  - netstandard2.0;net48;net8.0;net9.0;net10.0 + netstandard2.0;net8.0;net9.0;net10.0 ByteGuard Contributors, detilium ByteGuard File Validator is a security-focused .NET library for validating user-supplied files, providing a configurable API to help you enforce safe and consistent file handling across your applications. https://github.com/ByteGuard-HQ/byteguard-file-validator-net diff --git a/src/ByteGuard.FileValidator/ByteSize.cs b/src/ByteGuard.FileValidator/ByteSize.cs index 14c01cb..1d94935 100644 --- a/src/ByteGuard.FileValidator/ByteSize.cs +++ b/src/ByteGuard.FileValidator/ByteSize.cs @@ -1,5 +1,4 @@ -using System; -using System.Globalization; +using System.Globalization; namespace ByteGuard.FileValidator { diff --git a/src/ByteGuard.FileValidator/Configuration/ConfigurationValidator.cs b/src/ByteGuard.FileValidator/Configuration/ConfigurationValidator.cs index c0e0301..ff59372 100644 --- a/src/ByteGuard.FileValidator/Configuration/ConfigurationValidator.cs +++ b/src/ByteGuard.FileValidator/Configuration/ConfigurationValidator.cs @@ -1,6 +1,4 @@ -using System; -using System.Linq; -using ByteGuard.FileValidator.Exceptions; +using ByteGuard.FileValidator.Exceptions; namespace ByteGuard.FileValidator.Configuration { @@ -49,4 +47,4 @@ public static void ThrowIfInvalid(FileValidatorConfiguration configuration) } } } -} \ No newline at end of file +} diff --git a/src/ByteGuard.FileValidator/Configuration/FileValidatorConfiguration.cs b/src/ByteGuard.FileValidator/Configuration/FileValidatorConfiguration.cs index 2549cd9..25390af 100644 --- a/src/ByteGuard.FileValidator/Configuration/FileValidatorConfiguration.cs +++ b/src/ByteGuard.FileValidator/Configuration/FileValidatorConfiguration.cs @@ -1,5 +1,4 @@ -using System.Collections.Generic; -using ByteGuard.FileValidator.Exceptions; +using ByteGuard.FileValidator.Scanners; namespace ByteGuard.FileValidator.Configuration { @@ -27,33 +26,13 @@ public class FileValidatorConfiguration public long FileSizeLimit { get; set; } = -1; /// - /// Maximum file size limit in string representation (e.g. "25MB", "2 GB", etc.). + /// Whether to throw an exception if an unsupported/invalid file is encountered. Defaults to true. /// - /// - /// Defines the file size limit of files. See for conversion help. - /// Will be ignored if is defined. - /// Spacing ("25 MB" vs. "25MB") is irrelevant. - /// Supported string representation are: - ///
    - ///
  • B: Bytes
  • - ///
  • KB: Kilobytes
  • - ///
  • MB: Megabytes
  • - ///
  • GB: Gigabytes
  • - ///
- ///
- ///
- public string FriendlyFileSizeLimit { get; set; } + public bool ThrowExceptionOnInvalidFile { get; set; } = true; /// - /// Whether to throw an exception if an unsupported/invalid file is encountered. Defaults to true. + /// Optional antimalware scanner to use during file validation. /// - /// - /// Will throw the following exceptions: - ///
    - ///
  • if the given file type is not supported according to the .
  • - ///
  • if the file signature does not match the valid signatures for the given file type.
  • - ///
- ///
- public bool ThrowExceptionOnInvalidFile { get; set; } = true; + public IAntimalwareScanner? AntimalwareScanner { get; set; } = null; } -} \ No newline at end of file +} diff --git a/src/ByteGuard.FileValidator/Configuration/FileValidatorConfigurationBuilder.cs b/src/ByteGuard.FileValidator/Configuration/FileValidatorConfigurationBuilder.cs index 0de1375..2d6060c 100644 --- a/src/ByteGuard.FileValidator/Configuration/FileValidatorConfigurationBuilder.cs +++ b/src/ByteGuard.FileValidator/Configuration/FileValidatorConfigurationBuilder.cs @@ -1,4 +1,4 @@ -using System.Collections.Generic; +using ByteGuard.FileValidator.Scanners; namespace ByteGuard.FileValidator.Configuration { @@ -10,6 +10,7 @@ public class FileValidatorConfigurationBuilder private readonly List supportedFileTypes = new List(); private bool throwOnInvalidFiles = true; private long fileSizeLimit = ByteSize.MegaBytes(25); + private IAntimalwareScanner? antimalwareScanner = null; /// /// Allow specific file types (extensions) to be validated. @@ -43,6 +44,22 @@ public FileValidatorConfigurationBuilder SetFileSizeLimit(long inFileSizeLimit) return this; } + /// + /// Add an antimalware scanner. + /// + /// Antimalware scanner to use. + /// Thrown when the provided scanner is null. + public FileValidatorConfigurationBuilder AddAntimalwareScanner(IAntimalwareScanner scanner) + { + if (scanner == null) + { + throw new ArgumentNullException(nameof(scanner)); + } + + antimalwareScanner = scanner; + return this; + } + /// /// Build configuration. /// @@ -53,7 +70,8 @@ public FileValidatorConfiguration Build() { SupportedFileTypes = supportedFileTypes, ThrowExceptionOnInvalidFile = throwOnInvalidFiles, - FileSizeLimit = fileSizeLimit + FileSizeLimit = fileSizeLimit, + AntimalwareScanner = antimalwareScanner }; ConfigurationValidator.ThrowIfInvalid(configuration); @@ -61,4 +79,4 @@ public FileValidatorConfiguration Build() return configuration; } } -} \ No newline at end of file +} diff --git a/src/ByteGuard.FileValidator/Exceptions/AntimalwareScannerException.cs b/src/ByteGuard.FileValidator/Exceptions/AntimalwareScannerException.cs new file mode 100644 index 0000000..11cad3e --- /dev/null +++ b/src/ByteGuard.FileValidator/Exceptions/AntimalwareScannerException.cs @@ -0,0 +1,46 @@ +namespace ByteGuard.FileValidator.Exceptions +{ + /// + /// Exception type used specifically when a an antimalware scanner failed to scane the file. + /// + public class AntimalwareScannerException : Exception + { + /// + /// Default expcetion message for this expection type, if no custom message is provided. + /// + private const string defaultMessage = "An error occurred while scanning the file with the antimalware scanner."; + + /// + /// Initializes a new instance of the class indicating that an error occured during the scan. + /// + public AntimalwareScannerException() + : this(defaultMessage) + { + } + + /// + /// Initializes a new instance of the class indicating that an error occured during the scan. + /// + /// Custom exception message. + public AntimalwareScannerException(string message) : base(message) + { + } + + /// + /// Initializes a new instance of the class indicating that an error occured during the scan. + /// + /// Custom exception message. + /// Inner exception. + public AntimalwareScannerException(string message, Exception innerException) : base(message, innerException) + { + } + + /// + /// Initializes a new instance of the class indicating that an error occured during the scan. + /// + /// Inner exception. + public AntimalwareScannerException(Exception innerException) : base(defaultMessage, innerException) + { + } + } +} diff --git a/src/ByteGuard.FileValidator/Exceptions/EmptyFileException.cs b/src/ByteGuard.FileValidator/Exceptions/EmptyFileException.cs index 13c3882..b76f917 100644 --- a/src/ByteGuard.FileValidator/Exceptions/EmptyFileException.cs +++ b/src/ByteGuard.FileValidator/Exceptions/EmptyFileException.cs @@ -1,23 +1,33 @@ -using System; - -namespace ByteGuard.FileValidator.Exceptions +namespace ByteGuard.FileValidator.Exceptions { /// /// Exception type used specifically when a given file does not contain any content. /// public class EmptyFileException : Exception { + /// + /// Construct a new to indicate that the provided file does not have any content. + /// public EmptyFileException() : this("The provided file does not have any content.") { } + /// + /// Construct a new to indicate that the provided file does not have any content. + /// + /// Custom exception message. public EmptyFileException(string message) : base(message) { } + /// + /// Construct a new to indicate that the provided file does not have any content. + /// + /// Custom exception message. + /// Inner exception. public EmptyFileException(string message, Exception innerException) : base(message, innerException) { } } -} \ No newline at end of file +} diff --git a/src/ByteGuard.FileValidator/Exceptions/InvalidFileSizeException.cs b/src/ByteGuard.FileValidator/Exceptions/InvalidFileSizeException.cs index bde401a..3935b51 100644 --- a/src/ByteGuard.FileValidator/Exceptions/InvalidFileSizeException.cs +++ b/src/ByteGuard.FileValidator/Exceptions/InvalidFileSizeException.cs @@ -1,20 +1,24 @@ -using System; -using System.Runtime.Serialization; - -namespace ByteGuard.FileValidator.Exceptions +namespace ByteGuard.FileValidator.Exceptions { /// /// Exception type used specifically when a given file exceeds the configured file size limit. /// public class InvalidFileSizeException : Exception { + /// + /// Construct a new to indicate that the provided file does not adhere to the configured limit. + /// public InvalidFileSizeException() : base("File size is larger than the configured file size limit.") { } + /// + /// Construct a new to indicate that the provided file does not adhere to the configured limit. + /// + /// Custom exception message. public InvalidFileSizeException(string message) : base(message) { } } -} \ No newline at end of file +} diff --git a/src/ByteGuard.FileValidator/Exceptions/InvalidOpenDocumentFormatException.cs b/src/ByteGuard.FileValidator/Exceptions/InvalidOpenDocumentFormatException.cs index 0b64530..ae1e557 100644 --- a/src/ByteGuard.FileValidator/Exceptions/InvalidOpenDocumentFormatException.cs +++ b/src/ByteGuard.FileValidator/Exceptions/InvalidOpenDocumentFormatException.cs @@ -1,5 +1,3 @@ -using System; - namespace ByteGuard.FileValidator.Exceptions { /// @@ -8,15 +6,27 @@ namespace ByteGuard.FileValidator.Exceptions /// /// public class InvalidOpenDocumentFormatException : Exception { + /// + /// Construct a new to indicate that the provided file does not adhere to the OpenDocument format specification. + /// public InvalidOpenDocumentFormatException() : base("Invalid Open Document Format file.") { } + /// + /// Construct a new to indicate that the provided file does not adhere to the OpenDocument format specification. + /// + /// Custom exception message. public InvalidOpenDocumentFormatException(string message) : base(message) { } + /// + /// Construct a new to indicate that the provided file does not adhere to the OpenDocument format specification. + /// + /// Custom exception message. + /// Inner exception. public InvalidOpenDocumentFormatException(string message, Exception innerException) : base(message, innerException) { } diff --git a/src/ByteGuard.FileValidator/Exceptions/InvalidOpenXmlFormatException.cs b/src/ByteGuard.FileValidator/Exceptions/InvalidOpenXmlFormatException.cs index 478959e..bce9463 100644 --- a/src/ByteGuard.FileValidator/Exceptions/InvalidOpenXmlFormatException.cs +++ b/src/ByteGuard.FileValidator/Exceptions/InvalidOpenXmlFormatException.cs @@ -1,6 +1,4 @@ -using System; - -namespace ByteGuard.FileValidator.Exceptions +namespace ByteGuard.FileValidator.Exceptions { /// /// Exception type used specifically when a given file, which is expected to be an Open XML file, does not adhere @@ -8,17 +6,29 @@ namespace ByteGuard.FileValidator.Exceptions /// public class InvalidOpenXmlFormatException : Exception { + /// + /// Construct a new to indicate that the provided file does not adhere to the Microsoft Open XML format specification. + /// public InvalidOpenXmlFormatException() : base("Invalid Open XML file.") { } + /// + /// Construct a new to indicate that the provided file does not adhere to the Microsoft Open XML format specification. + /// + /// Custom exception message. public InvalidOpenXmlFormatException(string message) : base(message) { } + /// + /// Construct a new to indicate that the provided file does not adhere to the Microsoft Open XML format specification. + /// + /// Custom exception message. + /// Inner exception. public InvalidOpenXmlFormatException(string message, Exception innerException) : base(message, innerException) { } } -} \ No newline at end of file +} diff --git a/src/ByteGuard.FileValidator/Exceptions/InvalidSignatureException.cs b/src/ByteGuard.FileValidator/Exceptions/InvalidSignatureException.cs index 630aaeb..89ea67f 100644 --- a/src/ByteGuard.FileValidator/Exceptions/InvalidSignatureException.cs +++ b/src/ByteGuard.FileValidator/Exceptions/InvalidSignatureException.cs @@ -1,19 +1,24 @@ -using System; - -namespace ByteGuard.FileValidator.Exceptions +namespace ByteGuard.FileValidator.Exceptions { /// /// Exception type used specifically when a given file is invalid based on signature. /// public class InvalidSignatureException : Exception { + /// + /// Construct a new to indicate that the provided file signature does not match the file extension. + /// public InvalidSignatureException() : base("File signature does not match the expected signature for the given file type.") { } + /// + /// Construct a new to indicate that the provided file signature does not match the file extension. + /// + /// Custom exception message. public InvalidSignatureException(string message) : base(message) { } } -} \ No newline at end of file +} diff --git a/src/ByteGuard.FileValidator/Exceptions/MalwareDetectedException.cs b/src/ByteGuard.FileValidator/Exceptions/MalwareDetectedException.cs new file mode 100644 index 0000000..315faac --- /dev/null +++ b/src/ByteGuard.FileValidator/Exceptions/MalwareDetectedException.cs @@ -0,0 +1,33 @@ +namespace ByteGuard.FileValidator.Exceptions +{ + /// + /// Exception type used specifically when a given file is detected as being malware from antimalware scanners. + /// + public class MalwareDetectedException : Exception + { + /// + /// Construct a new to indicate that the antimalware scanner detected malware in the file. + /// + public MalwareDetectedException() + : this("The antimalware scanner reported malware detection in the given file.") + { + } + + /// + /// Construct a new to indicate that the antimalware scanner detected malware in the file. + /// + /// Custom exception message. + public MalwareDetectedException(string message) : base(message) + { + } + + /// + /// Construct a new to indicate that the antimalware scanner detected malware in the file. + /// + /// Custom exception message. + /// Inner exception. + public MalwareDetectedException(string message, Exception innerException) : base(message, innerException) + { + } + } +} diff --git a/src/ByteGuard.FileValidator/Exceptions/UnsupportedFileException.cs b/src/ByteGuard.FileValidator/Exceptions/UnsupportedFileException.cs index 5ab5e47..d5a206a 100644 --- a/src/ByteGuard.FileValidator/Exceptions/UnsupportedFileException.cs +++ b/src/ByteGuard.FileValidator/Exceptions/UnsupportedFileException.cs @@ -1,20 +1,24 @@ -using System; - -namespace ByteGuard.FileValidator.Exceptions +namespace ByteGuard.FileValidator.Exceptions { /// /// Exception type used specifically when a given file is unsupported. /// public class UnsupportedFileException : Exception { - + /// + /// Construct a new to indicate that the file is not supported as per the configured supported file extensions. + /// public UnsupportedFileException() : base("File type is not supported.") { } + /// + /// Construct a new to indicate that the file is not supported as per the configured supported file extensions. + /// + /// Custom exception message. public UnsupportedFileException(string message) : base(message) { } } -} \ No newline at end of file +} diff --git a/src/ByteGuard.FileValidator/FileExtensions.cs b/src/ByteGuard.FileValidator/FileExtensions.cs index 7822705..c3f619b 100644 --- a/src/ByteGuard.FileValidator/FileExtensions.cs +++ b/src/ByteGuard.FileValidator/FileExtensions.cs @@ -5,24 +5,99 @@ /// public static class FileExtensions { + /// + /// Joint Photographic Experts Group. + /// public const string Jpeg = ".jpeg"; + + /// + /// Joint Photographic Experts Group. + /// public const string Jpg = ".jpg"; + + /// + /// Joint Photographic Experts Group. + /// public const string Jpe = ".jpe"; + + /// + /// Portable Document Format. + /// public const string Pdf = ".pdf"; + + /// + /// Portable Network Graphics. + /// public const string Png = ".png"; + + /// + /// Bitmap. + /// public const string Bmp = ".bmp"; + + /// + /// Microsoft Word Document. + /// public const string Doc = ".doc"; + + /// + /// Microsoft Word Open XML Document. + /// public const string Docx = ".docx"; + + /// + /// OpenDocument Text. + /// public const string Odt = ".odt"; + + /// + /// Rich Text Format. + /// public const string Rtf = ".rtf"; + + /// + /// Microsoft Excel Spreadsheet. + /// public const string Xls = ".xls"; + + /// + /// Microsoft Excel Open XML Spreadsheet. + /// public const string Xlsx = ".xlsx"; + + /// + /// Microsoft PowerPoint Open XML Presentation. + /// public const string Pptx = ".pptx"; + + /// + /// MPEG-4 Audio. + /// public const string M4a = ".m4a"; + + /// + /// Apple QuickTime Movie. + /// public const string Mov = ".mov"; + + /// + /// Audio Video Interleave. + /// public const string Avi = ".avi"; + + /// + /// MP3 Audio. + /// public const string Mp3 = ".mp3"; + + /// + /// MPEG-4 Video. + /// public const string Mp4 = ".mp4"; + + /// + /// Waveform Audio File Format. + /// public const string Wav = ".wav"; } -} \ No newline at end of file +} diff --git a/src/ByteGuard.FileValidator/FileValidator.cs b/src/ByteGuard.FileValidator/FileValidator.cs index c01c3c1..2fe58e2 100644 --- a/src/ByteGuard.FileValidator/FileValidator.cs +++ b/src/ByteGuard.FileValidator/FileValidator.cs @@ -1,8 +1,4 @@ -using System; -using System.Collections.Generic; -using System.IO; -using System.Linq; -using DocumentFormat.OpenXml.Packaging; +using DocumentFormat.OpenXml.Packaging; using ByteGuard.FileValidator.Configuration; using ByteGuard.FileValidator.Exceptions; using ByteGuard.FileValidator.Models; @@ -241,17 +237,17 @@ public class FileValidator /// /// File validator configuration instance. /// - private readonly FileValidatorConfiguration configuration; + private readonly FileValidatorConfiguration _configuration; /// - /// Instantiate a new instance of the file validator with the given configuration. + /// Instantiate a new instance of the file validator. /// /// Configuration including which files should be supported and whether an exception should be thrown when encountering an invalid file. public FileValidator(FileValidatorConfiguration configuration) { ConfigurationValidator.ThrowIfInvalid(configuration); - this.configuration = configuration; + _configuration = configuration; } /// @@ -260,7 +256,7 @@ public FileValidator(FileValidatorConfiguration configuration) /// All supported file types as per the configuration. public List GetSupportedFileTypes() { - return configuration.SupportedFileTypes; + return _configuration.SupportedFileTypes; } /// @@ -277,7 +273,7 @@ public bool IsValidFile(string fileName, byte[] content) // Validate file type. if (!IsValidFileType(fileName)) { - if (configuration.ThrowExceptionOnInvalidFile) + if (_configuration.ThrowExceptionOnInvalidFile) { throw new UnsupportedFileException(); } @@ -288,7 +284,7 @@ public bool IsValidFile(string fileName, byte[] content) // Validate file size. if (!HasValidSize(content)) { - if (configuration.ThrowExceptionOnInvalidFile) + if (_configuration.ThrowExceptionOnInvalidFile) { throw new UnsupportedFileException(); } @@ -299,7 +295,7 @@ public bool IsValidFile(string fileName, byte[] content) // Validate file signature. if (!HasValidSignature(fileName, content)) { - if (configuration.ThrowExceptionOnInvalidFile) + if (_configuration.ThrowExceptionOnInvalidFile) { throw new InvalidSignatureException(); } @@ -310,7 +306,7 @@ public bool IsValidFile(string fileName, byte[] content) // Validate Open XML conformance for specific file types. if (IsOpenXmlFormat(fileName) && !IsValidOpenXmlDocument(fileName, content)) { - if (configuration.ThrowExceptionOnInvalidFile) + if (_configuration.ThrowExceptionOnInvalidFile) { throw new InvalidOpenXmlFormatException(); } @@ -321,7 +317,7 @@ public bool IsValidFile(string fileName, byte[] content) // Validate Open Document Format (ODF) for specific file types. if (IsOpenDocumentFormat(fileName) && !IsValidOpenDocumentFormat(fileName, content)) { - if (configuration.ThrowExceptionOnInvalidFile) + if (_configuration.ThrowExceptionOnInvalidFile) { throw new InvalidOpenDocumentFormatException(); } @@ -329,6 +325,21 @@ public bool IsValidFile(string fileName, byte[] content) return false; } + // Validate antimalware scan if configured. + if (_configuration.AntimalwareScanner != null) + { + var isClean = IsMalwareClean(fileName, content); + if (!isClean) + { + if (_configuration.ThrowExceptionOnInvalidFile) + { + throw new MalwareDetectedException(); + } + + return false; + } + } + return true; } @@ -398,10 +409,10 @@ public bool IsValidFile(string filePath) public bool IsValidFileType(string fileName) { var extension = Path.GetExtension(fileName).ToLowerInvariant(); - var isSupported = configuration.SupportedFileTypes.Contains(extension, StringComparer.InvariantCultureIgnoreCase) && + var isSupported = _configuration.SupportedFileTypes.Contains(extension, StringComparer.InvariantCultureIgnoreCase) && SupportedFileDefinitions.Any(fd => fd.FileType.Equals(extension, StringComparison.InvariantCultureIgnoreCase)); - if (!isSupported && configuration.ThrowExceptionOnInvalidFile) + if (!isSupported && _configuration.ThrowExceptionOnInvalidFile) { throw new UnsupportedFileException(); } @@ -449,7 +460,7 @@ public bool HasValidSignature(string fileName, byte[] content) fd.FileType.Equals(extension, StringComparison.InvariantCultureIgnoreCase)); if (fileDefinition == null) { - if (configuration.ThrowExceptionOnInvalidFile) + if (_configuration.ThrowExceptionOnInvalidFile) { throw new UnsupportedFileException(); } @@ -465,7 +476,7 @@ public bool HasValidSignature(string fileName, byte[] content) using (var pdfValidator = new PdfValidator(content)) { var isValidPdf = pdfValidator.IsValidPdfSignature(); - if (!isValidPdf && configuration.ThrowExceptionOnInvalidFile) + if (!isValidPdf && _configuration.ThrowExceptionOnInvalidFile) { throw new InvalidSignatureException(); } @@ -481,7 +492,7 @@ public bool HasValidSignature(string fileName, byte[] content) // Check whether the content is valid according to the primary header signature length. if (content.Length < signatureEnd) { - if (configuration.ThrowExceptionOnInvalidFile) + if (_configuration.ThrowExceptionOnInvalidFile) { throw new InvalidSignatureException("File content is too short to contain a valid signature."); } @@ -497,7 +508,7 @@ public bool HasValidSignature(string fileName, byte[] content) // Might as well return early as the subtype check is irrelevant if the primary signature is invalid. if (!result) { - if (configuration.ThrowExceptionOnInvalidFile) + if (_configuration.ThrowExceptionOnInvalidFile) { throw new InvalidSignatureException(); } @@ -515,7 +526,7 @@ public bool HasValidSignature(string fileName, byte[] content) // Check whether the content is valid according to the primary header signature length. if (content.Length < subtypeSignatureEnd) { - if (configuration.ThrowExceptionOnInvalidFile) + if (_configuration.ThrowExceptionOnInvalidFile) { throw new InvalidSignatureException("File content is too short to contain a valid subtype signature."); } @@ -529,7 +540,7 @@ public bool HasValidSignature(string fileName, byte[] content) result = fileDefinition.ValidSubtypeSignatures.Any(signature => subtypeHeaderBytes.Take(signature.Length).SequenceEqual(signature)); } - if (!result && configuration.ThrowExceptionOnInvalidFile) + if (!result && _configuration.ThrowExceptionOnInvalidFile) { throw new InvalidSignatureException(); } @@ -605,9 +616,9 @@ public bool HasValidSignature(string filePath) /// >Thrown if the file size is greater than the configured file size limit and is enabled public bool HasValidSize(byte[] content) { - var isBelowLimit = content.Length <= configuration.FileSizeLimit; + var isBelowLimit = content.Length <= _configuration.FileSizeLimit; - if (configuration.ThrowExceptionOnInvalidFile && !isBelowLimit) + if (_configuration.ThrowExceptionOnInvalidFile && !isBelowLimit) { throw new InvalidFileSizeException(); } @@ -628,9 +639,9 @@ public bool HasValidSize(byte[] content) /// >Thrown if the file size is greater than the configured file size limit and is enabled public bool HasValidSize(Stream stream) { - var isBelowLimit = stream.Length <= configuration.FileSizeLimit; + var isBelowLimit = stream.Length <= _configuration.FileSizeLimit; - if (configuration.ThrowExceptionOnInvalidFile && !isBelowLimit) + if (_configuration.ThrowExceptionOnInvalidFile && !isBelowLimit) { throw new InvalidFileSizeException(); } @@ -701,7 +712,7 @@ public bool IsValidOpenXmlDocument(string fileName, byte[] content) // If we are not expecting this file type to be an Open XML file, we can just return false. if (!IsOpenXmlFormat(fileName)) { - if (configuration.ThrowExceptionOnInvalidFile) + if (_configuration.ThrowExceptionOnInvalidFile) { throw new InvalidOpenXmlFormatException("The provided file extension is not recognized as an Open XML document."); } @@ -735,7 +746,7 @@ public bool IsValidOpenXmlDocument(string fileName, byte[] content) } } - if (configuration.ThrowExceptionOnInvalidFile && !isValid) + if (_configuration.ThrowExceptionOnInvalidFile && !isValid) { throw new InvalidOpenXmlFormatException(); } @@ -744,7 +755,7 @@ public bool IsValidOpenXmlDocument(string fileName, byte[] content) } catch (InvalidDataException e) { - if (configuration.ThrowExceptionOnInvalidFile) + if (_configuration.ThrowExceptionOnInvalidFile) { throw new InvalidOpenXmlFormatException("The provided file is not a valid Open XML file. See inner exception for details.", e); } @@ -754,7 +765,7 @@ public bool IsValidOpenXmlDocument(string fileName, byte[] content) catch (FileFormatException e) { // Thrown if the content is corrupt. - if (configuration.ThrowExceptionOnInvalidFile) + if (_configuration.ThrowExceptionOnInvalidFile) { throw new InvalidOpenXmlFormatException("File content appears to be corrupt. Se inner exception for details.", e); } @@ -764,7 +775,7 @@ public bool IsValidOpenXmlDocument(string fileName, byte[] content) catch (OpenXmlPackageException e) { // Thrown if the content is not valid Open XML. - if (configuration.ThrowExceptionOnInvalidFile) + if (_configuration.ThrowExceptionOnInvalidFile) { throw new InvalidOpenXmlFormatException("Content does not appear to be valid Open XML format. See inner exception for details.", e); } @@ -774,7 +785,7 @@ public bool IsValidOpenXmlDocument(string fileName, byte[] content) catch (InvalidOpenXmlFormatException) { // Exceptions throw from within the Open XML format validator. - if (configuration.ThrowExceptionOnInvalidFile) + if (_configuration.ThrowExceptionOnInvalidFile) { throw; } @@ -872,7 +883,7 @@ public bool IsValidOpenDocumentFormat(string fileName, byte[] content) // If we are not expecting this file type to be an Open XML file, we can just return false. if (!IsOpenDocumentFormat(fileName)) { - if (configuration.ThrowExceptionOnInvalidFile) + if (_configuration.ThrowExceptionOnInvalidFile) { throw new InvalidOpenDocumentFormatException("The provided file extension is not recognized as an Open Document Format document."); } @@ -896,7 +907,7 @@ public bool IsValidOpenDocumentFormat(string fileName, byte[] content) } } - if (configuration.ThrowExceptionOnInvalidFile && !isValid) + if (_configuration.ThrowExceptionOnInvalidFile && !isValid) { throw new InvalidOpenDocumentFormatException(); } @@ -905,7 +916,7 @@ public bool IsValidOpenDocumentFormat(string fileName, byte[] content) } catch (InvalidDataException e) { - if (configuration.ThrowExceptionOnInvalidFile) + if (_configuration.ThrowExceptionOnInvalidFile) { throw new InvalidOpenDocumentFormatException("The provided file is not a valid Open Document Format file. See inner exception for details.", e); } @@ -966,6 +977,98 @@ public bool IsValidOpenDocumentFormat(string filePath) } } + /// + /// Whether the given file is clean according to the configured antimalware scanner. + /// + /// File name including extension (e.g. my-file.odt). + /// Byte content of the file. + /// true if the no malware was detected in the file from the configured antimalware scanner, false otherwise. + /// Thrown if no antimalware scanner has been configured for the FileValidator. + /// Thrown if malware was detected in the file and is enabled. + /// Thrown if the configured antimalware scanner encountered an error while scanning the file for malware. + public bool IsMalwareClean(string fileName, byte[] content) + { + if (_configuration.AntimalwareScanner is null) + { + throw new InvalidOperationException("No antimalware scanner has been configured for the FileValidator."); + } + + using (var memoryStream = new MemoryStream(content)) + { + return IsMalwareClean(fileName, memoryStream); + } + } + + /// + /// Whether the given file is clean according to the configured antimalware scanner. + /// + /// File name including extension (e.g. my-file.odt). + /// Stream content of the file. + /// true if the no malware was detected in the file from the configured antimalware scanner, false otherwise. + /// Thrown if no antimalware scanner has been configured for the FileValidator. + /// Thrown if malware was detected in the file and is enabled. + /// Thrown if the configured antimalware scanner encountered an error while scanning the file for malware. + public bool IsMalwareClean(string fileName, Stream stream) + { + if (_configuration.AntimalwareScanner is null) + { + throw new InvalidOperationException("No antimalware scanner has been configured for the FileValidator."); + } + + stream.Seek(0, SeekOrigin.Begin); + + bool isClean; + try + { + isClean = _configuration.AntimalwareScanner.IsClean(stream, fileName); + } + catch (Exception ex) + { + throw new AntimalwareScannerException(ex); + } + + if (!isClean) + { + if (_configuration.ThrowExceptionOnInvalidFile) + { + throw new MalwareDetectedException(); + } + + return false; + } + + return true; + } + + /// + /// Whether the given file is clean according to the configured antimalware scanner. + /// + /// Full path to the file including filename and extension (e.g. C:\temp\my-file.odt). + /// true if the no malware was detected in the file from the configured antimalware scanner, false otherwise. + /// Thrown if no antimalware scanner has been configured for the FileValidator. + /// Thrown if the is null or whitespace. + /// Thrown if malware was detected in the file and is enabled. + /// Thrown if the configured antimalware scanner encountered an error while scanning the file for malware. + public bool IsMalwareClean(string filePath) + { + if (_configuration.AntimalwareScanner is null) + { + throw new InvalidOperationException("No antimalware scanner has been configured for the FileValidator."); + } + + if (string.IsNullOrWhiteSpace(filePath)) + { + throw new ArgumentNullException(nameof(filePath), "File path cannot be null or empty."); + } + + using (var fileStream = File.OpenRead(filePath)) + { + var fileName = Path.GetFileName(filePath); + + return IsValidOpenDocumentFormat(fileName, fileStream); + } + } + /// /// Whether the given file type is expected to be an Open XML file. /// diff --git a/src/ByteGuard.FileValidator/Models/FileDefinition.cs b/src/ByteGuard.FileValidator/Models/FileDefinition.cs index 5392924..2700c2f 100644 --- a/src/ByteGuard.FileValidator/Models/FileDefinition.cs +++ b/src/ByteGuard.FileValidator/Models/FileDefinition.cs @@ -1,6 +1,4 @@ -using System.Collections.Generic; - -namespace ByteGuard.FileValidator.Models +namespace ByteGuard.FileValidator.Models { /// /// Definition of filetype, their valid signatures, and potentially their valid subtype signatures. @@ -10,7 +8,7 @@ internal class FileDefinition /// /// File type in question (e.g. .jpw, .png, .pdf, etc). /// - public string FileType { get; set; } + public string FileType { get; set; } = default!; /// /// Valid header signatures. @@ -46,4 +44,4 @@ internal class FileDefinition /// public List ValidSubtypeSignatures { get; set; } = new List(); } -} \ No newline at end of file +} diff --git a/src/ByteGuard.FileValidator/Scanners/IAntimalwareScanner.cs b/src/ByteGuard.FileValidator/Scanners/IAntimalwareScanner.cs new file mode 100644 index 0000000..572bd72 --- /dev/null +++ b/src/ByteGuard.FileValidator/Scanners/IAntimalwareScanner.cs @@ -0,0 +1,19 @@ +namespace ByteGuard.FileValidator.Scanners; + +/// +/// An abstraction for antimalware scanners. +/// +public interface IAntimalwareScanner +{ + /// + /// Whether the given file is clean from malicious content based on an antimalware scanner. + /// + /// + /// Implementers should let exception propagate in case the antimalware scanner is not reachable or any other error occurs. + /// The will catch those exceptions and handle them appropriately. + /// + /// Stream content of the file. + /// ile name including extension (e.g. my-file.jpg). + /// true if the antimalware scanner did not find anything, false otherwise. + public bool IsClean(Stream contentStream, string fileName); +} diff --git a/src/ByteGuard.FileValidator/Validators/OpenDocumentFormatValidator.cs b/src/ByteGuard.FileValidator/Validators/OpenDocumentFormatValidator.cs index 6cc8131..9bee276 100644 --- a/src/ByteGuard.FileValidator/Validators/OpenDocumentFormatValidator.cs +++ b/src/ByteGuard.FileValidator/Validators/OpenDocumentFormatValidator.cs @@ -1,5 +1,3 @@ -using System; -using System.IO; using System.IO.Compression; namespace ByteGuard.FileValidator.Validators diff --git a/src/ByteGuard.FileValidator/Validators/OpenXmlFormatValidator.cs b/src/ByteGuard.FileValidator/Validators/OpenXmlFormatValidator.cs index d723a92..594e46e 100644 --- a/src/ByteGuard.FileValidator/Validators/OpenXmlFormatValidator.cs +++ b/src/ByteGuard.FileValidator/Validators/OpenXmlFormatValidator.cs @@ -1,9 +1,6 @@ using DocumentFormat.OpenXml; using DocumentFormat.OpenXml.Packaging; using DocumentFormat.OpenXml.Validation; -using System; -using System.IO; -using System.Linq; using ByteGuard.FileValidator.Exceptions; namespace ByteGuard.FileValidator.Validators diff --git a/src/ByteGuard.FileValidator/Validators/PdfValidator.cs b/src/ByteGuard.FileValidator/Validators/PdfValidator.cs index 8a589f4..e154104 100644 --- a/src/ByteGuard.FileValidator/Validators/PdfValidator.cs +++ b/src/ByteGuard.FileValidator/Validators/PdfValidator.cs @@ -1,6 +1,4 @@ -using System; -using System.IO; -using System.Text; +using System.Text; using ByteGuard.FileValidator.Exceptions; namespace ByteGuard.FileValidator.Validators diff --git a/tests/ByteGuard.FileValidator.Tests.Unit/ByteGuard.FileValidator.Tests.Unit.csproj b/tests/ByteGuard.FileValidator.Tests.Unit/ByteGuard.FileValidator.Tests.Unit.csproj index b1ab82c..340f3d1 100644 --- a/tests/ByteGuard.FileValidator.Tests.Unit/ByteGuard.FileValidator.Tests.Unit.csproj +++ b/tests/ByteGuard.FileValidator.Tests.Unit/ByteGuard.FileValidator.Tests.Unit.csproj @@ -4,6 +4,7 @@ net8.0 enable enable + false false true diff --git a/tests/ByteGuard.FileValidator.Tests.Unit/ConfigurationValidatorTests.cs b/tests/ByteGuard.FileValidator.Tests.Unit/ConfigurationValidatorTests.cs index d738579..62ee7e8 100644 --- a/tests/ByteGuard.FileValidator.Tests.Unit/ConfigurationValidatorTests.cs +++ b/tests/ByteGuard.FileValidator.Tests.Unit/ConfigurationValidatorTests.cs @@ -9,7 +9,7 @@ public class ConfigurationValidatorTests public void ThrowIfInvalid_ConfigurationIsNull_ShouldThrowArgumentNullException() { // Act - Action act = () => new FileValidator(null); + Action act = () => new FileValidator(null!); // Act & Assert Assert.Throws(act); @@ -21,7 +21,7 @@ public void ThrowIfInvalid_SupportedFileTypesIsNull_ShouldThrowArgumentException // Arrange var config = new FileValidatorConfiguration { - SupportedFileTypes = null + SupportedFileTypes = null! }; // Act @@ -95,4 +95,4 @@ public void ThrowIfInvalid_FileSizeLimitIsLessThanOrEqualToZero_ShouldThrowArgum Assert.Throws(act); } -} \ No newline at end of file +} diff --git a/tests/ByteGuard.FileValidator.Tests.Unit/FileValidatorTests.cs b/tests/ByteGuard.FileValidator.Tests.Unit/FileValidatorTests.cs index a0ca844..97644cc 100644 --- a/tests/ByteGuard.FileValidator.Tests.Unit/FileValidatorTests.cs +++ b/tests/ByteGuard.FileValidator.Tests.Unit/FileValidatorTests.cs @@ -1,6 +1,8 @@ using System.Reflection; using ByteGuard.FileValidator.Configuration; using ByteGuard.FileValidator.Exceptions; +using ByteGuard.FileValidator.Scanners; +using NSubstitute; namespace ByteGuard.FileValidator.Tests.Unit; @@ -10,7 +12,7 @@ public class FileValidatorTests public void Constructor_ShouldThrowArgumentNullException_WhenConfigurationIsNotProvided() { // Act - Action act = () => new FileValidator(null); + Action act = () => new FileValidator(null!); // Assert Assert.Throws(act); @@ -1059,4 +1061,52 @@ public void IsValidFile_InvalidFileSize_ShouldThrowInvalidFileSizeException(byte // Assert Assert.Throws(act); } + + [Fact(DisplayName = "IsValidFile(string, byte[]) should throw MalwareDetectedException when an antimalware scanner is registered and malware is detected and ThrowExceptionOnInvalidFile is true")] + public void IsValidFile_AntimalwareScannerDetectsMalwareAndThrowExceptionOnInvalidFileIsTrue_ShouldThrowMalwareDetectedException() + { + // Arrange + var mockAntimalwareScanner = Substitute.For(); + mockAntimalwareScanner.IsClean(Arg.Any(), Arg.Any()).Returns(false); // Simulate malware detection + + var config = new FileValidatorConfiguration + { + SupportedFileTypes = [".pdf"], + FileSizeLimit = ByteSize.MegaBytes(25), + ThrowExceptionOnInvalidFile = true, + AntimalwareScanner = mockAntimalwareScanner + }; + var fileValidator = new FileValidator(config); + var fileBytes = new byte[] { 0x25, 0x50, 0x44, 0x46, 0x2D }; // Valid PDF signature + + // Act + Action act = () => fileValidator.IsValidFile("test.pdf", fileBytes); + + // Assert + Assert.Throws(act); + } + + [Fact(DisplayName = "IsValidFile(string, byte[]) should return false when an antimalware scanner is registered and malware is detected and ThrowExceptionOnInvalidFile is false")] + public void IsValidFile_AntimalwareScannerDetectsMalwareAndThrowExceptionOnInvalidFileIsFalse_ShouldReturnFalse() + { + // Arrange + var mockAntimalwareScanner = Substitute.For(); + mockAntimalwareScanner.IsClean(Arg.Any(), Arg.Any()).Returns(false); // Simulate malware detection + + var config = new FileValidatorConfiguration + { + SupportedFileTypes = [".pdf"], + FileSizeLimit = ByteSize.MegaBytes(25), + ThrowExceptionOnInvalidFile = false, + AntimalwareScanner = mockAntimalwareScanner + }; + var fileValidator = new FileValidator(config); + var fileBytes = new byte[] { 0x25, 0x50, 0x44, 0x46, 0x2D }; // Valid PDF signature + + // Act + var actual = fileValidator.IsValidFile("test.pdf", fileBytes); + + // Assert + Assert.False(actual); + } }