diff --git a/.github/workflows/ci-cd.yml b/.github/workflows/ci-cd.yml index 658c963..dd15467 100644 --- a/.github/workflows/ci-cd.yml +++ b/.github/workflows/ci-cd.yml @@ -23,9 +23,10 @@ on: default: 'core' type: choice options: - - core # PowerCSharpVersion — Core, Extensions, Helpers, Utilities, Compatibility, AspNetCore - - features # PowerCSharpFeaturesVersion — Features.Abstractions, Features, BuiltInFeatures - - cache # PowerCSharpFeatureCacheVersion — Feature.Cache.*, Feature.Cache.BitFaster, Feature.Cache.Disk + - core # PowerCSharpVersion — Core, Extensions, Helpers, Utilities, Compatibility, AspNetCore + - features # PowerCSharpFeaturesVersion — Features.Abstractions, Features, BuiltInFeatures + - cache # PowerCSharpFeatureCacheVersion — Feature.Cache.*, Feature.Cache.BitFaster, Feature.Cache.Disk + - sanitization # PowerCSharpFeatureSanitizationVersion — Feature.Sanitization.*, Feature.Sanitization permissions: contents: write @@ -260,6 +261,10 @@ jobs: VERSION_ELEMENT="PowerCSharpFeatureCacheVersion" TAG_PREFIX="cache-v" ;; + "sanitization") + VERSION_ELEMENT="PowerCSharpFeatureSanitizationVersion" + TAG_PREFIX="sanitization-v" + ;; *) VERSION_ELEMENT="PowerCSharpVersion" TAG_PREFIX="v" diff --git a/.gitignore b/.gitignore index e578ce9..0615368 100644 --- a/.gitignore +++ b/.gitignore @@ -59,6 +59,8 @@ bld/ # Visual Studio 2015/2017 cache/options directory .vs/ +# VS Code workspace settings (machine/user specific) +.vscode/settings.json # Uncomment if you have tasks that create the project's static files in wwwroot #wwwroot/ @@ -413,7 +415,6 @@ FodyWeavers.xsd # VS Code files for those working on multiple tools .vscode/* -!.vscode/settings.json !.vscode/tasks.json !.vscode/launch.json !.vscode/extensions.json diff --git a/CHANGELOG.md b/CHANGELOG.md index fc4ef5a..b14f517 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -211,6 +211,20 @@ This is the first stable production release of PowerCSharp, marking the transiti ## [Unreleased] +### Added + +- **PowerCSharp.Feature.Sanitization.Abstractions v1.0.0** — Sanitization engine, contracts, and NoOp safe-off implementation. Migrated and de-branded from an internal reference implementation, then refactored from one large class into concern-based files: `SanitizationEngine.LogInjection.cs` (CWE-117), `SanitizationEngine.FilePath.cs` (CWE-22), `SanitizationEngine.SensitiveData.cs` (CWE-200), `SanitizationEngine.RegexInjection.cs` (CWE-400/CWE-730). Includes `SanitizationExtensions` string extensions (usable with no DI or configuration), `SanitizationSettings`, `SanitizationResult`/`SensitiveDataResult`, and `NoOpSanitizationService`. Targets `netstandard2.0` + `net8.0`. + +- **PowerCSharp.Feature.Sanitization v1.0.0** — Sanitization feature module (`SanitizationFeatureModule`), options (`SanitizationFeatureOptions`), `SanitizationService` / `SanitizationSettingsProvider`, and `AddSanitizationFeature()` explicit extension. No separate provider package (unlike Cache) — registers the real service directly when the feature flag is enabled, `NoOpSanitizationService` otherwise. `ConfigureSanitizationEngine()` bridges DI-resolved settings into the static engine for non-DI call sites. + +- **Comprehensive Sanitization test suite** — `PowerCSharp.Feature.Sanitization.Tests` covering log injection, file-path traversal (strict allowlist and legacy modes), sensitive-data masking, regex/ReDoS validation, extension methods, result-type equality, and feature DI wiring. + +- **`PowerCSharpFeatureSanitizationVersion`** — New independent version family in `Directory.Build.props`, alongside a `sanitization` `package_family` choice in the release `workflow_dispatch`. + +### Fixed + +- **Sensitive-data key masking** — `MaskKeyBasedValues`'s handling of the generic `password`/`token`/`secret`/etc. key-value pattern referenced a capture group that didn't exist on that regex, which always evaluated to an empty mask and silently left the raw secret value in the sanitized output (with the key name dropped). Found while writing tests for the Sanitization feature; the same defect exists in the original reference implementation this was migrated from. Fixed by reconstructing the key prefix the same way the other key-based heuristics in the same method already do. + --- ## Version History diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..22b1e98 --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,201 @@ +# CLAUDE.md + +> Guidance for Claude — and any AI coding agent — operating in the **PowerCSharp** repository. +> Read this file in full before touching any code, documentation, or CI/CD configuration. +> Nested `CLAUDE.md` files in specific project folders take precedence over this file for +> anything specific to that project; this file is the baseline that always applies. + +--- + +## 1. Role + +You are acting as a **Senior .NET Architect and AI Workflow Engineer** embedded in the PowerCSharp +codebase — a modular, independently-versioned suite of C# libraries (`Core`, `Extensions`, +`Utilities`, `Helpers`, `Compatibility`, `Extensions.AspNetCore`) plus a pluggable **Features +Framework** (`Features` engine, `BuiltInFeatures`, and the `Feature.*` family: `Cache` shipped +today, `Sitecore` and others on the roadmap). + +Your job is not to produce code that merely compiles. It is to produce code and documentation +that: + +- Fits the existing architectural seams instead of inventing new ones. +- Respects package boundaries and dependency-isolation rules (Section 3). +- Is reviewable by a human maintainer in one pass — clear diffs, clear rationale, no surprises. +- Ships with tests, XML doc comments, and README/CHANGELOG updates where convention demands it. + +## 2. Operating Principles (non-negotiable) + +1. **Understand before you edit.** Read the relevant `src/**`, `tests/**`, and `docs/**` files for + the area you're touching before writing a line of code. Do not assume a class, interface, or + convention exists — verify it by reading the source. +2. **Ask, don't guess, on architectural forks.** If a change could reasonably go two ways (new + package vs. extend an existing one; new version family vs. reuse one; new abstraction vs. reuse + an existing contract), stop and ask the maintainer rather than picking one silently. +3. **Never commit or push without explicit human approval.** Prepare changes on a branch and stop. + Only stage/commit/push when the user has explicitly said to do so in the current turn. +4. **Never work directly on `main` or `develop`.** Always branch from `develop` using + `feature/` (or `release/` for release prep), per `docs/WORKFLOW.md`. If you lack + the git permissions to create or push a branch, say so explicitly and ask for them rather than + working uncommitted on a protected branch. +5. **Preserve dependency isolation.** A package must never leak a third-party dependency into + consumers who didn't ask for it (Section 3.2). This is the single most important architectural + invariant in this repository — violating it is a design bug, not a style nit. +6. **Respect version-family boundaries.** Every change belongs to exactly one version family + (Section 4), and that determines where the version bump happens. Do not bump a family you + didn't touch. +7. **Escalate before refactoring sensitive zones.** `src/Features/PowerCSharp.Features` + (assembly discovery + composite flag resolution) and + `src/Features/PowerCSharp.Feature.Cache.Disk` (cross-process file locking) are structurally + sensitive — see their nested `CLAUDE.md` files. Scope changes narrowly; do not "clean up" these + areas opportunistically. +8. **`PowerCSharp.Compatibility` is a live production dependency** for real .NET Framework + consumers, not a legacy afterthought. Treat every change to it as shipping immediately — see + `src/PowerCSharp.Compatibility/CLAUDE.md`. +9. **Public API changes are breaking-change events.** Any signature, namespace, or behavioral + change to a public type in a shipped package (anything with a NuGet badge in `README.md`) + requires an explicit note in the response and, where applicable, a `CHANGELOG.md` entry. + +## 3. Repository Architecture + +### 3.1 Package topology + +```text +PowerCSharp.Core zero-dependency foundation (interfaces, models) + └─ PowerCSharp.Extensions cross-platform extension methods (net8.0 + netstandard2.0) + └─ PowerCSharp.Extensions.AspNetCore ASP.NET Core–specific extensions (net8.0) + └─ PowerCSharp.Utilities validation, file, math utilities (net8.0 + netstandard2.0) + └─ PowerCSharp.Helpers JSON, crypto, environment helpers (net8.0 + netstandard2.0) + +PowerCSharp.Compatibility standalone .NET Framework layer (net462/net472/net48) + NOT part of the Core dependency graph — own CLAUDE.md + +--- Features Framework (independent versioning family) --- +PowerCSharp.Features.Abstractions pure contracts, zero third-party deps + └─ PowerCSharp.Features engine: discovery, flag resolution, DI orchestration + └─ PowerCSharp.BuiltInFeatures Group 1 bundle (CORS today; runtime-flag toggled only) + +--- Cache Feature Family (independent versioning family) --- +PowerCSharp.Feature.Cache.Abstractions cache contracts + NoOp (netstandard2.0 + net8.0) + └─ PowerCSharp.Feature.Cache module + options + AddCacheFeature() wiring + ├─ PowerCSharp.Feature.Cache.BitFaster BitFaster-backed LRU (isolates BitFaster.Caching) + └─ PowerCSharp.Feature.Cache.Disk disk-backed LRU (cross-process locking) — own CLAUDE.md + +--- Roadmapped, confirmed in scope (see src/Features/CLAUDE.md) --- +PowerCSharp.Feature.Sitecore third-party GraphQL/Sitecore integration — not started +``` + +### 3.2 Dependency-isolation rule + +Third-party NuGet packages (`BitFaster.Caching`, a future Sitecore SDK, etc.) are referenced +**only** by the leaf package that needs them — never by an `*.Abstractions` package or the +engine. An application that does not reference `PowerCSharp.Feature.Cache.BitFaster` must never +pull `BitFaster.Caching` in transitively. Apply the identical rule to any new pluggable feature, +including the future `PowerCSharp.Feature.Sitecore`. + +### 3.3 Solutions + +- `PowerCSharp.sln` — the main solution; drives `dotnet build` / `test` / `pack` in CI + (`.github/workflows/ci-cd.yml`). +- `PowerCSharp-Compatibility.sln` — isolated solution for the .NET Framework layer. **Not + currently wired into CI** — see `src/PowerCSharp.Compatibility/CLAUDE.md` for the implication + before touching that package. + +## 4. Versioning Model + +Centrally managed in `Directory.Build.props` as independently-bumped "families": + +| MSBuild property | Covers | Bumped via | +|---|---|---| +| `PowerCSharpVersion` | Core, Extensions, Extensions.AspNetCore, Utilities, Helpers | `workflow_dispatch` → `package_family: core` | +| `PowerCSharpCompatibilityVersion` | Compatibility | manual edit in `Directory.Build.props` | +| `PowerCSharpFeaturesVersion` | Features.Abstractions, Features, BuiltInFeatures | `workflow_dispatch` → `package_family: features` | +| `PowerCSharpFeatureCacheVersion` | Feature.Cache.Abstractions, Feature.Cache, Feature.Cache.BitFaster, Feature.Cache.Disk | `workflow_dispatch` → `package_family: cache` | + +If you are adding to an existing package, bump its existing family. If you are standing up a new +pluggable `Feature.` family (e.g. `Feature.Sitecore`), it earns its own +`PowerCSharpFeatureVersion` property and its own `workflow_dispatch` choice — mirroring the +Cache precedent (`docs/PowerCSharp.Features.PackageLayout.md` §2 already anticipates +`PowerCSharpFeatureSitecoreVersion`). Never fold a new feature family into +`PowerCSharpFeaturesVersion`; that property is reserved for the framework trio itself. + +## 5. Formatting Constraints for Structured Output + +When a response involves anything beyond a short prose answer — a plan, a code review, a proposed +change set, a risk assessment — structure it with the XML tags below so it can be parsed and +reviewed reliably. Use only the tags relevant to the response; do not force all of them into a +trivial answer (a one-line fix does not need a `` block). + +```xml + + What you found reading the code. Cite file paths and line numbers. State assumptions explicitly + rather than silently relying on them. + + + + Ordered, numbered steps. Each step names the file(s) it touches and why. + + + + Anything touching a sensitive zone (Section 2.7), a version-family boundary, a public API + surface, or CI/CD. State the blast radius. If there is no material risk, say so explicitly + rather than omitting the tag. + + + + + One-line description of the change. + + + + + Tests added/updated, and what was manually verified (build output, dotnet test results, etc.). + + + + Anything left for the human reviewer to decide before merge. + +``` + +Code blocks always carry a language tag (` ```csharp `, ` ```xml `, ` ```yaml `). Commit message +suggestions follow Conventional Commits (`feat:`, `fix:`, `docs:`, `refactor:`, `test:`, `chore:`), +per `docs/WORKFLOW.md`. + +## 6. Tone & Communication Guidelines + +- Write like a senior architect reviewing a colleague's PR: direct, specific, evidence-based. Cite + the file and line you're referring to instead of describing code in the abstract. +- Prefer precision over hedging. If something is a hard rule (Section 2), say so plainly. If it's + a judgment call, say that too, and give the reasoning, not just the conclusion. +- No filler, no restating the request back, no preambles. +- Surface risk and disagreement early and explicitly rather than burying it at the end of a long + explanation. If a request conflicts with an architectural invariant in this file, name which one + and why, then propose the compliant alternative — do not silently comply or silently refuse. +- Keep the length of a response proportional to the size of the change. A one-line fix gets a + one-line summary. A new package gets a structured `` / `` writeup. + +## 7. How to Build, Test, and Pack + +```bash +dotnet restore PowerCSharp.sln +dotnet build PowerCSharp.sln --configuration Release +dotnet test PowerCSharp.sln --configuration Release --collect:"XPlat Code Coverage" +dotnet pack PowerCSharp.sln --configuration Release --output ./packages +``` + +The .NET Framework compatibility layer is **not** covered by the commands above — see +`src/PowerCSharp.Compatibility/CLAUDE.md`. + +## 8. Nested Documentation Index + +- `Workflows.md` — step-by-step SOPs for recurring tasks (extending a core library, building a new + pluggable Feature package, cutting a release). +- `src/Features/CLAUDE.md` — Features Framework internals, extension points, Sitecore roadmap. +- `src/Features/PowerCSharp.Feature.Cache.Disk/CLAUDE.md` — disk-cache locking model and hazards. +- `src/PowerCSharp.Compatibility/CLAUDE.md` — .NET Framework layer constraints and the CI gap. +- `docs/WORKFLOW.md` — GitFlow branching and CI/CD pipeline reference (human-facing, complements + this file). +- `docs/PowerCSharp.Features.Architecture.md` and + `docs/PowerCSharp.Features.Authoring-Guide.md` — required reading before building any new + Feature package, pluggable or built-in. +- `docs/EDGE_CASES_AND_SECURITY.md` — per-API edge-case and security notes; consult before + changing the behavior of any existing public extension/utility method. diff --git a/Directory.Build.props b/Directory.Build.props index 8314530..07c836a 100644 --- a/Directory.Build.props +++ b/Directory.Build.props @@ -11,7 +11,10 @@ 1.3.2 - + + + 1.0.0 + Mario Arce Copyright © Mario Arce 2026 diff --git a/PowerCSharp.sln b/PowerCSharp.sln index 561d853..d21a0ae 100644 --- a/PowerCSharp.sln +++ b/PowerCSharp.sln @@ -47,6 +47,18 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "PowerCSharp.Feature.Cache.A EndProject Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "PowerCSharp.Feature.Cache.Disk", "src\Features\PowerCSharp.Feature.Cache.Disk\PowerCSharp.Feature.Cache.Disk.csproj", "{137976EF-A677-499E-9567-A8B7E3B6F1BE}" EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "PowerCSharp.Core.Tests", "tests\PowerCSharp.Core.Tests\PowerCSharp.Core.Tests.csproj", "{F5B563E8-E215-4350-9480-BBFCE47DCF34}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "PowerCSharp.Utilities.Tests", "tests\PowerCSharp.Utilities.Tests\PowerCSharp.Utilities.Tests.csproj", "{79C80B63-F711-4EC4-91F5-52D892E60DC6}" +EndProject +Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "PowerCSharp.Feature.Sanitization", "PowerCSharp.Feature.Sanitization", "{8396894C-0CAD-4C19-ADF6-E9E65735A3BC}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "PowerCSharp.Feature.Sanitization.Abstractions", "src\Features\PowerCSharp.Feature.Sanitization.Abstractions\PowerCSharp.Feature.Sanitization.Abstractions.csproj", "{F17178B0-967E-4922-BA9C-34A771E84F52}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "PowerCSharp.Feature.Sanitization", "src\Features\PowerCSharp.Feature.Sanitization\PowerCSharp.Feature.Sanitization.csproj", "{2C6E4B9A-8B2C-4B1E-9C3F-6A7D8E9F0A1B}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "PowerCSharp.Feature.Sanitization.Tests", "tests\PowerCSharp.Feature.Sanitization.Tests\PowerCSharp.Feature.Sanitization.Tests.csproj", "{2D2654C2-072D-47D7-BDF2-CA3A706E1271}" +EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution Debug|Any CPU = Debug|Any CPU @@ -128,6 +140,26 @@ Global {137976EF-A677-499E-9567-A8B7E3B6F1BE}.Debug|Any CPU.Build.0 = Debug|Any CPU {137976EF-A677-499E-9567-A8B7E3B6F1BE}.Release|Any CPU.ActiveCfg = Release|Any CPU {137976EF-A677-499E-9567-A8B7E3B6F1BE}.Release|Any CPU.Build.0 = Release|Any CPU + {F5B563E8-E215-4350-9480-BBFCE47DCF34}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {F5B563E8-E215-4350-9480-BBFCE47DCF34}.Debug|Any CPU.Build.0 = Debug|Any CPU + {F5B563E8-E215-4350-9480-BBFCE47DCF34}.Release|Any CPU.ActiveCfg = Release|Any CPU + {F5B563E8-E215-4350-9480-BBFCE47DCF34}.Release|Any CPU.Build.0 = Release|Any CPU + {79C80B63-F711-4EC4-91F5-52D892E60DC6}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {79C80B63-F711-4EC4-91F5-52D892E60DC6}.Debug|Any CPU.Build.0 = Debug|Any CPU + {79C80B63-F711-4EC4-91F5-52D892E60DC6}.Release|Any CPU.ActiveCfg = Release|Any CPU + {79C80B63-F711-4EC4-91F5-52D892E60DC6}.Release|Any CPU.Build.0 = Release|Any CPU + {F17178B0-967E-4922-BA9C-34A771E84F52}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {F17178B0-967E-4922-BA9C-34A771E84F52}.Debug|Any CPU.Build.0 = Debug|Any CPU + {F17178B0-967E-4922-BA9C-34A771E84F52}.Release|Any CPU.ActiveCfg = Release|Any CPU + {F17178B0-967E-4922-BA9C-34A771E84F52}.Release|Any CPU.Build.0 = Release|Any CPU + {2C6E4B9A-8B2C-4B1E-9C3F-6A7D8E9F0A1B}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {2C6E4B9A-8B2C-4B1E-9C3F-6A7D8E9F0A1B}.Debug|Any CPU.Build.0 = Debug|Any CPU + {2C6E4B9A-8B2C-4B1E-9C3F-6A7D8E9F0A1B}.Release|Any CPU.ActiveCfg = Release|Any CPU + {2C6E4B9A-8B2C-4B1E-9C3F-6A7D8E9F0A1B}.Release|Any CPU.Build.0 = Release|Any CPU + {2D2654C2-072D-47D7-BDF2-CA3A706E1271}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {2D2654C2-072D-47D7-BDF2-CA3A706E1271}.Debug|Any CPU.Build.0 = Debug|Any CPU + {2D2654C2-072D-47D7-BDF2-CA3A706E1271}.Release|Any CPU.ActiveCfg = Release|Any CPU + {2D2654C2-072D-47D7-BDF2-CA3A706E1271}.Release|Any CPU.Build.0 = Release|Any CPU EndGlobalSection GlobalSection(NestedProjects) = preSolution {73835311-BC0A-4107-9E6D-4950DF565C46} = {493BDE4C-2EBA-49EE-BF44-10AAB73634D6} @@ -149,5 +181,11 @@ Global {01C67F51-0FE0-4576-9507-7CE856370C14} = {8507D629-2649-468E-8345-212CFC547FA8} {E61A4C83-42A8-42A5-B1F1-868ED5DE762F} = {006F80FE-AC82-4752-972B-081BA6C6A651} {137976EF-A677-499E-9567-A8B7E3B6F1BE} = {006F80FE-AC82-4752-972B-081BA6C6A651} + {F5B563E8-E215-4350-9480-BBFCE47DCF34} = {8507D629-2649-468E-8345-212CFC547FA8} + {79C80B63-F711-4EC4-91F5-52D892E60DC6} = {8507D629-2649-468E-8345-212CFC547FA8} + {8396894C-0CAD-4C19-ADF6-E9E65735A3BC} = {006F80FE-AC82-4752-972B-081BA6C6A651} + {F17178B0-967E-4922-BA9C-34A771E84F52} = {8396894C-0CAD-4C19-ADF6-E9E65735A3BC} + {2C6E4B9A-8B2C-4B1E-9C3F-6A7D8E9F0A1B} = {8396894C-0CAD-4C19-ADF6-E9E65735A3BC} + {2D2654C2-072D-47D7-BDF2-CA3A706E1271} = {8507D629-2649-468E-8345-212CFC547FA8} EndGlobalSection EndGlobal diff --git a/README.md b/README.md index 60fdc94..649a21f 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,6 @@ # PowerCSharp -![PowerCSharp Banner](docs/images/PowerCSharp_Banner.png) +![PowerCSharp Banner](https://raw.githubusercontent.com/marioarce/PowerCSharp/0191ee12092c28ccf5a578e59977583117a3ff00/docs/images/PowerCSharp_Banner.png) [![PowerCSharp](https://img.shields.io/badge/PowerCSharp-v2.0.0-blue.svg)](https://github.com/marioarce/PowerCSharp) [![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](https://opensource.org/licenses/MIT) @@ -21,6 +21,7 @@ Enhanced C# extension methods and utilities for .NET developers [![NuGet](https://img.shields.io/nuget/v/PowerCSharp.Features.svg)](https://www.nuget.org/packages/PowerCSharp.Features) [![NuGet](https://img.shields.io/nuget/v/PowerCSharp.BuiltInFeatures.svg)](https://www.nuget.org/packages/PowerCSharp.BuiltInFeatures) [![NuGet](https://img.shields.io/nuget/v/PowerCSharp.Feature.Cache.svg)](https://www.nuget.org/packages/PowerCSharp.Feature.Cache) +[![NuGet](https://img.shields.io/nuget/v/PowerCSharp.Feature.Sanitization.svg)](https://www.nuget.org/packages/PowerCSharp.Feature.Sanitization) PowerCSharp is a comprehensive library of extension methods, utilities, and helper classes designed to enhance your C# development experience. Built by a senior C# architect with 20+ years of experience, this library provides practical, well-tested solutions for common programming challenges. @@ -30,6 +31,7 @@ PowerCSharp is a comprehensive library of extension methods, utilities, and help - **Features Framework**: Brand-new `PowerCSharp.Features` engine — hybrid auto-scan + explicit module discovery, composite flag resolution (config → env vars → overrides), DI orchestration, opt-in diagnostics endpoint - **Built-in Features**: `PowerCSharp.BuiltInFeatures` bundle — runtime-flag-toggled ASP.NET Core capabilities (CORS), toggled via `PowerFeatures::Enabled` - **Cache Feature Family**: `PowerCSharp.Feature.Cache` (module + options), `PowerCSharp.Feature.Cache.Abstractions` (contracts + NoOp, `netstandard2.0` + `net8.0`), `PowerCSharp.Feature.Cache.BitFaster` (BitFaster-backed LRU), `PowerCSharp.Feature.Cache.Disk` (disk-backed LRU with cross-process locking) +- **Sanitization Feature Family**: `PowerCSharp.Feature.Sanitization.Abstractions` (engine + contracts + NoOp, `netstandard2.0` + `net8.0`) and `PowerCSharp.Feature.Sanitization` (module + options) — log injection (CWE-117), file-path traversal (CWE-22), sensitive-data masking (CWE-200), and regex-injection/ReDoS validation (CWE-400/CWE-730) - **EditorConfig**: Comprehensive coding standards applied across the entire codebase - **Directory Extensions**: `TrySafeDelete` and related safe I/O helpers - **Code Quality**: Nullable annotations, member ordering, and namespace cleanup throughout @@ -60,6 +62,11 @@ PowerCSharp is organized into focused, independently versioned packages. - **[PowerCSharp.Feature.Cache.BitFaster](src/Features/PowerCSharp.Feature.Cache.BitFaster/README.md)** - BitFaster-backed in-memory LRU cache. Isolates `BitFaster.Caching` dependency. - **[PowerCSharp.Feature.Cache.Disk](src/Features/PowerCSharp.Feature.Cache.Disk/README.md)** - Disk-backed LRU cache with atomic writes, cross-process file-lock coordination, and background cleanup. +### Sanitization Feature Family (`v1.0.0`) + +- **[PowerCSharp.Feature.Sanitization.Abstractions](src/Features/PowerCSharp.Feature.Sanitization.Abstractions/README.md)** - Sanitization engine, contracts, and NoOp safe-off implementation covering log injection, file-path traversal, sensitive-data masking, and regex-injection/ReDoS. Targets `netstandard2.0` + `net8.0`. +- **[PowerCSharp.Feature.Sanitization](src/Features/PowerCSharp.Feature.Sanitization/README.md)** - Sanitization feature module, options, and `AddSanitizationFeature()` wiring. No separate provider package — registers the real service directly. + ### 🏗️ Architecture PowerCSharp follows a clean architectural pattern with **centralized interfaces** in PowerCSharp.Core: @@ -100,6 +107,13 @@ dotnet add package PowerCSharp.Feature.Cache.BitFaster # in-memory LRU (BitFas dotnet add package PowerCSharp.Feature.Cache.Disk # disk-backed LRU ``` +### Sanitization feature + +```bash +dotnet add package PowerCSharp.Feature.Sanitization.Abstractions # engine — usable standalone, no DI required +dotnet add package PowerCSharp.Feature.Sanitization # module — Features Framework + DI wiring +``` + ## 💡 Usage Examples ### String Extensions (PowerCSharp.Extensions) @@ -223,6 +237,22 @@ if (result.Hit) Console.WriteLine(result.Value); ``` +### Sanitization Feature (PowerCSharp.Feature.Sanitization.Abstractions) + +```csharp +using PowerCSharp.Feature.Sanitization.Abstractions; + +// Usable standalone — no DI or feature registration required. +string safeForLog = untrustedInput.SanitizeForLog(); // CWE-117 log injection +string safeForPath = untrustedSegment.SanitizeForFilePath(); // CWE-22 path traversal (throws if rejected) +string masked = payload.SanitizeForSensitiveData(); // CWE-200 sensitive-data masking +string safePattern = untrustedPattern.SanitizeForRegexInjection(); // CWE-400/CWE-730 ReDoS validation + +// Or resolve the DI-facing service once PowerCSharp.Feature.Sanitization is registered: +var sanitizer = app.Services.GetRequiredService(); +var result = sanitizer.SanitizeForLogInjection(untrustedInput); +``` + ### LINQ & Dynamic Query Extensions (PowerCSharp.Extensions) ```csharp @@ -382,8 +412,8 @@ string random = CryptoHelper.GenerateRandomString(10); ## 🎯 Target Frameworks -- **Modern .NET**: .NET 8.0 — core libraries, Features engine, BuiltInFeatures, Cache feature modules -- **.NET Standard 2.0 + .NET 8.0**: `PowerCSharp.Features.Abstractions`, `PowerCSharp.Feature.Cache.Abstractions`, `PowerCSharp.Feature.Cache.BitFaster` — usable from .NET Framework and .NET Core +- **Modern .NET**: .NET 8.0 — core libraries, Features engine, BuiltInFeatures, Cache and Sanitization feature modules +- **.NET Standard 2.0 + .NET 8.0**: `PowerCSharp.Features.Abstractions`, `PowerCSharp.Feature.Cache.Abstractions`, `PowerCSharp.Feature.Cache.BitFaster`, `PowerCSharp.Feature.Sanitization.Abstractions` — usable from .NET Framework and .NET Core - **.NET Framework**: 4.6.2, 4.7.2, 4.8 — via `PowerCSharp.Compatibility` - **ASP.NET Core**: .NET 8.0 — `PowerCSharp.Extensions.AspNetCore`, Features engine, BuiltInFeatures @@ -418,6 +448,10 @@ dotnet test - **[PowerCSharp.Feature.Cache.BitFaster](src/Features/PowerCSharp.Feature.Cache.BitFaster/README.md)** - BitFaster provider guide - **[PowerCSharp.Feature.Cache.Disk](src/Features/PowerCSharp.Feature.Cache.Disk/README.md)** - Disk provider guide +**Sanitization feature family** +- **[PowerCSharp.Feature.Sanitization.Abstractions](src/Features/PowerCSharp.Feature.Sanitization.Abstractions/README.md)** - Engine and contracts reference +- **[PowerCSharp.Feature.Sanitization](src/Features/PowerCSharp.Feature.Sanitization/README.md)** - Module guide + ### Detailed API Documentation - **[PowerCSharp.Core API](docs/PowerCSharp.Core.md)** - Complete core API reference - **[PowerCSharp.Extensions API](docs/PowerCSharp.Extensions.md)** - Cross-platform extensions documentation @@ -430,6 +464,7 @@ dotnet test - **[Features Authoring Guide](docs/PowerCSharp.Features.Authoring-Guide.md)** - How to build a new feature - **[Features Flag Reference](docs/PowerCSharp.Features.FlagReference.md)** - Flag schema and provider precedence - **[Cache Feature API](docs/PowerCSharp.Feature.Cache.md)** - Cache family API reference +- **[Sanitization Feature API](docs/PowerCSharp.Feature.Sanitization.md)** - Sanitization family API reference ### Development Documentation - [Examples and Samples](samples/) - Working code examples diff --git a/Workflows.md b/Workflows.md new file mode 100644 index 0000000..605dfd2 --- /dev/null +++ b/Workflows.md @@ -0,0 +1,149 @@ +# Workflows.md + +> Standard Operating Procedures for recurring tasks in the PowerCSharp repository. These SOPs are +> the concrete, step-by-step companion to `CLAUDE.md`. Follow them in order; do not skip +> verification steps to save time. + +--- + +## SOP-01 — Adding a New Extension / Utility / Helper Method + +**When to use:** adding a method to an existing `PowerCSharp.Extensions`, `PowerCSharp.Utilities`, +`PowerCSharp.Helpers`, or `PowerCSharp.Extensions.AspNetCore` class (or a new class within one of +those packages). This is the `PowerCSharpVersion` family. + +1. **Locate the right home.** Match the method to an existing namespace/class by convention + (e.g. string helpers → `PowerCSharp.Extensions` `StringExtensions`; validation → + `PowerCSharp.Utilities` `ValidationUtility`). Read the target file fully before adding to it — + check `docs/PowerCSharp..md` for the documented surface and + `docs/EDGE_CASES_AND_SECURITY.md` for existing null/edge-case conventions in that area. +2. **Match existing signatures and null-handling style.** This codebase favors "safe" variants + that don't throw (`SafeSubstring`, `FirstOrDefaultSafe`) — follow the established pattern for + the class you're extending rather than introducing a new error-handling style. +3. **Target frameworks.** Confirm the package's `` in its `.csproj` + (`net8.0;netstandard2.0` for most Core-family packages). If the API you need doesn't exist on + `netstandard2.0`, either polyfill it or guard with `#if NET8_0_OR_GREATER` — do not silently + drop a target framework. +4. **Nullable + EditorConfig.** `Nullable` is `enable` repo-wide (`Directory.Build.props`). Follow + `.editorconfig` member-ordering and style rules; run `dotnet format` if unsure. +5. **Write the method with full XML doc comments** (``, ``, ``, + `` where relevant) — these packages ship with `GenerateDocumentationFile` and their + docs pages are hand-maintained from these comments. +6. **Add tests** in the matching `tests/PowerCSharp..Tests` project (xunit). Cover: happy + path, null/empty input, and at least one boundary case, mirroring the style already used for + sibling methods in the same test file. +7. **Update package docs.** Add a short usage example to `docs/PowerCSharp..md` and, if + the method is a headline addition, to the "Usage Examples" section of the root `README.md`. +8. **Update `CHANGELOG.md`** under an `[Unreleased]` or next-version heading, following the + Keep a Changelog format already in use. +9. **Verify locally:** + ```bash + dotnet build PowerCSharp.sln --configuration Release + dotnet test tests/PowerCSharp..Tests --configuration Release + ``` +10. **Version family:** this change belongs to `PowerCSharpVersion` (or + `PowerCSharpCompatibilityVersion` if the target is `PowerCSharp.Compatibility` — see that + package's own `CLAUDE.md` first). Do not bump the version yourself; that happens in SOP-03 at + release time via `workflow_dispatch`. Note the intended family in your summary to the reviewer. +11. **Branch and hand off.** Work on `feature/` branched from `develop`. Do not + merge or push without explicit approval — present the diff for peer review per the working + agreement in place for this repository. + +--- + +## SOP-02 — Building a New Pluggable Feature Package (`PowerCSharp.Feature.`) + +**When to use:** standing up a new feature under the Features Framework — for example, the +roadmapped `PowerCSharp.Feature.Sitecore`. Read `docs/PowerCSharp.Features.Architecture.md` and +`docs/PowerCSharp.Features.Authoring-Guide.md` in full before starting; this SOP sequences that +guidance into an actionable checklist. Do not begin writing code before both documents have been +read for this session. + +1. **Classify the tier.** Run the decision tree in the Authoring Guide §1. A feature that pulls a + third-party SDK (a Sitecore GraphQL/Content Serialization client, for example) or carries + non-trivial implementation is **always Pluggable (Group 2)** — it never belongs in + `PowerCSharp.BuiltInFeatures`. +2. **Decide single package vs. family.** If there is exactly one implementation, use a single + `PowerCSharp.Feature.` package. If there are or will be swappable backends (as with + Cache: BitFaster vs. Disk), split into `PowerCSharp.Feature.` (contracts + module) plus + one `PowerCSharp.Feature..` package per backend. **Ask the maintainer if this is + ambiguous** — do not guess for a feature with real architectural weight like Sitecore. +3. **Scaffold the project(s)** under `src/Features/PowerCSharp.Feature.[.{Provider}]/`, + matching `docs/PowerCSharp.Features.PackageLayout.md` §1 folder conventions: + - Contracts-only packages target `netstandard2.0;net8.0` and take **zero** third-party + dependencies beyond `Microsoft.Extensions.Logging.Abstractions`. + - Implementation packages target `net8.0` and isolate their third-party SDK dependency + entirely within that package (Section 3.2 of the root `CLAUDE.md`). +4. **Define the anatomy** (Authoring Guide §2), all four/five pieces: + - a stable `FeatureKey` string (e.g. `"Sitecore"`), used in config as `PowerFeatures:`; + - the contracts other code will depend on; + - a `FeatureOptionsBase` subclass bound from `PowerFeatures:`; + - an `IFeatureModule` implementation for auto-discovery, plus (optionally) an explicit + `AddFeature()` extension method for opt-in registration; + - a safe-off (NoOp) behavior for when the flag is disabled — dependents must still resolve. +5. **Wire `ConfigureServices`** using the Cache module (`CacheFeatureModule`, in + `src/Features/PowerCSharp.Feature.Cache/`) as the literal template: check + `context.Flags.IsEnabled(FeatureKey)` first, register the NoOp implementation and return early + if disabled, otherwise register the real implementation. Add `ConfigurePipeline` only if the + feature contributes middleware. +6. **Add the project(s) to `PowerCSharp.sln`** under the existing `Features` solution folder, plus + a matching `tests/PowerCSharp.Feature..Tests` project referencing `xunit` + + `Microsoft.NET.Test.Sdk` at the versions already used by sibling test projects. +7. **Add a new version-family property** to `Directory.Build.props` + (`PowerCSharpFeatureVersion`, e.g. `PowerCSharpFeatureSitecoreVersion`), starting at + `1.0.0`. Set every new package's `` to that property. Add the family to the + `package_family` choice list in `.github/workflows/ci-cd.yml`'s `workflow_dispatch` inputs. +8. **Write tests** covering: flag-off → NoOp is resolved; flag-on → real implementation is + resolved; option binding; and the feature's own core logic. Mirror + `tests/PowerCSharp.Feature.Cache.Tests` structure. +9. **Document it**: a package `README.md` (packed via `PackageReadmeFile`, see the Cache `.csproj` + for the pattern), an entry in the root `README.md` package table, and a `docs/PowerCSharp.Feature..md` + page following the existing per-package doc format. +10. **Verify dependency isolation manually.** Reference only the new package from a scratch console + app and confirm the third-party SDK does *not* appear in `dotnet list package --include-transitive` + output unless that specific package was referenced. +11. **Branch and hand off.** Work on `feature/-feature` from `develop`. Present the plan + (tier decision, package layout, version-family name) as a `` block per `CLAUDE.md` + Section 5 before writing substantial code, since this is an architectural addition — confirm + with the maintainer before scaffolding if any step above required a judgment call. + +--- + +## SOP-03 — Preparing and Publishing a Release + +**When to use:** a `develop`-branch feature set is ready to ship to NuGet.org and GitHub Packages +for one or more version families. Full reference: `docs/WORKFLOW.md`. + +1. **Confirm `develop` is green.** `dotnet build PowerCSharp.sln --configuration Release` and + `dotnet test PowerCSharp.sln --configuration Release` must both pass locally before starting; + CI will re-verify on push but don't rely on CI to catch avoidable failures. +2. **Identify which version family(ies) changed** since the last release: `core` + (`PowerCSharpVersion`), `features` (`PowerCSharpFeaturesVersion`), `cache` + (`PowerCSharpFeatureCacheVersion`), or a newly-introduced family from SOP-02. Cross-check + against `CHANGELOG.md`'s `[Unreleased]` section. +3. **Create the release branch from `develop`:** + ```bash + git checkout develop + git pull origin develop + git checkout -b release/v + ``` +4. **Update documentation and changelog** on the release branch: move `[Unreleased]` entries in + `CHANGELOG.md` under the new version heading with today's date, and confirm the root + `README.md` NuGet badge table doesn't need a new row (new package) or wording updates. +5. **Bump the version(s).** Preferred path is the `workflow_dispatch` trigger on + `.github/workflows/ci-cd.yml` (`release_type`: patch/minor/major, `package_family`: + core/features/cache/\) — this both edits `Directory.Build.props` and creates the + `v` / `features-v` / `cache-v` tag automatically. If multiple families + changed, run it once per family. Manual edits to `Directory.Build.props` are the fallback only + if `workflow_dispatch` is unavailable. +6. **Open the PR:** `release/v` → `main`, including release notes, a summary of testing + performed, and any breaking changes called out explicitly (per `CLAUDE.md` Section 2.9). +7. **On merge to `main`**, the pipeline automatically: builds and tests, packs (`dotnet pack + PowerCSharp.sln --configuration Release --output ./packages`), publishes to NuGet.org and + GitHub Packages, and deletes the `release/*` branch. Confirm the packages appear on + nuget.org/GitHub Packages after the workflow completes — do not assume a green pipeline run + equals a successful publish without checking. +8. **Verify Codecov** picked up the coverage report for the release build (badge in `README.md`). +9. **Do not commit, tag, or push any step above without explicit sign-off** unless the user has + directed you to execute the release directly — treat release actions as high-blast-radius by + default and confirm before executing steps 3, 5, 6, and 7. diff --git a/docs/PowerCSharp.Feature.Sanitization.md b/docs/PowerCSharp.Feature.Sanitization.md new file mode 100644 index 0000000..33eb320 --- /dev/null +++ b/docs/PowerCSharp.Feature.Sanitization.md @@ -0,0 +1,307 @@ +# PowerCSharp.Feature.Sanitization — API Reference + +> The Sanitization pluggable feature family: a framework-agnostic engine plus the Features-Framework module. Each package in the family versions independently under `PowerCSharpFeatureSanitizationVersion`. Migrated and de-branded from an internal reference implementation, then refactored from one large class into concern-based files (log injection, file path, sensitive data, regex injection). + +--- + +## Package Family Overview + +| Package | Role | Target Frameworks | Version | +|---|---|---|---| +| `PowerCSharp.Feature.Sanitization.Abstractions` | Engine + contracts + NoOp floor | `netstandard2.0` + `net8.0` | `$(PowerCSharpFeatureSanitizationVersion)` | +| `PowerCSharp.Feature.Sanitization` | Module + options + ASP.NET Core wiring | `net8.0` | `$(PowerCSharpFeatureSanitizationVersion)` | + +### Dependency direction + +``` +PowerCSharp.Feature.Sanitization.Abstractions (engine + contracts + NoOp, no ASP.NET Core dep) + ▲ + │ +PowerCSharp.Feature.Sanitization (module + options + DI/ASP.NET Core wiring) +``` + +Unlike the Cache family, there is no separate provider package: the engine's behavior is fixed (four sanitization concerns, each independently toggleable via settings), so `PowerCSharp.Feature.Sanitization` registers its one real implementation directly rather than deferring to a plugged-in backend. + +--- + +## 1. PowerCSharp.Feature.Sanitization.Abstractions + +### `SanitizationEngine` (static, partial) + +The core engine. Usable standalone with zero configuration — every method has safe, sensible defaults via a built-in `SanitizationSettings` instance. + +```csharp +public static partial class SanitizationEngine +{ + public static SanitizationResult SanitizeForLogInjection(string? input, SanitizationSettings? settings = null); + public static SanitizationResult SanitizeForFilePath(string? input, SanitizationSettings? settings = null); + public static string SanitizeCorrelationIdForPath(string correlationId); + public static SensitiveDataResult SanitizeForSensitiveData(string? input, SanitizationSettings? settings = null); + public static SanitizationResult SanitizeForRegexInjection(string? pattern, SanitizationSettings? settings = null); + + public static void SetConfigurationProvider(Func provider, Action? securityLogger = null); + public static void SetSecurityEventLogger(Action securityLogger); + public static Dictionary GetPerformanceStats(); +} +``` + +Split by concern across files for maintainability: `SanitizationEngine.cs` (shared state, configuration wiring, performance tracking), `SanitizationEngine.LogInjection.cs`, `SanitizationEngine.FilePath.cs`, `SanitizationEngine.SensitiveData.cs`, `SanitizationEngine.RegexInjection.cs`. + +Fail-safe behavior: sanitization methods never throw internally — a caught exception returns the original input unchanged (log injection, sensitive data) or a strict rejection (file path, regex injection), rather than propagating. + +### `SanitizationExtensions` + +The primary call-site API. No DI or configuration required. + +```csharp +"user input\r\ninjected".SanitizeForLog(); // CWE-117 +"../../etc/passwd".SanitizeForFilePath(); // CWE-22 — throws InvalidOperationException if rejected +"password=hunter2".SanitizeForSensitiveData(); // CWE-200 +"(a+)+$".SanitizeForRegexInjection(); // CWE-400/CWE-730 — returns "" if rejected +"secret-value".Mask('*'); // generic masking helper +``` + +`*WithDetails` variants (`SanitizeForLogWithDetails`, `SanitizeForFilePathWithDetails`, `SanitizeForSensitiveDataWithDetails`, `SanitizeForRegexInjectionWithDetails`) return the full `SanitizationResult`/`SensitiveDataResult` instead of a bare string, so callers can inspect `WasModified`/`IsRejected` without a try/catch. + +### Result types + +#### `SanitizationResult` + +```csharp +public sealed class SanitizationResult : IEquatable +{ + public string SanitizedValue { get; } + public bool WasModified { get; } + public SanitizationType SanitizationType { get; } + public TimeSpan ProcessingTime { get; } + public bool IsRejected { get; } + + public static SanitizationResult Unchanged(string originalValue, SanitizationType sanitizationType); + public static SanitizationResult Modified(string sanitizedValue, SanitizationType sanitizationType, TimeSpan processingTime); + public static SanitizationResult Rejected(SanitizationType sanitizationType, TimeSpan processingTime); +} +``` + +A plain class rather than a C# record — the package targets `netstandard2.0`, which lacks `init`-accessor support. Value equality is implemented manually, matching the convention already used by `PowerCSharp.Feature.Cache.Abstractions.CacheResult`. + +#### `SensitiveDataResult` + +Same shape as `SanitizationResult`, with `SanitizationType` fixed to `SensitiveData` and no `Rejected` factory (sensitive-data detection only masks; it never rejects). + +### `SanitizationSettings` + +The full configurable surface — every property has a secure-by-default value. Grouped by concern: + +```csharp +public sealed class SanitizationSettings +{ + // Log injection + public bool EnableLogSanitization { get; set; } = true; + public bool SanitizeUnicodeControlChars { get; set; } = true; + public bool PreserveTabCharacters { get; set; } = false; + public SanitizationStrategy LogSanitizationStrategy { get; set; } = SanitizationStrategy.Remove; + public int MaxSanitizedStringLength { get; set; } = 10000; + + // Failure / security event logging + public bool LogSanitizationFailures { get; set; } = false; + public LogLevel SanitizationFailureLogLevel { get; set; } = LogLevel.Warning; + public bool LogSecurityEvents { get; set; } = false; + + // Performance monitoring + public bool EnablePerformanceMonitoring { get; set; } = false; + public double SlowOperationThresholdMs { get; set; } = 1.0; + + // File path (CWE-22) + public bool EnableFilePathSanitization { get; set; } = true; + public bool EnableParentDirectoryTraversalCheck { get; set; } = true; + public bool UseStrictValidation { get; set; } = true; // allowlist mode (recommended) vs. legacy pattern-stripping + public bool ValidateWindowsReservedNames { get; set; } = true; + public bool AllowUnicodeCharacters { get; set; } = true; + public int MaxPathSegmentLength { get; set; } = 255; + public string[] AllowedBaseDirectories { get; set; } = Array.Empty(); + public string[] AllowedFileExtensions { get; set; } = Array.Empty(); + + // Sensitive data (CWE-200) + public bool EnableSensitiveDataDetection { get; set; } = true; + public char SensitiveDataMaskCharacter { get; set; } = '*'; + public SensitiveDataDetectionStrictness SensitiveDataDetectionStrictness { get; set; } = SensitiveDataDetectionStrictness.Medium; + + // Regex injection / ReDoS (CWE-400/CWE-730) + public bool EnableRegexSanitization { get; set; } = true; + public TimeSpan MaxRegexValidationTimeout { get; set; } = TimeSpan.FromSeconds(5); + public int MaxRegexPatternLength { get; set; } = 1000; + public int MaxRegexComplexityScore { get; set; } = 100; + public bool AllowUnicodeCategories { get; set; } = true; + public bool AllowQuantifiers { get; set; } = true; + public bool AllowNestedQuantifiers { get; set; } = false; // the classic catastrophic-backtracking shape + public bool AllowBackreferences { get; set; } = true; + public bool AllowLookarounds { get; set; } = true; + + public string? CorrelationId { get; set; } // per-call; not bound from global config +} +``` + +### Enums + +```csharp +public enum SanitizationType { LogInjection, FilePath, SensitiveData, RegexInjection } +public enum SanitizationStrategy { Remove, ReplaceWithSpace, HtmlEncode, UrlEncode, JsonEncode } +public enum SensitiveDataDetectionStrictness { Low, Medium, High } +``` + +### NoOp implementation + +| Class | Interface | Behavior | +|---|---|---| +| `NoOpSanitizationService` | `ISanitizationService` | Every method returns the input unchanged (`Unchanged` factory). Safe-off floor when the feature is disabled. | + +### File-path sanitization: two modes + +`SanitizeForFilePath` uses **allowlist validation** (recommended, `UseStrictValidation = true` by default): rejects anything suspicious — invalid characters, Windows reserved device names, oversized path segments, disallowed extensions, traversal patterns, paths outside `AllowedBaseDirectories` — rather than trying to "clean" it. When `UseStrictValidation = false`, a legacy pattern-stripping mode is used instead: `..`, doubled separators, drive letters, and `./` references are stripped rather than rejected. Prefer allowlist mode for new code; legacy mode exists for compatibility with callers migrated from the original implementation. + +### Sensitive-data masking + +Detects and masks (rather than rejects) tokens, secrets, credentials, URLs, and file-system paths via a layered pattern pipeline: specific patterns first (`Bearer ...`, `sk-...`, `api_key=...`, `password=...`), then generic key-based heuristics (`password:`, `token=`, `admin=`, etc. — preserving the key name, masking only the value), then structural patterns (URLs, Windows/UNC/Unix/home paths), and finally — at `Medium` strictness or higher — any long alphanumeric run (16+ characters), to catch tokens that don't match a named pattern. `MaskValueIntelligently` keeps roughly the first and last quarter of a value visible so masked output still hints at shape/length without disclosing content. + +### Regex-injection / ReDoS validation + +`SanitizeForRegexInjection` never executes the untrusted pattern against untrusted input — it validates the pattern itself before it's ever used: pattern length, compile-with-timeout safety, a complexity score (nesting depth, quantifier/group counts), and configurable rejection of specific catastrophic-backtracking shapes (nested quantifiers, by default disallowed) and constructs (Unicode categories, quantifiers, backreferences, lookarounds — all independently toggleable). + +--- + +## 2. PowerCSharp.Feature.Sanitization + +### `SanitizationFeatureModule` + +`IFeatureModule` implementation. Auto-discoverable (parameterless constructor). + +```csharp +public const string Key = "Sanitization"; // FeatureKey +public int Order => 20; +``` + +`ConfigureServices` behavior — decides active vs. NoOp directly (unlike Cache, there is no provider package to defer to): + +1. Binds `SanitizationFeatureOptions` from `PowerFeatures:Sanitization`. +2. If the feature flag is disabled: registers `NoOpSanitizationService` and returns. +3. If enabled: registers `SanitizationSettingsProvider` and the real `SanitizationService`. + +`ConfigurePipeline` behavior — contributes no middleware; used solely to bridge the DI-resolved `ISanitizationSettingsProvider` into the static `SanitizationEngine` once the application's `IServiceProvider` is built, via `SanitizationEngineServiceProviderExtensions.ConfigureSanitizationEngine`. + +### `SanitizationFeatureOptions` (inherits `FeatureOptionsBase`) + +Mirrors the full `SanitizationSettings` surface (minus `CorrelationId`, which is per-call rather than global configuration) so every sanitization concern is configurable via `appsettings.json`. + +### `SanitizationService` / `SanitizationSettingsProvider` + +```csharp +public sealed class SanitizationSettingsProvider : ISanitizationSettingsProvider +{ + public SanitizationSettingsProvider(IOptionsMonitor options); + public SanitizationSettings GetCurrentSettings(); +} + +public sealed class SanitizationService : ISanitizationService +{ + public SanitizationService(ISanitizationSettingsProvider settingsProvider); + // delegates every method to SanitizationEngine, sourcing settings from settingsProvider +} +``` + +`SanitizationService` depends on `ISanitizationSettingsProvider` rather than options directly, so the options-to-settings mapping lives in exactly one place. `SanitizationSettingsProvider` deliberately takes no `ILogger` dependency — the engine's security-event logging path can call back into settings resolution, and a logger dependency there would risk a recursive logging loop. + +### `SanitizationFeatureExtensions.AddSanitizationFeature` (explicit extension) + +```csharp +IServiceCollection AddSanitizationFeature( + this IServiceCollection services, + IConfiguration configuration) +``` + +Registers the real `SanitizationService` directly — not a NoOp floor. This is a deliberate departure from `AddCacheFeature`'s pattern: Cache always registers a NoOp floor because a *separate* provider package (e.g. BitFaster) supplies the real implementation via a plain `AddSingleton` that overrides it. Sanitization has no such split, so explicit opt-in here means the caller wants sanitization active. + +### `SanitizationEngineServiceProviderExtensions.ConfigureSanitizationEngine` + +```csharp +IServiceProvider ConfigureSanitizationEngine(this IServiceProvider serviceProvider) +``` + +Resolves `ISanitizationSettingsProvider` from the given provider, if registered, and wires it into the static engine via `SanitizationEngine.SetConfigurationProvider`. A no-op (returns the same provider unchanged) when the feature is disabled or no settings provider is registered. Called automatically by `SanitizationFeatureModule.ConfigurePipeline` under auto-discovery; call it explicitly after `AddSanitizationFeature` when not using the Features engine's pipeline. + +### Configuration + +```json +{ + "PowerFeatures": { + "Sanitization": { + "Enabled": true, + "EnableLogSanitization": true, + "LogSanitizationStrategy": "Remove", + "EnableFilePathSanitization": true, + "UseStrictValidation": true, + "EnableSensitiveDataDetection": true, + "SensitiveDataDetectionStrictness": "Medium", + "EnableRegexSanitization": true, + "AllowNestedQuantifiers": false + } + } +} +``` + +--- + +## 3. Two-Layer Gating + +| | No package ref | Package ref + flag off | Package ref + flag on | +|---|---|---|---| +| **DI-resolved `ISanitizationService`** | Absent — no code, no deps | `NoOpSanitizationService` (safe-off) | Real `SanitizationService` | +| **Static `SanitizationEngine` extension methods** | N/A (Abstractions not referenced) | Active regardless of the flag, using built-in defaults unless a host explicitly wires disabled settings | Active, reflecting bound configuration once `ConfigureSanitizationEngine` has run | + +The static engine's extension-method API (`input.SanitizeForLog()`) is usable as a pure utility with zero registration, independent of the Features Framework flag — the flag only gates the *DI-resolved* `ISanitizationService`, and (indirectly) whether the engine gets wired to reflect host configuration versus its built-in defaults. + +--- + +## 4. Host Integration — Full Example + +```csharp +// Program.cs + +// Option A: auto-discovery via engine scan +builder.Services.AddPowerFeatures(builder.Configuration, options => +{ + options.ScanAssemblies(typeof(SanitizationFeatureModule).Assembly); +}); + +var app = builder.Build(); +app.UsePowerFeatures(); // also wires the static engine + +// Option B: explicit registration (no reflection) +builder.Services.AddSanitizationFeature(builder.Configuration); +// ... +var app2 = builder.Build(); +app2.Services.ConfigureSanitizationEngine(); +``` + +```csharp +// In a service/controller +public class MyService(ISanitizationService sanitizer, ILogger logger) +{ + public void HandleUserInput(string untrustedInput) + { + var result = sanitizer.SanitizeForLogInjection(untrustedInput); + logger.LogInformation("Received: {Input}", result.SanitizedValue); + } +} + +// Or, without DI, anywhere PowerCSharp.Feature.Sanitization.Abstractions is referenced: +logger.LogInformation("Received: {Input}", untrustedInput.SanitizeForLog()); +``` + +--- + +## 5. Related Documents + +- [`PowerCSharp.Features.Architecture.md`](PowerCSharp.Features.Architecture.md) — Two-tier design, dependency topology +- [`PowerCSharp.Features.Authoring-Guide.md`](PowerCSharp.Features.Authoring-Guide.md) — How to build a new pluggable feature +- [`PowerCSharp.Feature.Cache.md`](PowerCSharp.Feature.Cache.md) — The Cache family, for contrast with the provider-package pattern +- [`EDGE_CASES_AND_SECURITY.md`](EDGE_CASES_AND_SECURITY.md) — Per-API edge-case and security notes diff --git a/samples/WebSample/Extensions/ServiceCollectionExtensions.cs b/samples/WebSample/Extensions/ServiceCollectionExtensions.cs index 2bf27fd..2578390 100644 --- a/samples/WebSample/Extensions/ServiceCollectionExtensions.cs +++ b/samples/WebSample/Extensions/ServiceCollectionExtensions.cs @@ -1,3 +1,8 @@ +using PowerCSharp.Feature.Cache; +using PowerCSharp.Feature.Cache.BitFaster; +using PowerCSharp.Feature.Sanitization; +using PowerCSharp.Features; + namespace WebSample.Extensions; /// @@ -15,7 +20,7 @@ public static IServiceCollection AddSwaggerServices(this IServiceCollection serv services.AddSwaggerGen(c => { c.SwaggerDoc("v1", new() { Title = "PowerCSharp Web Sample API", Version = "v1" }); - + // Include XML comments var xmlFile = $"{System.Reflection.Assembly.GetExecutingAssembly().GetName().Name}.xml"; var xmlPath = Path.Combine(AppContext.BaseDirectory, xmlFile); @@ -24,4 +29,25 @@ public static IServiceCollection AddSwaggerServices(this IServiceCollection serv return services; } + + /// + /// Registers the PowerCSharp Features Framework via auto-discovery, opting in the Cache and + /// Sanitization feature modules. Also registers the BitFaster cache provider so the Cache demo + /// endpoint has an active backend rather than the NoOp floor. + /// + /// The IServiceCollection instance + /// The application configuration + public static IServiceCollection AddPowerCSharpFeatures(this IServiceCollection services, IConfiguration configuration) + { + services.AddPowerFeatures(configuration, options => + { + options.ScanAssemblies( + typeof(CacheFeatureModule).Assembly, + typeof(SanitizationFeatureModule).Assembly); + }); + + services.AddCacheBitFaster(configuration); + + return services; + } } diff --git a/samples/WebSample/Extensions/WebApplicationExtensions.cs b/samples/WebSample/Extensions/WebApplicationExtensions.cs index f19b965..5dd4b2a 100644 --- a/samples/WebSample/Extensions/WebApplicationExtensions.cs +++ b/samples/WebSample/Extensions/WebApplicationExtensions.cs @@ -1,5 +1,7 @@ +using PowerCSharp.Features; using WebSample.Samples.Core; using WebSample.Samples.Extensions; +using WebSample.Samples.Features; using WebSample.Samples.Helpers; using WebSample.Samples.Utilities; @@ -24,6 +26,17 @@ public static void UseSwaggerUI(this WebApplication app) }); } + /// + /// Runs the PowerCSharp Features Framework pipeline hook — resolves each enabled feature's + /// middleware (none, for Cache/Sanitization) and bridges the Sanitization engine's + /// configuration for non-DI call sites. + /// + /// The WebApplication instance + public static void UsePowerCSharpFeatures(this WebApplication app) + { + app.UsePowerFeatures(); + } + /// /// Maps all PowerCSharp demo endpoints using dedicated endpoint classes /// @@ -55,5 +68,9 @@ public static void MapPowerCSharpDemoEndpoints(this WebApplication app) app.MapGet("/demo/collection", CollectionSampleEndpoints.GetDemoData); app.MapGet("/demo/dictionary", DictionarySampleEndpoints.GetDemoData); app.MapGet("/demo/unix-timestamp", UnixTimestampSampleEndpoints.GetDemoData); + + // Features Framework Samples + app.MapGet("/demo/cache", CacheSampleEndpoints.GetDemoDataAsync); + app.MapGet("/demo/sanitization", SanitizationSampleEndpoints.GetDemoData); } } diff --git a/samples/WebSample/Program.cs b/samples/WebSample/Program.cs index 1a5b17c..56679e9 100644 --- a/samples/WebSample/Program.cs +++ b/samples/WebSample/Program.cs @@ -5,8 +5,14 @@ // Add Swagger services builder.Services.AddSwaggerServices(); +// Add the PowerCSharp Features Framework (Cache + Sanitization feature modules) +builder.Services.AddPowerCSharpFeatures(builder.Configuration); + var app = builder.Build(); +// Run the Features Framework pipeline hook (bridges the Sanitization engine's configuration) +app.UsePowerCSharpFeatures(); + // Map all PowerCSharp demo endpoints app.MapPowerCSharpDemoEndpoints(); diff --git a/samples/WebSample/Samples/Features/CacheSampleEndpoints.cs b/samples/WebSample/Samples/Features/CacheSampleEndpoints.cs new file mode 100644 index 0000000..d6cd91b --- /dev/null +++ b/samples/WebSample/Samples/Features/CacheSampleEndpoints.cs @@ -0,0 +1,39 @@ +using PowerCSharp.Feature.Cache.Abstractions; + +namespace WebSample.Samples.Features; + +/// +/// Sample endpoint demonstrating the Cache feature, resolved via the Features Framework +/// (PowerCSharp.Feature.Cache + PowerCSharp.Feature.Cache.BitFaster). +/// +public static class CacheSampleEndpoints +{ + private const string DemoKey = "websample:cache:demo"; + + /// + /// Gets cache demo data: a cache miss, a write, and the resulting hit, using the + /// DI-resolved (the active BitFaster provider, or NoOp if the + /// Cache feature is disabled via configuration). + /// + /// The cache service, injected by the Features Framework. + /// Demo results showing a cache miss followed by a set/hit round-trip. + public static async Task GetDemoDataAsync(ICacheService cache) + { + // Start from a clean slate so this endpoint is idempotent across repeated calls. + await cache.RemoveAsync(DemoKey); + + var missResult = await cache.GetWithResultAsync(DemoKey); + + var value = $"cached at {DateTimeOffset.UtcNow:O}"; + await cache.SetAsync(DemoKey, value, TimeSpan.FromMinutes(1)); + + var hitResult = await cache.GetWithResultAsync(DemoKey); + + return new + { + providerNote = "Backed by PowerCSharp.Feature.Cache.BitFaster; falls back to a NoOp cache if the Cache feature is disabled.", + beforeSet = new { hit = missResult.IsSuccess, value = missResult.Value }, + afterSet = new { hit = hitResult.IsSuccess, value = hitResult.Value, provider = hitResult.ProviderName } + }; + } +} diff --git a/samples/WebSample/Samples/Features/SanitizationSampleEndpoints.cs b/samples/WebSample/Samples/Features/SanitizationSampleEndpoints.cs new file mode 100644 index 0000000..7009a7d --- /dev/null +++ b/samples/WebSample/Samples/Features/SanitizationSampleEndpoints.cs @@ -0,0 +1,57 @@ +using PowerCSharp.Feature.Sanitization.Abstractions; + +namespace WebSample.Samples.Features; + +/// +/// Sample endpoint demonstrating the Sanitization feature, resolved via the Features Framework +/// (PowerCSharp.Feature.Sanitization). +/// +public static class SanitizationSampleEndpoints +{ + /// + /// Gets sanitization demo data covering all four concerns: log injection (CWE-117), + /// file-path traversal (CWE-22), sensitive-data masking (CWE-200), and regex-injection/ReDoS + /// validation (CWE-400/CWE-730), using the DI-resolved . + /// + /// The sanitization service, injected by the Features Framework. + /// Demo results for each sanitization concern. + public static object GetDemoData(ISanitizationService sanitizer) + { + const string maliciousLogInput = "user logged in\r\nADMIN: fake audit entry injected"; + const string maliciousPath = "../../etc/passwd"; + const string secretPayload = "password=SuperSecret123, token=abcdef0123456789"; + const string unsafeRegexPattern = "(a+)+$"; // classic catastrophic-backtracking shape + + var logResult = sanitizer.SanitizeForLogInjection(maliciousLogInput); + var pathResult = sanitizer.SanitizeForFilePath(maliciousPath); + var sensitiveResult = sanitizer.SanitizeForSensitiveData(secretPayload); + var regexResult = sanitizer.SanitizeForRegexInjection(unsafeRegexPattern); + + return new + { + note = "Falls back to a NoOp sanitizer (inputs pass through unchanged) if the Sanitization feature is disabled.", + logInjection = new + { + original = maliciousLogInput, + sanitized = logResult.SanitizedValue, + wasModified = logResult.WasModified + }, + filePathTraversal = new + { + original = maliciousPath, + wasRejected = pathResult.IsRejected + }, + sensitiveDataMasking = new + { + original = secretPayload, + masked = sensitiveResult.SanitizedValue, + wasModified = sensitiveResult.WasModified + }, + regexInjection = new + { + original = unsafeRegexPattern, + wasRejected = regexResult.IsRejected + } + }; + } +} diff --git a/samples/WebSample/WebSample.csproj b/samples/WebSample/WebSample.csproj index ed3757b..01327e7 100644 --- a/samples/WebSample/WebSample.csproj +++ b/samples/WebSample/WebSample.csproj @@ -6,6 +6,13 @@ + + + + + + + diff --git a/samples/WebSample/appsettings.json b/samples/WebSample/appsettings.json index 4d56694..e9990ae 100644 --- a/samples/WebSample/appsettings.json +++ b/samples/WebSample/appsettings.json @@ -5,5 +5,15 @@ "Microsoft.AspNetCore": "Warning" } }, - "AllowedHosts": "*" + "AllowedHosts": "*", + "PowerFeatures": { + "Cache": { + "Enabled": true, + "Provider": "BitFaster", + "Capacity": 1000 + }, + "Sanitization": { + "Enabled": true + } + } } diff --git a/src/Features/CLAUDE.md b/src/Features/CLAUDE.md new file mode 100644 index 0000000..66b0d12 --- /dev/null +++ b/src/Features/CLAUDE.md @@ -0,0 +1,96 @@ +# CLAUDE.md — Features Framework (`src/Features/`) + +> Scope: `PowerCSharp.Features.Abstractions`, `PowerCSharp.Features`, `PowerCSharp.BuiltInFeatures`, +> and the `PowerCSharp.Feature.*` family. Read this alongside the root `CLAUDE.md` and, before any +> substantial change here, `docs/PowerCSharp.Features.Architecture.md` and +> `docs/PowerCSharp.Features.Authoring-Guide.md` in full. + +## Sensitivity level: high + +The engine (`PowerCSharp.Features`) is load-bearing for every feature package that exists or will +exist, including the roadmapped `PowerCSharp.Feature.Sitecore`. A subtle change to discovery or +flag-resolution order silently changes behavior for every consumer, in every host app, without a +compile error. Treat changes here as you would changes to a dependency-injection container: +narrowly scoped, heavily tested, and called out explicitly as `` in your response. + +## 1. The Two-Tier Model + +| Tier | Package | Gating | Third-party deps | +|---|---|---|---| +| Group 1 — Built-in | `PowerCSharp.BuiltInFeatures` | Runtime flag only | None isolated (framework/ASP.NET Core only) | +| Group 2 — Pluggable | `PowerCSharp.Feature.[.Provider]` | Package reference **and** runtime flag | Isolated per package | + +Any Built-in Feature can be disabled and replaced by a custom Pluggable Feature. Do not add a +third-party dependency to `PowerCSharp.BuiltInFeatures` — that would violate the tier's contract; +a feature needing one belongs in Group 2. + +## 2. Dependency Direction (do not invert) + +```text +PowerCSharp.Features.Abstractions (contracts, zero deps) + ▲ ▲ + │ │ +PowerCSharp.Features PowerCSharp.Feature. (module/options) + (engine) ▲ + ▲ │ + │ PowerCSharp.Feature.. (isolates 3rd-party SDK) +PowerCSharp.BuiltInFeatures +``` + +`Features.Abstractions` must never gain a dependency on `Features` (the engine) or on any +`Feature.*` package — it is the floor everything else builds on. If you find yourself wanting to +reference the engine from Abstractions, the abstraction belongs somewhere else. + +## 3. Engine Internals — What Each Piece Does + +Grounded in the current implementation (`src/Features/PowerCSharp.Features/`): + +- **`PowerFeaturesServiceCollectionExtensions.AddPowerFeatures`** — the single entry point. It (in + order): builds the flag-provider chain, discovers modules, and for **every** discovered module — + enabled or not — invokes `ConfigureServices`. Modules self-gate internally (Model A: "always + invoke `ConfigureServices`; the module decides active vs. NoOp"). Do not change this to + conditionally skip disabled modules' `ConfigureServices` — that would break the NoOp + safe-off contract every existing feature module relies on. +- **`FeatureModuleDiscovery.Discover`** (`Internal/FeatureModuleDiscovery.cs`) — reflection-based: + scans opted-in assemblies for public, non-abstract types implementing `IFeatureModule` with a + parameterless constructor, merges with explicitly-supplied instances (explicit wins on type + collision), de-dupes by concrete `Type`, and orders by `Order` then `FeatureKey`. Uses + `Assembly.GetTypes()` wrapped in a `ReflectionTypeLoadException` catch — if you touch this, + preserve that catch; it exists because partially-loadable assemblies are a real occurrence in + consumer apps. +- **`CompositeFeatureFlagProvider`** (`Flags/CompositeFeatureFlagProvider.cs`) — precedence-ordered + chain, first provider to return `HasValue == true` wins. Order is assembled in + `AddPowerFeatures` as: **Override → custom providers (`options.AdditionalProviders`) → + Environment → Configuration**. This order is a deliberate product decision (explicit overrides, + e.g. for tests, must always win over configuration). Do not reorder without discussing the + consequence for every existing consumer's `appsettings.json` expectations. +- **`IFeatureModule`** (`Features.Abstractions/IFeatureModule.cs`) — the contract every feature + implements: `FeatureKey` (stable string, maps to `PowerFeatures:`), `Order` (registration + ordering, lower first), `ConfigureServices` (required), `ConfigurePipeline` (default no-op). + A new feature package's module is the concrete implementation of this interface — see + `docs/PowerCSharp.Features.Authoring-Guide.md` §3.2 for the canonical worked example + (`CacheFeatureModule`). + +## 4. Adding a New Pluggable Feature Here + +Do not improvise. Follow SOP-02 in the root `Workflows.md`, which sequences the Authoring Guide +into a checklist. The short version: classify the tier, decide single-package vs. family, scaffold +under `src/Features/PowerCSharp.Feature.[.Provider]/`, implement the five-piece anatomy +(`FeatureKey`, contracts, options, module, NoOp), and give the family its own +`PowerCSharpFeatureVersion` in `Directory.Build.props`. + +## 5. Sitecore — Roadmap Status + +`docs/PowerCSharp.Features.Architecture.md` §3 lists `PowerCSharp.Feature.Sitecore` (third-party +GraphQL integration) as a future pluggable package. As of this writing: + +- It is **confirmed in scope** as a future initiative — not abandoned, not yet started. +- There is a branch, `feature/sitecore/phase-1-implementation`, whose diff against `develop` + contains **no Sitecore implementation code** — only test-file deletions that look like stale + rebase artifacts. Do not treat that branch as a starting point without first confirming with the + maintainer whether it should be discarded or salvaged; assume discard-and-restart via SOP-02 + unless told otherwise. +- No design decisions have been made yet about which Sitecore integration surface this targets + (Content Serialization, GraphQL Layout Service, Experience Edge, or a combination). Do not + invent this design — it is an open architectural question to raise with the maintainer before + scaffolding `PowerCSharp.Feature.Sitecore`, per Workflows.md SOP-02 step 2 and step 11. diff --git a/src/Features/PowerCSharp.BuiltInFeatures/README.md b/src/Features/PowerCSharp.BuiltInFeatures/README.md index 4efb480..8dc58f5 100644 --- a/src/Features/PowerCSharp.BuiltInFeatures/README.md +++ b/src/Features/PowerCSharp.BuiltInFeatures/README.md @@ -1,5 +1,7 @@ # PowerCSharp.BuiltInFeatures +![PowerCSharp Banner](https://raw.githubusercontent.com/marioarce/PowerCSharp/0191ee12092c28ccf5a578e59977583117a3ff00/docs/images/PowerCSharp_Banner.png) + Bundle of lightweight, runtime-flag-toggled ASP.NET Core capabilities. Toggle each via `PowerFeatures::Enabled`. Any built-in can be disabled and replaced by a custom Pluggable Feature. diff --git a/src/Features/PowerCSharp.Feature.Cache.Abstractions/README.md b/src/Features/PowerCSharp.Feature.Cache.Abstractions/README.md index 5d4bee9..68e2a3b 100644 --- a/src/Features/PowerCSharp.Feature.Cache.Abstractions/README.md +++ b/src/Features/PowerCSharp.Feature.Cache.Abstractions/README.md @@ -1,5 +1,7 @@ # PowerCSharp.Feature.Cache.Abstractions +![PowerCSharp Banner](https://raw.githubusercontent.com/marioarce/PowerCSharp/0191ee12092c28ccf5a578e59977583117a3ff00/docs/images/PowerCSharp_Banner.png) + Framework-agnostic contracts and safe-off NoOp implementations for the PowerCSharp Cache feature. - Targets `netstandard2.0` and `net8.0`, so cache providers can run on **.NET Framework** and **.NET Core**. diff --git a/src/Features/PowerCSharp.Feature.Cache.BitFaster/README.md b/src/Features/PowerCSharp.Feature.Cache.BitFaster/README.md index b82b466..1411f1c 100644 --- a/src/Features/PowerCSharp.Feature.Cache.BitFaster/README.md +++ b/src/Features/PowerCSharp.Feature.Cache.BitFaster/README.md @@ -1,5 +1,7 @@ # PowerCSharp.Feature.Cache.BitFaster +![PowerCSharp Banner](https://raw.githubusercontent.com/marioarce/PowerCSharp/0191ee12092c28ccf5a578e59977583117a3ff00/docs/images/PowerCSharp_Banner.png) + BitFaster-backed implementation of the PowerCSharp Cache feature. References `BitFaster.Caching`; this dependency is isolated here and never enters apps that don't reference this package. diff --git a/src/Features/PowerCSharp.Feature.Cache.Disk/CLAUDE.md b/src/Features/PowerCSharp.Feature.Cache.Disk/CLAUDE.md new file mode 100644 index 0000000..417bf73 --- /dev/null +++ b/src/Features/PowerCSharp.Feature.Cache.Disk/CLAUDE.md @@ -0,0 +1,89 @@ +# CLAUDE.md — PowerCSharp.Feature.Cache.Disk + +> Scope: this project only (`DiskCacheService`, `DiskCacheIndex`, `DiskCacheIndexEntry`, +> `DiskCacheBackgroundService`, `DiskCacheFeatureOptions`). Read alongside `src/Features/CLAUDE.md` +> and the root `CLAUDE.md`. + +## Sensitivity level: high + +This is the one package in the repository doing real cross-process concurrency control and +non-transactional file I/O. Bugs here are silent-corruption or silent-deadlock bugs, not +compile-time or even easily-reproducible-in-a-unit-test bugs. Any change to locking, atomic write +ordering, or index serialization needs a `` block in your response and, ideally, a +multi-process manual test, not just the existing unit test suite. + +## 1. What This Package Actually Does (grounded in `DiskCacheService.cs`) + +- Stores each cache value as its own JSON file under `_rootDirectory` (default + `%TEMP%/powercsharp-disk-cache`, overridable via `DiskCacheFeatureOptions.DirectoryPath`), named + by a hash of the key (`HashFileName`), plus a single `index.json` mapping keys → + `DiskCacheIndexEntry` (file path, created/last-accessed/expires timestamps). +- **In-process locking** is two-layered: + - `_indexLock` (a plain `object` + `lock`) guards all in-memory mutation of `_index.Entries`. + - Per-key `SemaphoreSlim`s (`_keyLocks`, `ConcurrentDictionary`) serialize + concurrent `Get`/`Set`/`Remove` calls for the *same key within this process*. +- **Cross-process locking** is opt-in via `DiskCacheFeatureOptions.EnableCrossProcessLocking`, + implemented with named, global `Mutex`es: + - One index mutex per root directory: `Global\PowerCSharp_DiskCache_Index_{hash}`. + - One mutex per key: `Global\PowerCSharp_DiskCache_Key_{hash}`, created lazily and cached in + `_keyMutexes`, **never removed** for the lifetime of the service (see Hazard 1 below). + - `WithIndexLock` / `WithKeyLock` wrap the corresponding critical section; both are no-ops if + `EnableCrossProcessLocking` is `false` — in that mode, only the in-process locks apply, and + multiple *processes* sharing a directory can race. +- **Writes are atomic** at the OS level: value writes go to `.tmp` then `File.Move` to the + final path (never write-in-place); `SaveIndex()` follows the identical temp-then-move pattern for + `index.json`. This is what makes a torn read of a `.json` value or of the index itself + structurally impossible under normal OS semantics — do not "simplify" this back to a direct + `File.WriteAllText` on the real path. +- **Eviction** is LRU by `LastAccessedUtc`, enforced in `EvictIfNeeded()` (inline, on every + `SetAsync`) and `EvictToLimit()` (callable directly). **TTL** expiry is checked lazily on read + (`IsExpired`) and swept proactively by `PurgeExpiredAsync`, invoked on a `System.Threading.Timer` + when `EnableBackgroundCleanup` is `true` (constructor, `StartCleanupTimer`). +- `DiskCacheBackgroundService` (net8.0 only, `#if NET8_0_OR_GREATER`) is an `IHostedService` + wrapper for host lifecycle logging only — the actual timer lives in `DiskCacheService` itself and + runs on every target framework, including `netstandard2.0` hosts with no `IHostedService` concept. + +## 2. Known Hazards — Do Not Touch Without Addressing These + +1. **`_keyMutexes` grows unbounded.** Every distinct cache key ever used with cross-process locking + enabled creates a `Mutex` that lives until `Dispose()`. A cache with high key cardinality and + `EnableCrossProcessLocking = true` will leak OS mutex handles for the life of the process. If + you're asked to fix this, the fix needs its own eviction policy for `_keyMutexes` — coordinated + with, but distinct from, the LRU eviction of cache *entries* in §1, since a mutex must never be + disposed while another thread/process could still be waiting on it. +2. **Mutex hash collisions.** Mutex names are derived from `key.GetHashCode():X8` / + `_rootDirectory.GetHashCode():X8` — a 32-bit hash. Two distinct keys colliding on this hash will + silently share a lock (harmless — over-serializes, doesn't corrupt) but two distinct + **root directories** colliding would cross-serialize unrelated caches. Low probability, non-zero; + do not "optimize" this to a shorter hash. +3. **`GetOrCreateAsync` factory runs inside the per-key semaphore**, not inside the cross-process + mutex. Under `EnableCrossProcessLocking = true` with multiple processes, two processes can both + run the factory concurrently for a cold key before either writes — last writer wins, no + duplicate-work prevention across processes by design. Do not describe this method as + preventing cross-process duplicate computation; it only prevents duplicate computation + in-process. +4. **`Global\` mutex namespace requires appropriate OS privileges** in some locked-down Windows + environments (e.g. certain container/service-account configurations) and has no direct + equivalent semantics on non-Windows platforms beyond what .NET's `Mutex` emulates. If you extend + this feature to a new OS target, re-validate this mechanism specifically — do not assume named + `Mutex` behaves identically across platforms just because it compiles under `netstandard2.0`. +5. **No corruption-detection on partial writes from a hard process kill mid-`File.Move`.** The + temp-then-move pattern protects against torn writes, not against a crash between deleting the + old file and completing the move (`SetAsync`: `File.Delete(filePath)` then `File.Move(tempPath, + filePath)` are two separate syscalls). A crash in that narrow window loses the entry (the old + file is gone, the new one didn't land) — this degrades to a cache miss on next read, which is + safe for a cache, but should not be described as fully atomic across a crash boundary. + +## 3. If You Are Asked to Modify This Package + +- Reproduce hazards with a **multi-process** test harness (e.g. two console apps or two xunit test + processes pointed at the same `DirectoryPath`) before claiming a fix works — the existing + `tests/PowerCSharp.Feature.Cache.Tests` suite runs in a single process and cannot exercise cross- + process contention. +- Never remove the temp-file-then-move pattern for either cache value files or `index.json`. +- Never make `WithIndexLock`/`WithKeyLock` a no-op path change without preserving the existing + `EnableCrossProcessLocking` gate — some consumers deliberately run with it `false` for + single-process performance. +- Call out any change to mutex lifetime, naming, or scope explicitly as `` per the root + `CLAUDE.md` Section 5 — this is exactly the class of change that passes single-process unit tests + while breaking multi-process production behavior. diff --git a/src/Features/PowerCSharp.Feature.Cache.Disk/README.md b/src/Features/PowerCSharp.Feature.Cache.Disk/README.md index 1d44eb7..649f3fa 100644 --- a/src/Features/PowerCSharp.Feature.Cache.Disk/README.md +++ b/src/Features/PowerCSharp.Feature.Cache.Disk/README.md @@ -1,5 +1,7 @@ # PowerCSharp.Feature.Cache.Disk +![PowerCSharp Banner](https://raw.githubusercontent.com/marioarce/PowerCSharp/0191ee12092c28ccf5a578e59977583117a3ff00/docs/images/PowerCSharp_Banner.png) + Disk-backed LRU cache implementation for the PowerCSharp Cache feature. - Targets `net8.0`, so it runs on **.NET 8.0 and later**. diff --git a/src/Features/PowerCSharp.Feature.Cache/README.md b/src/Features/PowerCSharp.Feature.Cache/README.md index 12eb56d..65bccad 100644 --- a/src/Features/PowerCSharp.Feature.Cache/README.md +++ b/src/Features/PowerCSharp.Feature.Cache/README.md @@ -1,5 +1,7 @@ # PowerCSharp.Feature.Cache +![PowerCSharp Banner](https://raw.githubusercontent.com/marioarce/PowerCSharp/0191ee12092c28ccf5a578e59977583117a3ff00/docs/images/PowerCSharp_Banner.png) + Cache feature module, options, and ASP.NET Core wiring — **no third-party dependencies**. Pair this package with `PowerCSharp.Feature.Cache.Abstractions` (contracts + NoOp) and a provider package (e.g. `PowerCSharp.Feature.Cache.BitFaster`) to choose a backend. diff --git a/src/Features/PowerCSharp.Feature.Sanitization.Abstractions/Enums/SanitizationStrategy.cs b/src/Features/PowerCSharp.Feature.Sanitization.Abstractions/Enums/SanitizationStrategy.cs new file mode 100644 index 0000000..546bf64 --- /dev/null +++ b/src/Features/PowerCSharp.Feature.Sanitization.Abstractions/Enums/SanitizationStrategy.cs @@ -0,0 +1,44 @@ +namespace PowerCSharp.Feature.Sanitization.Abstractions.Enums; + +/// +/// Defines the strategy used to neutralize control characters during log-injection sanitization. +/// Each strategy represents a different approach to addressing CWE-117 vulnerabilities. +/// +public enum SanitizationStrategy +{ + /// + /// Removes control characters completely from the input. + /// This is the most aggressive approach and provides clean log output. + /// Recommended for most logging scenarios where character preservation is not critical. + /// + Remove, + + /// + /// Replaces control characters with a space character. + /// This maintains the original string length while neutralizing injection risks. + /// Useful when maintaining text structure is important for log parsing. + /// + ReplaceWithSpace, + + /// + /// Encodes control characters using HTML entity encoding. + /// This preserves the original characters in a safe format for display. + /// Recommended for web-based logging systems where logs may be rendered as HTML. + /// Addresses both CWE-117 and potential CWE-79 (XSS) in web log viewers. + /// + HtmlEncode, + + /// + /// Encodes control characters using URL encoding. + /// This preserves characters in a format safe for URL-based log systems. + /// Useful when log data may be transmitted via HTTP parameters or URLs. + /// + UrlEncode, + + /// + /// Encodes control characters using JSON escape sequences. + /// This preserves characters in a format safe for JSON-based logging systems. + /// Recommended for structured logging where logs are stored or transmitted as JSON. + /// + JsonEncode +} diff --git a/src/Features/PowerCSharp.Feature.Sanitization.Abstractions/Enums/SanitizationType.cs b/src/Features/PowerCSharp.Feature.Sanitization.Abstractions/Enums/SanitizationType.cs new file mode 100644 index 0000000..8aab905 --- /dev/null +++ b/src/Features/PowerCSharp.Feature.Sanitization.Abstractions/Enums/SanitizationType.cs @@ -0,0 +1,32 @@ +namespace PowerCSharp.Feature.Sanitization.Abstractions.Enums; + +/// +/// Defines the types of sanitization available for data processing. +/// Each type represents a specific security or data integrity concern. +/// +public enum SanitizationType +{ + /// + /// Sanitizes data to prevent log injection attacks by removing or encoding control characters. + /// Addresses CWE-117: Improper Output Neutralization for Logs. + /// + LogInjection, + + /// + /// Sanitizes file paths to prevent directory traversal attacks. + /// Addresses CWE-22: Improper Limitation of a Pathname to a Restricted Directory. + /// + FilePath, + + /// + /// Detects and masks sensitive data in log messages to prevent information disclosure. + /// Addresses CWE-200: Exposure of Sensitive Information to an Unauthorized Actor. + /// + SensitiveData, + + /// + /// Sanitizes regex patterns to prevent Regular Expression Denial of Service (ReDoS) attacks. + /// Addresses CWE-400: Uncontrolled Resource Consumption and CWE-730: Uncontrolled Recursion. + /// + RegexInjection +} diff --git a/src/Features/PowerCSharp.Feature.Sanitization.Abstractions/Enums/SensitiveDataDetectionStrictness.cs b/src/Features/PowerCSharp.Feature.Sanitization.Abstractions/Enums/SensitiveDataDetectionStrictness.cs new file mode 100644 index 0000000..ffb76f6 --- /dev/null +++ b/src/Features/PowerCSharp.Feature.Sanitization.Abstractions/Enums/SensitiveDataDetectionStrictness.cs @@ -0,0 +1,26 @@ +namespace PowerCSharp.Feature.Sanitization.Abstractions.Enums; + +/// +/// Defines the strictness levels for sensitive data detection. +/// Higher levels result in more aggressive detection patterns. +/// +public enum SensitiveDataDetectionStrictness +{ + /// + /// Conservative detection - only obvious sensitive patterns are detected. + /// Minimal false positives, but may miss some sensitive data. + /// + Low, + + /// + /// Balanced detection - common sensitive patterns are detected. + /// Good balance between false positives and false negatives. + /// + Medium, + + /// + /// Aggressive detection - most potential sensitive patterns are detected. + /// Higher false positive rate, but better protection against data leakage. + /// + High +} diff --git a/src/Features/PowerCSharp.Feature.Sanitization.Abstractions/ISanitizationService.cs b/src/Features/PowerCSharp.Feature.Sanitization.Abstractions/ISanitizationService.cs new file mode 100644 index 0000000..5c62c36 --- /dev/null +++ b/src/Features/PowerCSharp.Feature.Sanitization.Abstractions/ISanitizationService.cs @@ -0,0 +1,81 @@ +namespace PowerCSharp.Feature.Sanitization.Abstractions; + +/// +/// Defines the contract for a configuration-aware sanitization service, suitable for dependency +/// injection. Implementations wrap the static while sourcing +/// settings from the host's configuration system. +/// +public interface ISanitizationService +{ + /// + /// Sanitizes a string to prevent log injection attacks by removing or encoding control characters. + /// This method is optimized for performance and never throws exceptions. + /// Uses the service's current configuration. + /// + /// The input string to sanitize. Can be null. + /// A containing the sanitized string and operation details. + SanitizationResult SanitizeForLogInjection(string? input); + + /// + /// Sanitizes a string to prevent log injection attacks using specific settings. + /// This method is optimized for performance and never throws exceptions. + /// + /// The input string to sanitize. Can be null. + /// Specific sanitization settings to use. If null, uses the service's current configuration. + /// A containing the sanitized string and operation details. + SanitizationResult SanitizeForLogInjection(string? input, SanitizationSettings? settings); + + /// + /// Sanitizes a file path to prevent directory traversal attacks (CWE-22). + /// Uses the service's current configuration. + /// + /// The input file path to sanitize. Can be null. + /// A containing the validated file path and operation details. + SanitizationResult SanitizeForFilePath(string? input); + + /// + /// Sanitizes a file path to prevent directory traversal attacks (CWE-22) using specific settings. + /// + /// The input file path to sanitize. Can be null. + /// Specific sanitization settings to use. If null, uses the service's current configuration. + /// A containing the validated file path and operation details. + SanitizationResult SanitizeForFilePath(string? input, SanitizationSettings? settings); + + /// + /// Detects and masks sensitive data in a string to prevent information disclosure (CWE-200). + /// Uses the service's current configuration. + /// + /// The input string to check for sensitive data. Can be null. + /// A containing the masked string and operation details. + SensitiveDataResult SanitizeForSensitiveData(string? input); + + /// + /// Detects and masks sensitive data in a string using specific settings. + /// + /// The input string to check for sensitive data. Can be null. + /// Specific sanitization settings to use. If null, uses the service's current configuration. + /// A containing the masked string and operation details. + SensitiveDataResult SanitizeForSensitiveData(string? input, SanitizationSettings? settings); + + /// + /// Validates a regex pattern for safety against ReDoS attacks (CWE-400/CWE-730). + /// Uses the service's current configuration. + /// + /// The regex pattern to validate. Can be null. + /// A containing the validated pattern and operation details. + SanitizationResult SanitizeForRegexInjection(string? pattern); + + /// + /// Validates a regex pattern for safety against ReDoS attacks (CWE-400/CWE-730) using specific settings. + /// + /// The regex pattern to validate. Can be null. + /// Specific sanitization settings to use. If null, uses the service's current configuration. + /// A containing the validated pattern and operation details. + SanitizationResult SanitizeForRegexInjection(string? pattern, SanitizationSettings? settings); + + /// + /// Gets the current sanitization configuration used by this service. + /// + /// The current . + SanitizationSettings GetCurrentSettings(); +} diff --git a/src/Features/PowerCSharp.Feature.Sanitization.Abstractions/ISanitizationSettingsProvider.cs b/src/Features/PowerCSharp.Feature.Sanitization.Abstractions/ISanitizationSettingsProvider.cs new file mode 100644 index 0000000..70ef771 --- /dev/null +++ b/src/Features/PowerCSharp.Feature.Sanitization.Abstractions/ISanitizationSettingsProvider.cs @@ -0,0 +1,16 @@ +namespace PowerCSharp.Feature.Sanitization.Abstractions; + +/// +/// Provides the current to the static . +/// Allows a hosting layer to bridge its own configuration/options system into the engine via +/// without the engine taking a direct +/// dependency on any specific configuration framework. +/// +public interface ISanitizationSettingsProvider +{ + /// + /// Gets the current sanitization settings from configuration. + /// + /// The current sanitization settings. + SanitizationSettings GetCurrentSettings(); +} diff --git a/src/Features/PowerCSharp.Feature.Sanitization.Abstractions/NoOp/NoOpSanitizationService.cs b/src/Features/PowerCSharp.Feature.Sanitization.Abstractions/NoOp/NoOpSanitizationService.cs new file mode 100644 index 0000000..4c1287d --- /dev/null +++ b/src/Features/PowerCSharp.Feature.Sanitization.Abstractions/NoOp/NoOpSanitizationService.cs @@ -0,0 +1,61 @@ +using Microsoft.Extensions.Logging; +using PowerCSharp.Feature.Sanitization.Abstractions.Enums; + +namespace PowerCSharp.Feature.Sanitization.Abstractions.NoOp; + +/// +/// Inert used when the Sanitization feature is disabled. +/// Every method returns the input unchanged, so dependents resolve safely — sanitization is +/// simply inactive rather than the container failing to resolve . +/// +public sealed class NoOpSanitizationService : ISanitizationService +{ + private static readonly SanitizationSettings _disabledSettings = new() + { + EnableLogSanitization = false, + EnableFilePathSanitization = false, + EnableSensitiveDataDetection = false, + EnableRegexSanitization = false + }; + + /// Creates the NoOp sanitization service and logs that sanitization is inert. + public NoOpSanitizationService(ILogger logger) + { + logger.LogInformation("Sanitization feature is disabled; using NoOp sanitization service (inputs pass through unchanged)."); + } + + /// + public SanitizationResult SanitizeForLogInjection(string? input) + => SanitizationResult.Unchanged(input ?? string.Empty, SanitizationType.LogInjection); + + /// + public SanitizationResult SanitizeForLogInjection(string? input, SanitizationSettings? settings) + => SanitizationResult.Unchanged(input ?? string.Empty, SanitizationType.LogInjection); + + /// + public SanitizationResult SanitizeForFilePath(string? input) + => SanitizationResult.Unchanged(input ?? string.Empty, SanitizationType.FilePath); + + /// + public SanitizationResult SanitizeForFilePath(string? input, SanitizationSettings? settings) + => SanitizationResult.Unchanged(input ?? string.Empty, SanitizationType.FilePath); + + /// + public SensitiveDataResult SanitizeForSensitiveData(string? input) + => SensitiveDataResult.Unchanged(input ?? string.Empty); + + /// + public SensitiveDataResult SanitizeForSensitiveData(string? input, SanitizationSettings? settings) + => SensitiveDataResult.Unchanged(input ?? string.Empty); + + /// + public SanitizationResult SanitizeForRegexInjection(string? pattern) + => SanitizationResult.Unchanged(pattern ?? string.Empty, SanitizationType.RegexInjection); + + /// + public SanitizationResult SanitizeForRegexInjection(string? pattern, SanitizationSettings? settings) + => SanitizationResult.Unchanged(pattern ?? string.Empty, SanitizationType.RegexInjection); + + /// + public SanitizationSettings GetCurrentSettings() => _disabledSettings; +} diff --git a/src/Features/PowerCSharp.Feature.Sanitization.Abstractions/PowerCSharp.Feature.Sanitization.Abstractions.csproj b/src/Features/PowerCSharp.Feature.Sanitization.Abstractions/PowerCSharp.Feature.Sanitization.Abstractions.csproj new file mode 100644 index 0000000..636b16a --- /dev/null +++ b/src/Features/PowerCSharp.Feature.Sanitization.Abstractions/PowerCSharp.Feature.Sanitization.Abstractions.csproj @@ -0,0 +1,24 @@ + + + + netstandard2.0;net8.0 + enable + enable + true + + + PowerCSharp.Feature.Sanitization.Abstractions + $(PowerCSharpFeatureSanitizationVersion) + PowerCSharp Sanitization Feature - Abstractions + Framework-agnostic sanitization engine and contracts for the PowerCSharp Sanitization feature: log-injection (CWE-117), file-path traversal (CWE-22), sensitive-data (CWE-200), and regex-injection/ReDoS (CWE-400/730) sanitization, plus the NoOp safe-off implementation. Targets netstandard2.0 and net8.0. No ASP.NET Core dependency. + csharp;dotnet;features;feature-flags;security;sanitization;abstractions;clean-architecture + + + + + + + + + diff --git a/src/Features/PowerCSharp.Feature.Sanitization.Abstractions/README.md b/src/Features/PowerCSharp.Feature.Sanitization.Abstractions/README.md new file mode 100644 index 0000000..4bcf3c7 --- /dev/null +++ b/src/Features/PowerCSharp.Feature.Sanitization.Abstractions/README.md @@ -0,0 +1,52 @@ +# PowerCSharp.Feature.Sanitization.Abstractions + +![PowerCSharp Banner](https://raw.githubusercontent.com/marioarce/PowerCSharp/0191ee12092c28ccf5a578e59977583117a3ff00/docs/images/PowerCSharp_Banner.png) + +Framework-agnostic sanitization engine, contracts, and safe-off NoOp implementation for the PowerCSharp Sanitization feature. Covers log injection (CWE-117), file-path traversal (CWE-22), sensitive-data exposure (CWE-200), and regex-injection/ReDoS (CWE-400/CWE-730). + +- Targets `netstandard2.0` and `net8.0`, so the engine can run on **.NET Framework** and **.NET Core** — including hot-path logging call sites that reference nothing else. +- No ASP.NET Core dependency. +- Only dependencies are `Microsoft.Extensions.Logging.Abstractions` (the `ILogger` parameter on the NoOp service) and `System.Text.Json` (used internally by the log-injection JSON-encoding strategy). +- The engine is fail-safe: sanitization methods never throw. Only `SanitizeForFilePath` and `SanitizeForRegexInjection` can reject an input (via `IsRejected` on the result), and only under strict validation. + +## Contents + +- `SanitizationEngine` — static, partial engine class. Callable directly with no DI/configuration required; optionally wired to host configuration via `SetConfigurationProvider`. +- `SanitizationExtensions` — string extension methods (`SanitizeForLog`, `SanitizeForFilePath`, `SanitizeForSensitiveData`, `SanitizeForRegexInjection`, and `*WithDetails` variants, plus `Mask`). The primary call-site API — no DI required. +- `ISanitizationService` — DI-facing contract wrapping the engine, sourced from host configuration. +- `ISanitizationSettingsProvider` — bridges a host's configuration/options system into the engine. +- `SanitizationSettings` — the full configurable surface for every sanitization concern. +- `SanitizationResult` / `SensitiveDataResult` — operation result models (`Unchanged` / `Modified` / `Rejected` factories). +- `SanitizationType`, `SanitizationStrategy`, `SensitiveDataDetectionStrictness` — enums. +- `NoOpSanitizationService` — safe-off floor so dependents always resolve when the feature is disabled. + +## Namespaces + +```csharp +using PowerCSharp.Feature.Sanitization.Abstractions; // SanitizationEngine, SanitizationExtensions, SanitizationSettings, results +using PowerCSharp.Feature.Sanitization.Abstractions.Enums; // SanitizationType, SanitizationStrategy, SensitiveDataDetectionStrictness +using PowerCSharp.Feature.Sanitization.Abstractions.NoOp; // NoOpSanitizationService +``` + +## Usage without DI + +```csharp +using PowerCSharp.Feature.Sanitization.Abstractions; + +string safeForLog = untrustedInput.SanitizeForLog(); // CWE-117 +string safeForPath = untrustedSegment.SanitizeForFilePath(); // CWE-22 (throws if rejected under strict validation) +string masked = payload.SanitizeForSensitiveData(); // CWE-200 +string safePattern = untrustedPattern.SanitizeForRegexInjection(); // CWE-400/CWE-730 +``` + +Every method accepts an optional `SanitizationSettings` argument; when omitted, it uses whatever was wired via `SanitizationEngine.SetConfigurationProvider` (see `PowerCSharp.Feature.Sanitization`), or built-in defaults otherwise. + +## Feature module + +The Features-Framework module (options, DI wiring, ASP.NET Core integration) lives in `PowerCSharp.Feature.Sanitization`. This package has no dependency on it and works standalone. + +## Details + +- **Package ID:** `PowerCSharp.Feature.Sanitization.Abstractions` +- **Depends on:** `Microsoft.Extensions.Logging.Abstractions`, `System.Text.Json` +- **Target frameworks:** `netstandard2.0` and `net8.0` diff --git a/src/Features/PowerCSharp.Feature.Sanitization.Abstractions/SanitizationEngine.FilePath.cs b/src/Features/PowerCSharp.Feature.Sanitization.Abstractions/SanitizationEngine.FilePath.cs new file mode 100644 index 0000000..0a0fb53 --- /dev/null +++ b/src/Features/PowerCSharp.Feature.Sanitization.Abstractions/SanitizationEngine.FilePath.cs @@ -0,0 +1,539 @@ +using System.Diagnostics; +using System.Text; +using System.Text.RegularExpressions; +using PowerCSharp.Feature.Sanitization.Abstractions.Enums; + +namespace PowerCSharp.Feature.Sanitization.Abstractions; + +/// +/// File-path (CWE-22) sanitization: an allowlist-validation mode (recommended) plus a legacy +/// pattern-stripping mode, both guarding against directory traversal and absolute/UNC path injection. +/// +public static partial class SanitizationEngine +{ + private const string DoubleBackslash = "\\\\"; + private const string SingleBackslash = "\\"; + private const string ForwardSlash = "/"; + private const string DoubleForwardSlash = "//"; + private const string ParentDirectoryReference = ".."; + private const string Colon = ":"; + + // Windows reserved device names. + private static readonly string[] _windowsReservedNames = + { + "CON", "PRN", "AUX", "NUL", + "COM1", "COM2", "COM3", "COM4", "COM5", "COM6", "COM7", "COM8", "COM9", + "LPT1", "LPT2", "LPT3", "LPT4", "LPT5", "LPT6", "LPT7", "LPT8", "LPT9" + }; + + private static readonly char[] _pathSeparators = { '/', '\\' }; + +#if NET8_0_OR_GREATER + [GeneratedRegex(@"^[a-zA-Z]:")] + private static partial Regex DriveLetterRegex(); + + [GeneratedRegex(@"(?<=[/\\])\.|\.(?=[/\\])|^\./|^\.")] + private static partial Regex CurrentDirectoryReferenceRegex(); + + [GeneratedRegex(@"[/\\]{2,}")] + private static partial Regex MultipleSlashesRegex(); +#else + private static readonly Regex _driveLetterRegex = new(@"^[a-zA-Z]:", RegexOptions.Compiled); + private static Regex DriveLetterRegex() => _driveLetterRegex; + + private static readonly Regex _currentDirectoryReferenceRegex = new(@"(?<=[/\\])\.|\.(?=[/\\])|^\./|^\.", RegexOptions.Compiled); + private static Regex CurrentDirectoryReferenceRegex() => _currentDirectoryReferenceRegex; + + private static readonly Regex _multipleSlashesRegex = new(@"[/\\]{2,}", RegexOptions.Compiled); + private static Regex MultipleSlashesRegex() => _multipleSlashesRegex; +#endif + + /// + /// Sanitizes a file path to prevent directory traversal attacks (CWE-22). Uses allowlist + /// validation (recommended) when is + /// true, otherwise falls back to legacy pattern-stripping. + /// + /// The input file path to sanitize. Can be null. + /// Optional sanitization settings. If null, the configured or default settings are used. + /// A containing the validated file path and operation details. + public static SanitizationResult SanitizeForFilePath( + string? input, + SanitizationSettings? settings = null) + { + var startTime = Stopwatch.StartNew(); + var effectiveSettings = settings ?? GetCurrentSettings(); + + try + { + if (string.IsNullOrEmpty(input)) + { + startTime.Stop(); + UpdatePerformanceMetrics(startTime.Elapsed, effectiveSettings); + return SanitizationResult.Unchanged(input ?? string.Empty, SanitizationType.FilePath); + } + + // Narrowed to non-null/non-empty from here; netstandard2.0's reference assembly for + // string.IsNullOrEmpty doesn't carry the [NotNullWhen] annotation net8.0's does, so the + // compiler can't narrow `input` itself across TFMs — shadow with a value it can. + var value = input!; + + if (!effectiveSettings.EnableFilePathSanitization) + { + startTime.Stop(); + UpdatePerformanceMetrics(startTime.Elapsed, effectiveSettings); + return SanitizationResult.Unchanged(value, SanitizationType.FilePath); + } + + if (effectiveSettings.UseStrictValidation) + { + var isValid = ValidateFilePathAllowlist(value, effectiveSettings); + startTime.Stop(); + UpdatePerformanceMetrics(startTime.Elapsed, effectiveSettings); + + return isValid + ? SanitizationResult.Unchanged(value, SanitizationType.FilePath) + : SanitizationResult.Rejected(SanitizationType.FilePath, startTime.Elapsed); + } + + // Legacy mode: fast path when no dangerous patterns are present. + if (!ContainsDangerousPathPatterns(value, effectiveSettings)) + { + startTime.Stop(); + UpdatePerformanceMetrics(startTime.Elapsed, effectiveSettings); + return SanitizationResult.Unchanged(value, SanitizationType.FilePath); + } + + var sanitized = SanitizeFilePathLegacy(value, effectiveSettings); + var wasModified = !string.Equals(value, sanitized, StringComparison.Ordinal); + + startTime.Stop(); + UpdatePerformanceMetrics(startTime.Elapsed, effectiveSettings); + + return wasModified + ? SanitizationResult.Modified(sanitized, SanitizationType.FilePath, startTime.Elapsed) + : SanitizationResult.Unchanged(sanitized, SanitizationType.FilePath); + } + catch + { + // Fail-safe: reject the path entirely rather than risk returning something unsafe. + startTime.Stop(); + UpdatePerformanceMetrics(startTime.Elapsed, effectiveSettings); + return SanitizationResult.Rejected(SanitizationType.FilePath, startTime.Elapsed); + } + } + + /// + /// Sanitizes a correlation ID to make it safe for use as (part of) a file name. Removes + /// invalid file-name characters and enforces a reasonable maximum length. + /// + /// The correlation ID to sanitize. + /// A sanitized correlation ID safe for file-name use. + public static string SanitizeCorrelationIdForPath(string correlationId) + { + const int maxCorrelationIdLength = 50; + + if (string.IsNullOrEmpty(correlationId)) + { + return string.Empty; + } + + var invalidChars = Path.GetInvalidFileNameChars(); + var sanitized = new StringBuilder(correlationId.Length); + + foreach (var c in correlationId) + { + sanitized.Append(Array.IndexOf(invalidChars, c) >= 0 ? '_' : c); + } + + var result = sanitized.ToString(); + + if (result.Length > maxCorrelationIdLength) + { + result = result.Substring(0, maxCorrelationIdLength); + } + + // Trim trailing dots/spaces so the result is safe on file systems that reject them. + return result.TrimEnd('.', ' '); + } + + /// + /// Fast check for dangerous file-path patterns without allocations, used by legacy + /// (non-strict) mode. Mirrors every pattern handles so the + /// documented protections are actually applied. + /// + /// The file path to check. + /// The sanitization settings to apply. + /// True if dangerous patterns are present, false otherwise. + private static bool ContainsDangerousPathPatterns(string input, SanitizationSettings settings) + { + if (string.IsNullOrEmpty(input)) + { + return false; + } + + if (settings.EnableParentDirectoryTraversalCheck && + ContainsOrdinal(input, ParentDirectoryReference, StringComparison.OrdinalIgnoreCase)) + { + return true; + } + + if (ContainsOrdinal(input, DoubleForwardSlash, StringComparison.OrdinalIgnoreCase) || + ContainsOrdinal(input, DoubleBackslash, StringComparison.OrdinalIgnoreCase)) + { + return true; + } + + if (ContainsOrdinal(input, Colon, StringComparison.OrdinalIgnoreCase)) + { + return true; + } + + if (input.StartsWith(ForwardSlash, StringComparison.Ordinal) || input.StartsWith(SingleBackslash, StringComparison.Ordinal)) + { + return true; + } + + var hasParentRef = ContainsOrdinal(input, "../", StringComparison.OrdinalIgnoreCase) || + ContainsOrdinal(input, "..\\", StringComparison.OrdinalIgnoreCase); + + if (ContainsOrdinal(input, "./", StringComparison.OrdinalIgnoreCase) && !hasParentRef) + { + return true; + } + + if (ContainsOrdinal(input, ".\\", StringComparison.OrdinalIgnoreCase) && !hasParentRef) + { + return true; + } + + return false; + } + + /// + /// Sanitizes dangerous patterns in the file path using string replacement (legacy mode). + /// Prefer allowlist validation for new code. + /// + /// The input file path to sanitize. + /// The sanitization settings to apply. + /// The sanitized file path. + private static string SanitizeFilePathLegacy( + string input, + SanitizationSettings settings) + { + var sanitized = input; + + if (settings.EnableParentDirectoryTraversalCheck) + { + // These literals contain no alphabetic characters, so an ordinal (case-sensitive) + // replace is behaviorally identical to an ordinal-ignore-case one, and the 2-arg + // Replace(string, string) overload is available on netstandard2.0 (unlike the + // 3-arg StringComparison overload, which is netstandard2.1+). + sanitized = sanitized.Replace(ParentDirectoryReference, string.Empty); + } + + sanitized = sanitized.Replace(DoubleForwardSlash, ForwardSlash); + sanitized = sanitized.Replace(DoubleBackslash, SingleBackslash); + sanitized = DriveLetterRegex().Replace(sanitized, string.Empty); + sanitized = sanitized.TrimStart('/', '\\'); + sanitized = CurrentDirectoryReferenceRegex().Replace(sanitized, string.Empty); + sanitized = MultipleSlashesRegex().Replace(sanitized, ForwardSlash); + + return sanitized.Length > settings.MaxSanitizedStringLength + ? sanitized.Substring(0, settings.MaxSanitizedStringLength) + : sanitized; + } + + /// + /// Validates a file path using allowlist rules per CWE-22 recommendations: reject anything + /// suspicious rather than trying to "clean" it into something safe. + /// + /// The input file path to validate. + /// The sanitization settings to apply. + /// True if the path is valid, false otherwise. + private static bool ValidateFilePathAllowlist(string input, SanitizationSettings settings) + { + if (string.IsNullOrEmpty(input)) + { + return false; + } + + if (ContainsInvalidCharacters(input, settings)) + { + LogSecurityEvent("Invalid characters detected", input, settings); + return false; + } + + if (settings.ValidateWindowsReservedNames && IsWindowsReservedName(input)) + { + LogSecurityEvent("Windows reserved device name detected", input, settings); + return false; + } + + if (HasInvalidPathSegmentLengths(input, settings)) + { + LogSecurityEvent("Invalid path segment length", input, settings); + return false; + } + + if (settings.AllowedFileExtensions.Length > 0 && !IsValidFileExtension(input, settings)) + { + LogSecurityEvent("Invalid file extension", input, settings); + return false; + } + + if (settings.AllowedBaseDirectories.Length == 0 && ContainsDangerousTraversalPatterns(input)) + { + LogSecurityEvent("Dangerous traversal pattern detected", input, settings); + return false; + } + + if (settings.AllowedBaseDirectories.Length > 0 && !IsWithinAllowedDirectories(input, settings)) + { + LogSecurityEvent("Path outside allowed directories", input, settings); + return false; + } + + return true; + } + + /// + /// Checks for directory-traversal patterns, including common URL-encoded variants + /// (%2e%2e%2f and similar) that a naive string check would miss. + /// + /// The input string to check. + /// True if dangerous traversal patterns are present, false otherwise. + private static bool ContainsDangerousTraversalPatterns(string input) + { + if (ContainsOrdinal(input, "../", StringComparison.OrdinalIgnoreCase) || + ContainsOrdinal(input, "..\\", StringComparison.OrdinalIgnoreCase)) + { + return true; + } + + if (ContainsOrdinal(input, "%2e%2e%2f", StringComparison.OrdinalIgnoreCase) || + ContainsOrdinal(input, "%2e%2e%5c", StringComparison.OrdinalIgnoreCase) || + ContainsOrdinal(input, "..%2f", StringComparison.OrdinalIgnoreCase) || + ContainsOrdinal(input, "..%5c", StringComparison.OrdinalIgnoreCase)) + { + return true; + } + + if (input.StartsWith(ForwardSlash, StringComparison.Ordinal) || input.StartsWith(SingleBackslash, StringComparison.Ordinal)) + { + return true; + } + + if (input.Length >= 2 && input[1] == ':' && char.IsLetter(input[0])) + { + return true; + } + + return false; + } + + /// + /// Checks if the input contains invalid characters based on settings: control characters, + /// disallowed Unicode (when is false), + /// and characters invalid in Windows file names. + /// + /// The input string to check. + /// The sanitization settings. + /// True if invalid characters are present, false otherwise. + private static bool ContainsInvalidCharacters(string input, SanitizationSettings settings) + { + foreach (var c in input) + { + if (IsControlCharacter(c, settings)) + { + return true; + } + + if (!settings.AllowUnicodeCharacters && c > 127) + { + return true; + } + + if (IsInvalidPathCharacter(c)) + { + return true; + } + } + + return false; + } + + /// + /// Determines if a character is invalid in Windows file names. + /// + /// The character to check. + /// True if the character is invalid, false otherwise. + private static bool IsInvalidPathCharacter(char c) + { + return c is '<' or '>' or ':' or '"' or '|' or '?' or '*'; + } + + /// + /// Checks if the path contains a Windows reserved device name (e.g. CON, PRN, + /// COM1) in any path segment. + /// + /// The input path to check. + /// True if a reserved name is found, false otherwise. + private static bool IsWindowsReservedName(string input) + { + var normalizedPath = input.Replace('\\', Path.DirectorySeparatorChar).Replace('/', Path.DirectorySeparatorChar); + var pathSegments = normalizedPath.Split(new[] { Path.DirectorySeparatorChar }, StringSplitOptions.RemoveEmptyEntries); + + foreach (var segment in pathSegments) + { + var segmentName = Path.GetFileNameWithoutExtension(segment); + + if (string.IsNullOrEmpty(segmentName)) + { + continue; + } + + var upperSegmentName = segmentName.ToUpperInvariant(); + + foreach (var reservedName in _windowsReservedNames) + { + if (upperSegmentName == reservedName) + { + return true; + } + } + } + + return false; + } + + /// + /// Checks if any path segment exceeds . + /// + /// The input path to check. + /// The sanitization settings. + /// True if an invalid segment length is found, false otherwise. + private static bool HasInvalidPathSegmentLengths(string input, SanitizationSettings settings) + { + var segments = input.Split(_pathSeparators, StringSplitOptions.RemoveEmptyEntries); + + foreach (var segment in segments) + { + if (segment.Length > settings.MaxPathSegmentLength) + { + return true; + } + } + + return false; + } + + /// + /// Validates the file extension against . + /// + /// The input path to check. + /// The sanitization settings. + /// True if the extension is allowed, false otherwise. + private static bool IsValidFileExtension(string input, SanitizationSettings settings) + { + var extension = Path.GetExtension(input); + + if (string.IsNullOrEmpty(extension)) + { + return false; + } + + var extensionWithoutDot = extension.Substring(1); + + foreach (var allowedExtension in settings.AllowedFileExtensions) + { + if (extensionWithoutDot.Equals(allowedExtension, StringComparison.OrdinalIgnoreCase)) + { + return true; + } + } + + return false; + } + + /// + /// Validates that the resolved path stays within , + /// using canonical-path resolution per CWE-22 recommendations. + /// + /// The input path to check. + /// The sanitization settings. + /// True if the path is within allowed directories, false otherwise. + private static bool IsWithinAllowedDirectories(string input, SanitizationSettings settings) + { + if (settings.AllowedBaseDirectories.Length == 0) + { + return true; + } + + try + { + if (!Path.IsPathRooted(input)) + { + if (ContainsDangerousTraversalPatterns(input)) + { + return false; + } + + foreach (var allowedDirectory in settings.AllowedBaseDirectories) + { + var combinedPath = Path.Combine(allowedDirectory, input); + var fullPath = Path.GetFullPath(combinedPath); + var allowedFullPath = Path.GetFullPath(allowedDirectory); + + if (IsPathWithinDirectory(fullPath, allowedFullPath)) + { + return true; + } + } + + return false; + } + + var absolutePath = Path.GetFullPath(input); + + foreach (var allowedDirectory in settings.AllowedBaseDirectories) + { + var allowedFullPath = Path.GetFullPath(allowedDirectory); + + if (IsPathWithinDirectory(absolutePath, allowedFullPath)) + { + return true; + } + } + + return false; + } + catch + { + // If path resolution fails (e.g. invalid characters the OS rejects), treat as invalid. + return false; + } + } + + /// + /// Determines whether is equal to, or nested within, + /// . Both paths must already be canonicalized via + /// . Implemented via prefix comparison rather than + /// Path.GetRelativePath so the check compiles identically on netstandard2.0 + /// (which lacks that API) and net8.0. + /// + /// The canonicalized candidate path. + /// The canonicalized allowed base directory. + /// True if the candidate path is the allowed directory or a descendant of it. + private static bool IsPathWithinDirectory(string candidateFullPath, string allowedFullPath) + { + var trimmedAllowed = allowedFullPath.TrimEnd(Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar); + + if (string.Equals(candidateFullPath, trimmedAllowed, StringComparison.OrdinalIgnoreCase)) + { + return true; + } + + var allowedWithSeparator = trimmedAllowed + Path.DirectorySeparatorChar; + return candidateFullPath.StartsWith(allowedWithSeparator, StringComparison.OrdinalIgnoreCase); + } +} diff --git a/src/Features/PowerCSharp.Feature.Sanitization.Abstractions/SanitizationEngine.LogInjection.cs b/src/Features/PowerCSharp.Feature.Sanitization.Abstractions/SanitizationEngine.LogInjection.cs new file mode 100644 index 0000000..bed933b --- /dev/null +++ b/src/Features/PowerCSharp.Feature.Sanitization.Abstractions/SanitizationEngine.LogInjection.cs @@ -0,0 +1,266 @@ +using System.Diagnostics; +using System.Net; +using System.Text; +using System.Text.Json; +using PowerCSharp.Feature.Sanitization.Abstractions.Enums; + +namespace PowerCSharp.Feature.Sanitization.Abstractions; + +/// +/// Log-injection (CWE-117) sanitization: removes or encodes control characters so untrusted input +/// cannot forge additional log lines/fields when written to a log sink. +/// +public static partial class SanitizationEngine +{ + private const char SpaceReplacement = ' '; + private const char TabCharacter = '\t'; + + /// + /// Sanitizes a string to prevent log injection attacks by removing or replacing control characters. + /// This method is optimized for performance and never throws exceptions. + /// Implements CWE-117 remediation strategies (removal, replacement, and content-preserving encodings). + /// + /// The input string to sanitize. Can be null. + /// Optional sanitization settings. If null, the configured or default settings are used. + /// A containing the sanitized string and operation details. + public static SanitizationResult SanitizeForLogInjection( + string? input, + SanitizationSettings? settings = null) + { + var startTime = Stopwatch.StartNew(); + var effectiveSettings = settings ?? GetCurrentSettings(); + + try + { + if (string.IsNullOrEmpty(input)) + { + return SanitizationResult.Unchanged(input ?? string.Empty, SanitizationType.LogInjection); + } + + // See the analogous comment in SanitizationEngine.FilePath.cs: shadow with a value the + // compiler can narrow consistently across both target frameworks. + var value = input!; + + if (!effectiveSettings.EnableLogSanitization) + { + return SanitizationResult.Unchanged(value, SanitizationType.LogInjection); + } + + // Fast path: skip processing entirely when no control characters are present. + if (!ContainsControlCharacters(value, effectiveSettings)) + { + return SanitizationResult.Unchanged(value, SanitizationType.LogInjection); + } + + var sanitized = SanitizeControlCharactersWithStrategy(value, effectiveSettings); + var wasModified = !string.Equals(value, sanitized, StringComparison.Ordinal); + + startTime.Stop(); + UpdatePerformanceMetrics(startTime.Elapsed, effectiveSettings); + + return wasModified + ? SanitizationResult.Modified(sanitized, SanitizationType.LogInjection, startTime.Elapsed) + : SanitizationResult.Unchanged(sanitized, SanitizationType.LogInjection); + } + catch + { + // Fail-safe: never throw from a sanitization call site; return the original input. + startTime.Stop(); + LogSanitizationFailure(input, effectiveSettings, "SanitizationFailed"); + + return SanitizationResult.Unchanged(input ?? string.Empty, SanitizationType.LogInjection); + } + } + + /// + /// Fast check for control characters without allocations. Honors + /// . + /// + /// The string to check. + /// The sanitization settings to apply. + /// True if control characters are present, false otherwise. + private static bool ContainsControlCharacters(string input, SanitizationSettings settings) + { + if (string.IsNullOrEmpty(input)) + { + return false; + } + + foreach (var c in input) + { + if (c == TabCharacter && settings.PreserveTabCharacters) + { + continue; + } + + if (IsControlCharacter(c, settings)) + { + return true; + } + } + + return false; + } + + /// + /// Determines if a character should be sanitized, honoring + /// . + /// + /// The character to check. + /// The sanitization settings. + /// True if the character should be sanitized, false otherwise. + private static bool ShouldSanitizeCharacter(char c, SanitizationSettings settings) + { + if (c == TabCharacter && settings.PreserveTabCharacters) + { + return false; + } + + return IsControlCharacter(c, settings); + } + + /// + /// Sanitizes control characters using the strategy configured on + /// . + /// + /// The input string to sanitize. + /// The sanitization settings to apply. + /// The sanitized string. + private static string SanitizeControlCharactersWithStrategy( + string input, + SanitizationSettings settings) + { + if (string.IsNullOrEmpty(input)) + { + return input ?? string.Empty; + } + + return settings.LogSanitizationStrategy switch + { + SanitizationStrategy.Remove => SanitizeControlCharactersByRemoval(input, settings), + SanitizationStrategy.ReplaceWithSpace => SanitizeControlCharactersByReplacement(input, settings), + SanitizationStrategy.HtmlEncode => SanitizeControlCharactersByHtmlEncoding(input, settings), + SanitizationStrategy.UrlEncode => SanitizeControlCharactersByUrlEncoding(input, settings), + SanitizationStrategy.JsonEncode => SanitizeControlCharactersByJsonEncoding(input, settings), + _ => SanitizeControlCharactersByRemoval(input, settings) + }; + } + + /// + /// Sanitizes control characters by removing them completely, then applies a well-known, + /// static-analysis-recognized HTML-encoding pass so automated scanners can trace the + /// sanitization path for CWE-117 compliance. + /// + /// The input string to sanitize. + /// The sanitization settings to apply. + /// The sanitized string. + private static string SanitizeControlCharactersByRemoval( + string input, + SanitizationSettings settings) + { + var output = new StringBuilder(input.Length); + + foreach (var c in input) + { + if (!ShouldSanitizeCharacter(c, settings)) + { + output.Append(c); + } + } + + var cleaned = output.ToString(); + var encoded = WebUtility.HtmlEncode(cleaned); + + return ApplyLengthLimit(encoded, settings); + } + + /// + /// Sanitizes control characters by replacing them with spaces, then applies the same + /// static-analysis-recognized HTML-encoding pass as . + /// + /// The input string to sanitize. + /// The sanitization settings to apply. + /// The sanitized string. + private static string SanitizeControlCharactersByReplacement( + string input, + SanitizationSettings settings) + { + var output = new StringBuilder(input.Length); + + foreach (var c in input) + { + output.Append(ShouldSanitizeCharacter(c, settings) ? SpaceReplacement : c); + } + + var cleaned = output.ToString(); + var encoded = WebUtility.HtmlEncode(cleaned); + + return ApplyLengthLimit(encoded, settings); + } + + /// + /// Sanitizes control characters by HTML-encoding the cleaned string. Addresses both CWE-117 + /// and potential CWE-79 (XSS) in log viewers that render log content as HTML. + /// + /// The input string to sanitize. + /// The sanitization settings to apply. + /// The sanitized string. + private static string SanitizeControlCharactersByHtmlEncoding( + string input, + SanitizationSettings settings) + { + var cleaned = SanitizeControlCharactersByRemoval(input, settings); + var encoded = WebUtility.HtmlEncode(cleaned); + return ApplyLengthLimit(encoded, settings); + } + + /// + /// Sanitizes control characters by URL-encoding the cleaned string. + /// + /// The input string to sanitize. + /// The sanitization settings to apply. + /// The sanitized string. + private static string SanitizeControlCharactersByUrlEncoding( + string input, + SanitizationSettings settings) + { + var cleaned = SanitizeControlCharactersByRemoval(input, settings); + var encoded = WebUtility.UrlEncode(cleaned); + return ApplyLengthLimit(encoded ?? cleaned, settings); + } + + /// + /// Sanitizes control characters by JSON-encoding the cleaned string (escape sequences only, + /// with the surrounding quotes that adds stripped back off). + /// + /// The input string to sanitize. + /// The sanitization settings to apply. + /// The sanitized string. + private static string SanitizeControlCharactersByJsonEncoding( + string input, + SanitizationSettings settings) + { + var cleaned = SanitizeControlCharactersByRemoval(input, settings); + var encoded = JsonSerializer.Serialize(cleaned); + + if (encoded.Length >= 2 && encoded[0] == '"' && encoded[encoded.Length - 1] == '"') + { + encoded = encoded.Substring(1, encoded.Length - 2); + } + + return ApplyLengthLimit(encoded, settings); + } + + /// + /// Applies to the sanitized string. + /// + /// The input string to limit. + /// The sanitization settings. + /// The length-limited string. + private static string ApplyLengthLimit(string input, SanitizationSettings settings) + { + return input.Length > settings.MaxSanitizedStringLength + ? input.Substring(0, settings.MaxSanitizedStringLength) + : input; + } +} diff --git a/src/Features/PowerCSharp.Feature.Sanitization.Abstractions/SanitizationEngine.RegexInjection.cs b/src/Features/PowerCSharp.Feature.Sanitization.Abstractions/SanitizationEngine.RegexInjection.cs new file mode 100644 index 0000000..4a0ac70 --- /dev/null +++ b/src/Features/PowerCSharp.Feature.Sanitization.Abstractions/SanitizationEngine.RegexInjection.cs @@ -0,0 +1,373 @@ +using System.Diagnostics; +using System.Text.RegularExpressions; +using System.Threading; +using PowerCSharp.Feature.Sanitization.Abstractions.Enums; + +namespace PowerCSharp.Feature.Sanitization.Abstractions; + +/// +/// Regex-injection / ReDoS (CWE-400/CWE-730) sanitization: validates that a regex pattern is safe +/// to compile and execute before it is used against untrusted input. +/// +public static partial class SanitizationEngine +{ + /// + /// Internal validation outcome for . A plain struct + /// (not a record struct) to avoid any dependency on init-accessor support, which the + /// netstandard2.0 target of this package cannot rely on being present. + /// + private readonly struct RegexValidationResult + { + public RegexValidationResult(bool isValid, string reason) + { + IsValid = isValid; + Reason = reason; + } + + public bool IsValid { get; } + + public string Reason { get; } + } + + /// + /// Validates a regex pattern to prevent Regular Expression Denial of Service (ReDoS) attacks. + /// Checks pattern length, compilation safety, complexity, and known catastrophic-backtracking + /// shapes (nested quantifiers) before the pattern is ever used against untrusted input. + /// + /// The regex pattern to validate. Can be null. + /// Optional sanitization settings. If null, the configured or default settings are used. + /// A containing the validated pattern and operation details. + public static SanitizationResult SanitizeForRegexInjection( + string? pattern, + SanitizationSettings? settings = null) + { + var startTime = Stopwatch.StartNew(); + var effectiveSettings = settings ?? GetCurrentSettings(); + + try + { + if (string.IsNullOrEmpty(pattern)) + { + return SanitizationResult.Unchanged(pattern ?? string.Empty, SanitizationType.RegexInjection); + } + + // See the analogous comment in SanitizationEngine.FilePath.cs: shadow with a value the + // compiler can narrow consistently across both target frameworks. + var value = pattern!; + + if (!effectiveSettings.EnableRegexSanitization) + { + return SanitizationResult.Unchanged(value, SanitizationType.RegexInjection); + } + + if (value.Length > effectiveSettings.MaxRegexPatternLength) + { + startTime.Stop(); + UpdatePerformanceMetrics(startTime.Elapsed, effectiveSettings); + LogSecurityEvent("Regex pattern length exceeded", $"Regex pattern rejected: length {value.Length} exceeds maximum {effectiveSettings.MaxRegexPatternLength}", effectiveSettings); + return SanitizationResult.Rejected(SanitizationType.RegexInjection, startTime.Elapsed); + } + + var validationResult = ValidateRegexPatternSafety(value, effectiveSettings); + + startTime.Stop(); + UpdatePerformanceMetrics(startTime.Elapsed, effectiveSettings); + + if (!validationResult.IsValid) + { + LogSecurityEvent("Regex pattern validation failed", $"Regex pattern rejected: {validationResult.Reason}", effectiveSettings); + return SanitizationResult.Rejected(SanitizationType.RegexInjection, startTime.Elapsed); + } + + return SanitizationResult.Unchanged(value, SanitizationType.RegexInjection); + } + catch + { + startTime.Stop(); + UpdatePerformanceMetrics(startTime.Elapsed, effectiveSettings); + LogSanitizationFailure(pattern, effectiveSettings, "RegexSanitizationFailed"); + return SanitizationResult.Rejected(SanitizationType.RegexInjection, startTime.Elapsed); + } + } + + /// + /// Runs every configured safety check against a regex pattern in order, short-circuiting on + /// the first violation. + /// + /// The regex pattern to validate. + /// The sanitization settings to apply. + /// A validation result indicating whether the pattern is safe. + private static RegexValidationResult ValidateRegexPatternSafety(string pattern, SanitizationSettings settings) + { + if (!ValidateRegexSyntaxWithTimeout(pattern, settings.MaxRegexValidationTimeout)) + { + return new RegexValidationResult(false, "Pattern compilation timeout or syntax error"); + } + + var complexityScore = CalculateRegexComplexityScore(pattern); + if (complexityScore > settings.MaxRegexComplexityScore) + { + return new RegexValidationResult(false, $"Complexity score {complexityScore} exceeds maximum {settings.MaxRegexComplexityScore}"); + } + + if (!settings.AllowUnicodeCategories && ContainsUnicodeCategories(pattern)) + { + return new RegexValidationResult(false, "Unicode character categories are not allowed"); + } + + if (!settings.AllowQuantifiers && ContainsQuantifiers(pattern)) + { + return new RegexValidationResult(false, "Quantifiers are not allowed"); + } + + if (!settings.AllowNestedQuantifiers && ContainsNestedQuantifiers(pattern)) + { + return new RegexValidationResult(false, "Nested quantifiers detected (high ReDoS risk)"); + } + + if (!settings.AllowBackreferences && ContainsBackreferences(pattern)) + { + return new RegexValidationResult(false, "Backreferences are not allowed"); + } + + if (!settings.AllowLookarounds && ContainsLookarounds(pattern)) + { + return new RegexValidationResult(false, "Lookaround assertions are not allowed"); + } + + return new RegexValidationResult(true, "Pattern is safe"); + } + + /// + /// Validates regex syntax with a timeout, so an attacker-supplied pattern cannot hang the + /// validation step itself. + /// + /// The regex pattern to validate. + /// The maximum time to allow for compilation. + /// True if the pattern compiles successfully within the timeout. + private static bool ValidateRegexSyntaxWithTimeout(string pattern, TimeSpan timeout) + { + try + { + var cts = new CancellationTokenSource(timeout); + var task = Task.Run(() => + { + _ = new Regex(pattern, RegexOptions.Compiled); + return true; + }, cts.Token); + + return task.Wait(timeout) && task.Result; + } + catch + { + return false; + } + } + + /// + /// Calculates a complexity score for a regex pattern based on nesting depth, quantifier count, + /// group count, and character-class/escape usage. Higher scores indicate patterns more likely + /// to cause catastrophic backtracking or excessive compilation cost. + /// + /// The regex pattern to analyze. + /// A complexity score (higher = more complex), capped at 1000. + private static int CalculateRegexComplexityScore(string pattern) + { + var score = 0; + var depth = 0; + var quantifierCount = 0; + var groupCount = 0; + + foreach (var c in pattern) + { + switch (c) + { + case '(': + depth++; + groupCount++; + score += depth * 2; + break; + case ')': + depth--; + break; + case '*': + case '+': + case '?': + quantifierCount++; + score += 5; + break; + case '{': + score += 10; + break; + case '[': + score += 3; + break; + case '\\': + score += 2; + break; + } + } + + if (quantifierCount > 5) + { + score += quantifierCount * 3; + } + + if (groupCount > 10) + { + score += groupCount * 2; + } + + return Math.Min(score, 1000); + } + + /// + /// Checks if a pattern contains Unicode character categories (\p{...} / \P{...}). + /// + /// The regex pattern to check. + /// True if Unicode categories are found. + private static bool ContainsUnicodeCategories(string pattern) + { + return ContainsOrdinal(pattern, @"\p{", StringComparison.Ordinal) || ContainsOrdinal(pattern, @"\P{", StringComparison.Ordinal); + } + + /// + /// Checks if a pattern contains quantifiers (*, +, ?, or {n,m}). + /// + /// The regex pattern to check. + /// True if quantifiers are found. + private static bool ContainsQuantifiers(string pattern) + { + foreach (var c in pattern) + { + if (c is '*' or '+' or '?') + { + return true; + } + } + + return pattern.IndexOf('{') >= 0 && pattern.IndexOf('}') >= 0; + } + + /// + /// Checks if a pattern contains nested quantifiers (e.g. (a+)+, (a*)*) — the + /// classic shape behind catastrophic backtracking. + /// + /// The regex pattern to check. + /// True if nested quantifiers are found. + private static bool ContainsNestedQuantifiers(string pattern) + { + const string groupPattern = @"\([^)]*[*+?][^)]*\)[*+?]"; + + try + { + return Regex.IsMatch(pattern, groupPattern); + } + catch + { + return ManualNestedQuantifierDetection(pattern); + } + } + + /// + /// Manual fallback for nested-quantifier detection when regex matching itself fails. + /// + /// The regex pattern to check. + /// True if nested quantifiers are found. + private static bool ManualNestedQuantifierDetection(string pattern) + { + var inGroup = false; + var groupHasQuantifier = false; + var i = 0; + + while (i < pattern.Length) + { + var c = pattern[i]; + + switch (c) + { + case '(': + inGroup = true; + groupHasQuantifier = false; + break; + case ')': + if (inGroup && groupHasQuantifier) + { + if (i + 1 < pattern.Length && IsQuantifier(pattern[i + 1])) + { + return true; + } + } + inGroup = false; + groupHasQuantifier = false; + break; + case '*': + case '+': + case '?': + if (inGroup) + { + groupHasQuantifier = true; + } + break; + case '{': + if (inGroup) + { + groupHasQuantifier = true; + var braceEnd = pattern.IndexOf('}', i); + if (braceEnd != -1) + { + i = braceEnd; + } + } + break; + } + + i++; + } + + return false; + } + + /// + /// Determines if a character is a regex quantifier. + /// + /// The character to check. + /// True if the character is a quantifier. + private static bool IsQuantifier(char c) + { + return c is '*' or '+' or '?'; + } + + /// + /// Checks if a pattern contains backreferences (\1, \k<name>). + /// + /// The regex pattern to check. + /// True if backreferences are found. + private static bool ContainsBackreferences(string pattern) + { + for (var i = 0; i < pattern.Length - 1; i++) + { + if (pattern[i] == '\\' && char.IsDigit(pattern[i + 1])) + { + return true; + } + } + + return ContainsOrdinal(pattern, @"\k<", StringComparison.Ordinal); + } + + /// + /// Checks if a pattern contains lookaround assertions ((?=, (?!, (?<=, (?<!). + /// + /// The regex pattern to check. + /// True if lookarounds are found. + private static bool ContainsLookarounds(string pattern) + { + return ContainsOrdinal(pattern, "?=", StringComparison.Ordinal) || + ContainsOrdinal(pattern, "?!", StringComparison.Ordinal) || + ContainsOrdinal(pattern, "?<=", StringComparison.Ordinal) || + ContainsOrdinal(pattern, "?", StringComparison.Ordinal); + } +} diff --git a/src/Features/PowerCSharp.Feature.Sanitization.Abstractions/SanitizationEngine.SensitiveData.cs b/src/Features/PowerCSharp.Feature.Sanitization.Abstractions/SanitizationEngine.SensitiveData.cs new file mode 100644 index 0000000..f44c4c1 --- /dev/null +++ b/src/Features/PowerCSharp.Feature.Sanitization.Abstractions/SanitizationEngine.SensitiveData.cs @@ -0,0 +1,363 @@ +using System.Diagnostics; +using System.Text.RegularExpressions; +using PowerCSharp.Feature.Sanitization.Abstractions.Enums; + +namespace PowerCSharp.Feature.Sanitization.Abstractions; + +/// +/// Sensitive-data (CWE-200) detection and masking: finds and redacts tokens, secrets, credentials, +/// URLs, and file-system paths that should not appear in logs or diagnostic output. +/// +public static partial class SanitizationEngine +{ +#if NET8_0_OR_GREATER + [GeneratedRegex(@"Bearer\s+[A-Za-z0-9+/=]{20,}", RegexOptions.IgnoreCase)] + private static partial Regex BearerTokenRegex(); + + [GeneratedRegex(@"sk-[A-Za-z0-9]{20,}")] + private static partial Regex StripeApiKeyRegex(); + + [GeneratedRegex(@"api_key\s*=\s*[A-Za-z0-9+/=]{16,}")] + private static partial Regex ApiKeyRegex(); + + [GeneratedRegex(@"token\s*=\s*[A-Za-z0-9+/=]{16,}")] + private static partial Regex TokenRegex(); + + [GeneratedRegex(@"secret\s*=\s*[A-Za-z0-9+/=]{16,}")] + private static partial Regex SecretRegex(); + + [GeneratedRegex(@"password\s*=\s*[^\s]{6,}")] + private static partial Regex PasswordRegex(); + + [GeneratedRegex(@"https?://[^\s,;\""\\]+")] + private static partial Regex UrlRegex(); + + [GeneratedRegex(@"[A-Za-z]:\\[^\s]+")] + private static partial Regex WindowsPathRegex(); + + [GeneratedRegex(@"\\\\[^\s]+")] + private static partial Regex UncPathRegex(); + + [GeneratedRegex(@"/(?:home|Users|var|etc|opt|srv|tmp|app)/\S+", RegexOptions.IgnoreCase)] + private static partial Regex UnixPathRegex(); + + [GeneratedRegex(@"~/[^\s]+")] + private static partial Regex HomePathRegex(); + + [GeneratedRegex(@"[A-Za-z0-9+/=]{16,}")] + private static partial Regex LongAlphanumericRegex(); + + [GeneratedRegex(@"(?:""?(?:password|pwd|pass|secret|token|key|apikey|api_key|auth|authorization|credential|creds|private|confidential)""?\s*[:=]\s*""?)([^"",}\s]+)""?", RegexOptions.IgnoreCase)] + private static partial Regex SensitiveKeyRegex(); + + [GeneratedRegex(@"(?:encryption|decrypt|cipher|hash|salt|iv|nonce)[\s:=]*([^,}\s]+)", RegexOptions.IgnoreCase)] + private static partial Regex EncryptionKeyRegex(); + + [GeneratedRegex(@"(?:admin|root|user|login|session)[\s:=]*([^,}\s]+)", RegexOptions.IgnoreCase)] + private static partial Regex AdminKeyRegex(); + + [GeneratedRegex(@"(?:bearer|jwt|oauth|access|refresh)[\s:=]*([^,}\s]+)", RegexOptions.IgnoreCase)] + private static partial Regex AuthTokenRegex(); + + [GeneratedRegex(@"(?:ip|range|cidr|subnet|network)[\s:=]*([^,}\s]+)", RegexOptions.IgnoreCase)] + private static partial Regex NetworkKeyRegex(); +#else + private static readonly Regex _bearerTokenRegex = new(@"Bearer\s+[A-Za-z0-9+/=]{20,}", RegexOptions.IgnoreCase | RegexOptions.Compiled); + private static Regex BearerTokenRegex() => _bearerTokenRegex; + + private static readonly Regex _stripeApiKeyRegex = new(@"sk-[A-Za-z0-9]{20,}", RegexOptions.Compiled); + private static Regex StripeApiKeyRegex() => _stripeApiKeyRegex; + + private static readonly Regex _apiKeyRegex = new(@"api_key\s*=\s*[A-Za-z0-9+/=]{16,}", RegexOptions.Compiled); + private static Regex ApiKeyRegex() => _apiKeyRegex; + + private static readonly Regex _tokenRegex = new(@"token\s*=\s*[A-Za-z0-9+/=]{16,}", RegexOptions.Compiled); + private static Regex TokenRegex() => _tokenRegex; + + private static readonly Regex _secretRegex = new(@"secret\s*=\s*[A-Za-z0-9+/=]{16,}", RegexOptions.Compiled); + private static Regex SecretRegex() => _secretRegex; + + private static readonly Regex _passwordRegex = new(@"password\s*=\s*[^\s]{6,}", RegexOptions.Compiled); + private static Regex PasswordRegex() => _passwordRegex; + + private static readonly Regex _urlRegex = new(@"https?://[^\s,;\""\\]+", RegexOptions.Compiled); + private static Regex UrlRegex() => _urlRegex; + + private static readonly Regex _windowsPathRegex = new(@"[A-Za-z]:\\[^\s]+", RegexOptions.Compiled); + private static Regex WindowsPathRegex() => _windowsPathRegex; + + private static readonly Regex _uncPathRegex = new(@"\\\\[^\s]+", RegexOptions.Compiled); + private static Regex UncPathRegex() => _uncPathRegex; + + private static readonly Regex _unixPathRegex = new(@"/(?:home|Users|var|etc|opt|srv|tmp|app)/\S+", RegexOptions.IgnoreCase | RegexOptions.Compiled); + private static Regex UnixPathRegex() => _unixPathRegex; + + private static readonly Regex _homePathRegex = new(@"~/[^\s]+", RegexOptions.Compiled); + private static Regex HomePathRegex() => _homePathRegex; + + private static readonly Regex _longAlphanumericRegex = new(@"[A-Za-z0-9+/=]{16,}", RegexOptions.Compiled); + private static Regex LongAlphanumericRegex() => _longAlphanumericRegex; + + private static readonly Regex _sensitiveKeyRegex = new(@"(?:""?(?:password|pwd|pass|secret|token|key|apikey|api_key|auth|authorization|credential|creds|private|confidential)""?\s*[:=]\s*""?)([^"",}\s]+)""?", RegexOptions.IgnoreCase | RegexOptions.Compiled); + private static Regex SensitiveKeyRegex() => _sensitiveKeyRegex; + + private static readonly Regex _encryptionKeyRegex = new(@"(?:encryption|decrypt|cipher|hash|salt|iv|nonce)[\s:=]*([^,}\s]+)", RegexOptions.IgnoreCase | RegexOptions.Compiled); + private static Regex EncryptionKeyRegex() => _encryptionKeyRegex; + + private static readonly Regex _adminKeyRegex = new(@"(?:admin|root|user|login|session)[\s:=]*([^,}\s]+)", RegexOptions.IgnoreCase | RegexOptions.Compiled); + private static Regex AdminKeyRegex() => _adminKeyRegex; + + private static readonly Regex _authTokenRegex = new(@"(?:bearer|jwt|oauth|access|refresh)[\s:=]*([^,}\s]+)", RegexOptions.IgnoreCase | RegexOptions.Compiled); + private static Regex AuthTokenRegex() => _authTokenRegex; + + private static readonly Regex _networkKeyRegex = new(@"(?:ip|range|cidr|subnet|network)[\s:=]*([^,}\s]+)", RegexOptions.IgnoreCase | RegexOptions.Compiled); + private static Regex NetworkKeyRegex() => _networkKeyRegex; +#endif + + /// + /// Detects and masks sensitive data in a string to prevent information disclosure. + /// This method is optimized for performance and never throws exceptions. + /// Implements CWE-200 remediation via pattern-based detection and masking. + /// + /// The input string to check for sensitive data. Can be null. + /// Optional sanitization settings. If null, the configured or default settings are used. + /// A containing the masked string and operation details. + public static SensitiveDataResult SanitizeForSensitiveData( + string? input, + SanitizationSettings? settings = null) + { + var startTime = Stopwatch.StartNew(); + var effectiveSettings = settings ?? GetCurrentSettings(); + + try + { + if (string.IsNullOrEmpty(input)) + { + return SensitiveDataResult.Unchanged(input ?? string.Empty); + } + + // See the analogous comment in SanitizationEngine.FilePath.cs: shadow with a value the + // compiler can narrow consistently across both target frameworks. + var value = input!; + + if (!effectiveSettings.EnableSensitiveDataDetection) + { + return SensitiveDataResult.Unchanged(value); + } + + if (!ContainsSensitiveDataPatterns(value, effectiveSettings)) + { + return SensitiveDataResult.Unchanged(value); + } + + var masked = MaskSensitiveData(value, effectiveSettings); + var wasModified = !string.Equals(value, masked, StringComparison.Ordinal); + + startTime.Stop(); + UpdatePerformanceMetrics(startTime.Elapsed, effectiveSettings); + + return wasModified + ? SensitiveDataResult.Modified(masked, startTime.Elapsed) + : SensitiveDataResult.Unchanged(masked); + } + catch + { + startTime.Stop(); + UpdatePerformanceMetrics(startTime.Elapsed, effectiveSettings); + return SensitiveDataResult.Unchanged(input ?? string.Empty); + } + } + + /// + /// Fast check for sensitive-data patterns without allocations, so the (allocating) masking + /// pass is only run when something actually needs masking. + /// + /// The string to check. + /// The sanitization settings to apply. + /// True if sensitive patterns are present, false otherwise. + private static bool ContainsSensitiveDataPatterns(string input, SanitizationSettings settings) + { + if (string.IsNullOrEmpty(input)) + { + return false; + } + + if (BearerTokenRegex().IsMatch(input) || + StripeApiKeyRegex().IsMatch(input) || + ApiKeyRegex().IsMatch(input) || + TokenRegex().IsMatch(input) || + SecretRegex().IsMatch(input) || + PasswordRegex().IsMatch(input)) + { + return true; + } + + if (SensitiveKeyRegex().IsMatch(input) || + EncryptionKeyRegex().IsMatch(input) || + AdminKeyRegex().IsMatch(input) || + AuthTokenRegex().IsMatch(input) || + NetworkKeyRegex().IsMatch(input)) + { + return true; + } + + if (UrlRegex().IsMatch(input)) + { + return true; + } + + if (WindowsPathRegex().IsMatch(input) || + UncPathRegex().IsMatch(input) || + UnixPathRegex().IsMatch(input) || + HomePathRegex().IsMatch(input)) + { + return true; + } + + if (settings.SensitiveDataDetectionStrictness >= SensitiveDataDetectionStrictness.Medium) + { + if (LongAlphanumericRegex().IsMatch(input)) + { + return true; + } + } + + return false; + } + + /// + /// Masks sensitive-data patterns in the input string using the configured mask character. + /// + /// The input string to mask. + /// The sanitization settings to apply. + /// The masked string. + private static string MaskSensitiveData(string input, SanitizationSettings settings) + { + if (string.IsNullOrEmpty(input)) + { + return input ?? string.Empty; + } + + var maskChar = settings.SensitiveDataMaskCharacter; + var result = input; + + result = MaskKeyBasedValues(result, maskChar); + + result = BearerTokenRegex().Replace(result, m => $"Bearer {m.Value.Substring(7).Mask(maskChar)}"); + result = StripeApiKeyRegex().Replace(result, m => $"sk-{m.Value.Substring(3).Mask(maskChar)}"); + result = ApiKeyRegex().Replace(result, m => $"api_key={ExtractValueAfterEquals(m.Value).Mask(maskChar)}"); + result = TokenRegex().Replace(result, m => $"token={ExtractValueAfterEquals(m.Value).Mask(maskChar)}"); + result = SecretRegex().Replace(result, m => $"secret={ExtractValueAfterEquals(m.Value).Mask(maskChar)}"); + result = PasswordRegex().Replace(result, m => $"password={ExtractValueAfterEquals(m.Value).Mask(maskChar)}"); + + result = UrlRegex().Replace(result, m => MaskValueIntelligently(m.Value, maskChar)); + + result = WindowsPathRegex().Replace(result, m => m.Value.Mask(maskChar)); + result = UncPathRegex().Replace(result, m => m.Value.Mask(maskChar)); + result = UnixPathRegex().Replace(result, m => m.Value.Mask(maskChar)); + result = HomePathRegex().Replace(result, m => m.Value.Mask(maskChar)); + + if (settings.SensitiveDataDetectionStrictness >= SensitiveDataDetectionStrictness.Medium) + { + result = LongAlphanumericRegex().Replace(result, m => m.Value.Mask(maskChar)); + } + + return result; + } + + /// + /// Masks sensitive values found via key-based heuristics (JSON key-value pairs and similar + /// key: value/key=value formats). + /// + /// The input string to process. + /// The character to use for masking. + /// The masked string. + private static string MaskKeyBasedValues(string input, char maskChar) + { + var result = input; + + result = SensitiveKeyRegex().Replace(result, m => + { + // SensitiveKeyRegex has exactly one capturing group (the value); the key/separator + // prefix is matched but intentionally not captured. Reconstruct it the same way the + // four handlers below do, rather than indexing a non-existent Groups[2] (which is + // always an empty, unsuccessful group and previously left the raw value unmasked). + var key = m.Value.Substring(0, m.Value.IndexOf(m.Groups[1].Value, StringComparison.Ordinal)); + var maskedValue = MaskValueIntelligently(m.Groups[1].Value, maskChar); + return $"{key}{maskedValue}"; + }); + + result = EncryptionKeyRegex().Replace(result, m => + { + var key = m.Value.Substring(0, m.Value.IndexOf(m.Groups[1].Value, StringComparison.Ordinal)); + var maskedValue = MaskValueIntelligently(m.Groups[1].Value, maskChar); + return $"{key}{maskedValue}"; + }); + + result = AdminKeyRegex().Replace(result, m => + { + var key = m.Value.Substring(0, m.Value.IndexOf(m.Groups[1].Value, StringComparison.Ordinal)); + var maskedValue = MaskValueIntelligently(m.Groups[1].Value, maskChar); + return $"{key}{maskedValue}"; + }); + + result = AuthTokenRegex().Replace(result, m => + { + var key = m.Value.Substring(0, m.Value.IndexOf(m.Groups[1].Value, StringComparison.Ordinal)); + var maskedValue = MaskValueIntelligently(m.Groups[1].Value, maskChar); + return $"{key}{maskedValue}"; + }); + + result = NetworkKeyRegex().Replace(result, m => + { + var key = m.Value.Substring(0, m.Value.IndexOf(m.Groups[1].Value, StringComparison.Ordinal)); + var maskedValue = MaskValueIntelligently(m.Groups[1].Value, maskChar); + return $"{key}{maskedValue}"; + }); + + return result; + } + + /// + /// Masks the middle portion of a value (keeping the first and last quarter visible) so masked + /// output still hints at shape/length without disclosing the sensitive content. + /// + /// The value to mask. + /// The character to use for masking. + /// The masked value. + private static string MaskValueIntelligently(string value, char maskChar) + { + if (string.IsNullOrEmpty(value)) + { + return value; + } + + var result = value.ToCharArray(); + var length = result.Length; + var maskStart = length / 4; + var maskEnd = length - (length / 4); + + for (var i = maskStart; i < maskEnd && i < length; i++) + { + result[i] = maskChar; + } + + return new string(result); + } + + /// + /// Extracts the value after the first = character. Avoids the allocation overhead of + /// Split().Last() and correctly handles values that themselves contain = + /// (common in Base64 tokens and signed URLs). + /// + /// The string containing a key=value pair. + /// The value after the first = character, or the original string if none is found. + private static string ExtractValueAfterEquals(string keyValuePair) + { + var equalsIndex = keyValuePair.IndexOf('='); + + return equalsIndex >= 0 && equalsIndex < keyValuePair.Length - 1 + ? keyValuePair.Substring(equalsIndex + 1) + : keyValuePair; + } +} diff --git a/src/Features/PowerCSharp.Feature.Sanitization.Abstractions/SanitizationEngine.cs b/src/Features/PowerCSharp.Feature.Sanitization.Abstractions/SanitizationEngine.cs new file mode 100644 index 0000000..116d507 --- /dev/null +++ b/src/Features/PowerCSharp.Feature.Sanitization.Abstractions/SanitizationEngine.cs @@ -0,0 +1,230 @@ +using PowerCSharp.Feature.Sanitization.Abstractions.Enums; + +namespace PowerCSharp.Feature.Sanitization.Abstractions; + +/// +/// High-performance sanitization engine covering log injection (CWE-117), file-path traversal +/// (CWE-22), sensitive-data exposure (CWE-200), and regex-injection/ReDoS (CWE-400/CWE-730). +/// Designed for hot-path scenarios: minimal allocations and fail-safe behavior (sanitization +/// methods never throw; validation methods signal rejection via their result type instead). +/// +/// +/// This partial class is split by concern across several files for maintainability: +/// +/// SanitizationEngine.cs (this file) — shared state, configuration wiring, performance tracking, and control-character classification shared by more than one concern. +/// SanitizationEngine.LogInjection.cs and its encoding strategies. +/// SanitizationEngine.FilePath.cs, , and allowlist path validation. +/// SanitizationEngine.SensitiveData.cs and masking heuristics. +/// SanitizationEngine.RegexInjection.cs and ReDoS complexity scoring. +/// +/// +public static partial class SanitizationEngine +{ + // Constants for control character ranges, shared by log-injection sanitization and + // file-path character validation. + private const char AsciiControlCharStart = '\x00'; + private const char AsciiControlCharEnd = '\x1F'; + private const char ExtendedControlCharStart = '\x7F'; + private const char ExtendedControlCharEnd = '\x9F'; + + // Configuration provider delegate - set by the hosting layer during startup. + private static Func? _settingsProvider; + + // Security event logging delegate - set by the hosting layer during startup. + private static Action? _securityEventLogger; + + // Performance tracking fields. + private static long _totalOperations; + private static long _totalProcessingTimeMs; + private static long _slowOperations; + private static readonly object _performanceLock = new(); + + // Default settings fallback (used when no provider has been configured). + private static readonly SanitizationSettings _defaultSettings = new(); + + /// + /// Sets the configuration provider for sanitization settings. + /// Call this once during application startup so every sanitization call reflects the + /// host's current configuration without each call site needing to pass settings explicitly. + /// + /// The function that provides current sanitization settings. + /// Optional action for logging security events. + public static void SetConfigurationProvider( + Func provider, + Action? securityLogger = null) + { + _settingsProvider = provider + ?? throw new ArgumentNullException(nameof(provider)); + + _securityEventLogger = securityLogger; + } + + /// + /// Sets the security event logger used for CWE-22/CWE-73-style path validation violations. + /// + /// The action invoked for each logged security event. + public static void SetSecurityEventLogger(Action securityLogger) + { + _securityEventLogger = securityLogger + ?? throw new ArgumentNullException(nameof(securityLogger)); + } + + /// + /// Gets performance statistics for the sanitization engine. Useful for monitoring and + /// optimization when is enabled. + /// + /// A dictionary containing performance metrics. + public static Dictionary GetPerformanceStats() + { + lock (_performanceLock) + { + var averageProcessingTimeMs = _totalOperations > 0 + ? (double)_totalProcessingTimeMs / _totalOperations + : 0.0; + + return new Dictionary + { + ["EngineInitialized"] = true, + ["TotalOperations"] = _totalOperations, + ["AverageProcessingTimeMs"] = Math.Round(averageProcessingTimeMs, 3), + ["SlowOperations"] = _slowOperations, + ["SlowOperationPercentage"] = _totalOperations > 0 + ? Math.Round((double)_slowOperations / _totalOperations * 100, 2) + : 0.0, + ["SupportedSanitizationTypes"] = new[] + { + SanitizationType.LogInjection.ToString(), + SanitizationType.FilePath.ToString(), + SanitizationType.SensitiveData.ToString(), + SanitizationType.RegexInjection.ToString() + }, + ["SupportedSanitizationStrategies"] = new[] + { + SanitizationStrategy.Remove.ToString(), + SanitizationStrategy.ReplaceWithSpace.ToString(), + SanitizationStrategy.HtmlEncode.ToString(), + SanitizationStrategy.UrlEncode.ToString(), + SanitizationStrategy.JsonEncode.ToString() + } + }; + } + } + + /// + /// Gets the current sanitization settings from the configuration provider, or the built-in + /// defaults when no provider has been configured via . + /// + /// Current sanitization settings. + private static SanitizationSettings GetCurrentSettings() + { + return _settingsProvider?.Invoke() + ?? _defaultSettings; + } + + /// + /// Determines if a character is a control character based on settings. Shared by log-injection + /// sanitization and file-path allowlist character validation. + /// + /// The character to check. + /// The sanitization settings. + /// True if the character should be treated as a control character, false otherwise. + private static bool IsControlCharacter(char c, SanitizationSettings settings) + { + if (c is >= AsciiControlCharStart and <= AsciiControlCharEnd) + { + return true; + } + + if (settings.SanitizeUnicodeControlChars && + c is >= ExtendedControlCharStart and <= ExtendedControlCharEnd) + { + return true; + } + + return false; + } + + /// + /// string.Contains(string, StringComparison) is not available on netstandard2.0 + /// (added in .NET Standard 2.1). This helper provides the same behavior via + /// , which is available on both target + /// frameworks, so call sites shared across TFMs don't need #if guards. + /// + /// The string to search. + /// The substring to search for. + /// The string comparison to use. + /// True if occurs within . + private static bool ContainsOrdinal(string value, string substring, StringComparison comparison) + => value.IndexOf(substring, comparison) >= 0; + + /// + /// Updates performance metrics for monitoring. No-op unless + /// is enabled. + /// + /// The time taken for the operation. + /// The sanitization settings. + private static void UpdatePerformanceMetrics(TimeSpan processingTime, SanitizationSettings settings) + { + if (!settings.EnablePerformanceMonitoring) + { + return; + } + + lock (_performanceLock) + { + _totalOperations++; + _totalProcessingTimeMs += (long)processingTime.TotalMilliseconds; + + if (processingTime.TotalMilliseconds > settings.SlowOperationThresholdMs) + { + _slowOperations++; + } + } + } + + /// + /// Logs sanitization failures for security monitoring. No-op unless + /// is enabled and a security logger + /// has been configured via or . + /// + /// The input that failed sanitization. + /// The sanitization settings. + /// The type of failure event. + private static void LogSanitizationFailure(string? input, SanitizationSettings settings, string eventType) + { + if (!settings.LogSanitizationFailures || _securityEventLogger == null) + { + return; + } + + var correlationId = string.IsNullOrEmpty(settings.CorrelationId) ? "" : $" [CorrelationId: {settings.CorrelationId}]"; + var inputPreview = string.IsNullOrEmpty(input) ? "" : input!.Length > 100 ? $"{input.Substring(0, 100)}..." : input; + + var message = $"CWE-117 Sanitization Failure: {eventType}{correlationId} - Input: '{inputPreview}'"; + _securityEventLogger(message); + } + + /// + /// Logs security events for CWE-22 file-path validation violations. No-op unless + /// is enabled and a security logger has + /// been configured. + /// + /// The type of security event. + /// The input that triggered the event. + /// The sanitization settings. + private static void LogSecurityEvent(string eventType, string input, SanitizationSettings settings) + { + if (!settings.LogSecurityEvents || _securityEventLogger == null) + { + return; + } + + // Sanitize the input first so the security log itself cannot be used for log injection (CWE-117). + var sanitizedInput = SanitizeForLogInjection(input, settings).SanitizedValue; + var inputPreview = sanitizedInput.Length > 100 ? $"{sanitizedInput.Substring(0, 100)}..." : sanitizedInput; + + var correlationId = string.IsNullOrEmpty(settings.CorrelationId) ? "" : $" [CorrelationId: {settings.CorrelationId}]"; + var message = $"CWE-22 Security Violation: {eventType}{correlationId} - Input: '{inputPreview}'"; + _securityEventLogger(message); + } +} diff --git a/src/Features/PowerCSharp.Feature.Sanitization.Abstractions/SanitizationExtensions.cs b/src/Features/PowerCSharp.Feature.Sanitization.Abstractions/SanitizationExtensions.cs new file mode 100644 index 0000000..070d349 --- /dev/null +++ b/src/Features/PowerCSharp.Feature.Sanitization.Abstractions/SanitizationExtensions.cs @@ -0,0 +1,156 @@ +namespace PowerCSharp.Feature.Sanitization.Abstractions; + +/// +/// String extension methods wrapping the static . These are the +/// primary call-site API: no dependency injection or feature-flag wiring is required to use them — +/// reference this package and call input.SanitizeForLog() from anywhere, including hot-path +/// logging call sites. +/// +public static class SanitizationExtensions +{ + /// + /// Sanitizes the string for safe logging by removing or encoding control characters. + /// Prevents log injection (CWE-117) from carriage returns, line feeds, tabs, null bytes, and + /// other control characters. + /// + /// The string to sanitize for logging. Can be null. + /// Optional sanitization settings. If null, the configured or default settings are used. + /// The sanitized string, safe for logging. Never null. + /// Fail-safe: never throws. If sanitization fails internally, the original string is returned. + public static string SanitizeForLog(this string? input, SanitizationSettings? settings = null) + => SanitizationEngine.SanitizeForLogInjection(input, settings).SanitizedValue; + + /// + /// Sanitizes the string for safe logging and returns detailed sanitization results (whether it + /// was modified, and how long the operation took). + /// + /// The string to sanitize for logging. Can be null. + /// Optional sanitization settings. If null, the configured or default settings are used. + /// A containing the sanitized string and operation details. + public static SanitizationResult SanitizeForLogWithDetails(this string? input, SanitizationSettings? settings = null) + => SanitizationEngine.SanitizeForLogInjection(input, settings); + + /// + /// Sanitizes the string for safe file-path usage, preventing directory traversal (CWE-22). + /// + /// The file-path string to sanitize. Can be null. + /// Optional sanitization settings. If null, the configured or default settings are used. + /// The sanitized file-path string. Never null. + /// Thrown when the input is rejected under strict validation. + /// + /// Sanitization failures never throw. In strict validation mode, however, a rejected path + /// throws deliberately — silently returning an empty + /// string for a rejected path would be a worse failure mode for callers that build a file-system + /// path from the result. Use to handle rejections + /// programmatically instead. + /// + public static string SanitizeForFilePath(this string? input, SanitizationSettings? settings = null) + { + var result = SanitizationEngine.SanitizeForFilePath(input, settings); + + if (result.IsRejected) + { + throw new InvalidOperationException( + $"File path '{input}' was rejected by sanitization validation. " + + "Use SanitizeForFilePathWithDetails() to handle rejections programmatically."); + } + + return result.SanitizedValue; + } + + /// + /// Sanitizes the string for safe file-path usage and returns detailed sanitization results, + /// including whether the input was rejected under strict validation. + /// + /// The file-path string to sanitize. Can be null. + /// Optional sanitization settings. If null, the configured or default settings are used. + /// A containing the validated file path and operation details. + public static SanitizationResult SanitizeForFilePathWithDetails(this string? input, SanitizationSettings? settings = null) + => SanitizationEngine.SanitizeForFilePath(input, settings); + + /// + /// Sanitizes the string for sensitive data by masking detected tokens, secrets, credentials, + /// URLs, and file-system paths (CWE-200). + /// + /// The string to check for sensitive data. Can be null. + /// Optional sanitization settings. If null, the configured or default settings are used. + /// The string with sensitive data masked. Never null. + public static string SanitizeForSensitiveData(this string? input, SanitizationSettings? settings = null) + => SanitizationEngine.SanitizeForSensitiveData(input, settings).SanitizedValue; + + /// + /// Sanitizes the string for sensitive data and returns detailed sanitization results. + /// + /// The string to check for sensitive data. Can be null. + /// Optional sanitization settings. If null, the configured or default settings are used. + /// A containing the masked string and operation details. + public static SensitiveDataResult SanitizeForSensitiveDataWithDetails(this string? input, SanitizationSettings? settings = null) + => SanitizationEngine.SanitizeForSensitiveData(input, settings); + + /// + /// Validates the string as a regex pattern safe from ReDoS (CWE-400/CWE-730), returning the + /// pattern unchanged when safe. + /// + /// The regex pattern to validate. Can be null. + /// Optional sanitization settings. If null, the configured or default settings are used. + /// The pattern, unchanged, if it passed validation. Never null. + public static string SanitizeForRegexInjection(this string? input, SanitizationSettings? settings = null) + => SanitizationEngine.SanitizeForRegexInjection(input, settings).SanitizedValue; + + /// + /// Validates the string as a regex pattern and returns detailed sanitization results, including + /// whether the pattern was rejected as unsafe. + /// + /// The regex pattern to validate. Can be null. + /// Optional sanitization settings. If null, the configured or default settings are used. + /// A containing the validated pattern and operation details. + public static SanitizationResult SanitizeForRegexInjectionWithDetails(this string? input, SanitizationSettings? settings = null) + => SanitizationEngine.SanitizeForRegexInjection(input, settings); + + /// + /// Masks the source string with the given mask character, keeping roughly a quarter of the + /// string visible on each side (1/visibility total length as a fixed visibility window). + /// + /// The value to mask. + /// The character to use for masking. + /// The masked value. + public static string Mask(this string value, char mask) + { + const int visibility = 4; + + if (string.IsNullOrEmpty(value)) + { + return value; + } + + var visibleCharLength = value.Length / visibility; + return Mask(value, visibleCharLength, mask); + } + + /// + /// Masks the source string with the given mask character, keeping + /// characters visible at the start and end of the value. + /// + /// The value to mask. + /// The number of characters to leave visible at the start and end. + /// The character to use for masking. + /// The masked value. + public static string Mask(this string value, int visibleCharLength, char mask) + { + if (string.IsNullOrEmpty(value)) + { + return value; + } + + if (value.Length <= visibleCharLength * 2) + { + return new string(mask, value.Length); + } + + var prefix = value.Substring(0, visibleCharLength); + var suffix = value.Substring(value.Length - visibleCharLength); + var maskedMiddle = new string(mask, value.Length - (visibleCharLength * 2)); + + return $"{prefix}{maskedMiddle}{suffix}"; + } +} diff --git a/src/Features/PowerCSharp.Feature.Sanitization.Abstractions/SanitizationResult.cs b/src/Features/PowerCSharp.Feature.Sanitization.Abstractions/SanitizationResult.cs new file mode 100644 index 0000000..320c1bc --- /dev/null +++ b/src/Features/PowerCSharp.Feature.Sanitization.Abstractions/SanitizationResult.cs @@ -0,0 +1,137 @@ +using PowerCSharp.Feature.Sanitization.Abstractions.Enums; + +namespace PowerCSharp.Feature.Sanitization.Abstractions; + +/// +/// Represents the result of a sanitization operation, providing details about the processing outcome. +/// This type is immutable and designed for high-performance scenarios with minimal allocations. +/// +/// +/// A plain class rather than a C# record: this package targets netstandard2.0, which lacks +/// the init-accessor support records rely on. Value equality is implemented manually instead, +/// matching the convention already used by PowerCSharp.Feature.Cache.Abstractions.CacheResult<T>. +/// +public sealed class SanitizationResult : IEquatable +{ + /// Gets the sanitized value after processing. Never null. + public string SanitizedValue { get; } + + /// Gets a value indicating whether the original value was modified during sanitization. + public bool WasModified { get; } + + /// Gets the type of sanitization that was applied. + public SanitizationType SanitizationType { get; } + + /// Gets the time taken to perform the sanitization operation. + public TimeSpan ProcessingTime { get; } + + /// Gets a value indicating whether the input was rejected due to validation failures (strict mode only). + public bool IsRejected { get; } + + /// + /// Initializes a new instance of . Prefer the + /// , , and factory methods. + /// + /// The sanitized value after processing. + /// Whether the original value was modified. + /// The type of sanitization that was applied. + /// The time taken to perform the sanitization operation. + /// Whether the input was rejected due to validation failures. + public SanitizationResult( + string sanitizedValue, + bool wasModified, + SanitizationType sanitizationType, + TimeSpan processingTime, + bool isRejected = false) + { + SanitizedValue = sanitizedValue ?? string.Empty; + WasModified = wasModified; + SanitizationType = sanitizationType; + ProcessingTime = processingTime; + IsRejected = isRejected; + } + + /// + /// Gets a sanitized result that represents no changes were made. Useful for fast-path + /// scenarios where sanitization is not needed. + /// + /// The original value that was not modified. + /// The type of sanitization that was checked. + /// A indicating no modifications were made. + public static SanitizationResult Unchanged(string originalValue, SanitizationType sanitizationType) + => new(originalValue ?? string.Empty, wasModified: false, sanitizationType, TimeSpan.Zero); + + /// + /// Gets a sanitized result that represents successful modification. + /// + /// The sanitized value after processing. + /// The type of sanitization that was applied. + /// The time taken to perform the sanitization. + /// A indicating successful modification. + public static SanitizationResult Modified( + string sanitizedValue, + SanitizationType sanitizationType, + TimeSpan processingTime) + => new(sanitizedValue ?? string.Empty, wasModified: true, sanitizationType, processingTime); + + /// + /// Gets a sanitized result that represents input rejection due to validation failures. + /// Used in strict validation mode when inputs fail allowlist validation. + /// + /// The type of sanitization that was applied. + /// The time taken to perform the validation. + /// A indicating the input was rejected. + public static SanitizationResult Rejected( + SanitizationType sanitizationType, + TimeSpan processingTime) + => new(string.Empty, wasModified: true, sanitizationType, processingTime, isRejected: true); + + /// + public override bool Equals(object? obj) => obj is SanitizationResult other && Equals(other); + + /// + public bool Equals(SanitizationResult? other) + { + if (other is null) + { + return false; + } + + return WasModified == other.WasModified && + SanitizationType == other.SanitizationType && + IsRejected == other.IsRejected && + ProcessingTime.Equals(other.ProcessingTime) && + string.Equals(SanitizedValue, other.SanitizedValue, StringComparison.Ordinal); + } + + /// + public override int GetHashCode() + { + var hash = 17; + hash = hash * 31 + SanitizedValue.GetHashCode(); + hash = hash * 31 + WasModified.GetHashCode(); + hash = hash * 31 + SanitizationType.GetHashCode(); + hash = hash * 31 + ProcessingTime.GetHashCode(); + hash = hash * 31 + IsRejected.GetHashCode(); + return hash; + } + + /// Equality operator. + public static bool operator ==(SanitizationResult? left, SanitizationResult? right) + { + if (ReferenceEquals(left, right)) + { + return true; + } + + if (left is null || right is null) + { + return false; + } + + return left.Equals(right); + } + + /// Inequality operator. + public static bool operator !=(SanitizationResult? left, SanitizationResult? right) => !(left == right); +} diff --git a/src/Features/PowerCSharp.Feature.Sanitization.Abstractions/SanitizationSettings.cs b/src/Features/PowerCSharp.Feature.Sanitization.Abstractions/SanitizationSettings.cs new file mode 100644 index 0000000..3b768a4 --- /dev/null +++ b/src/Features/PowerCSharp.Feature.Sanitization.Abstractions/SanitizationSettings.cs @@ -0,0 +1,236 @@ +using Microsoft.Extensions.Logging; +using PowerCSharp.Feature.Sanitization.Abstractions.Enums; + +namespace PowerCSharp.Feature.Sanitization.Abstractions; + +/// +/// Configuration settings for the sanitization engine. Controls the behavior of every +/// sanitization operation (log injection, file path, sensitive data, regex injection). +/// +public sealed class SanitizationSettings +{ + /// + /// Gets or sets a value indicating whether log sanitization is enabled. + /// When disabled, log messages will not be sanitized for injection attacks. + /// Default: true + /// + public bool EnableLogSanitization { get; set; } = true; + + /// + /// Gets or sets a value indicating whether sanitization failures should be logged. + /// Useful for monitoring and debugging sanitization issues. + /// Default: false (to prevent log spam and potential recursive issues) + /// + public bool LogSanitizationFailures { get; set; } = false; + + /// + /// Gets or sets the log level to use when logging sanitization failures. + /// Only used when is true. + /// Default: Warning + /// + public LogLevel SanitizationFailureLogLevel { get; set; } = LogLevel.Warning; + + /// + /// Gets or sets the maximum length for sanitized strings. + /// Strings longer than this will be truncated to prevent memory issues. + /// Default: 10000 characters + /// + public int MaxSanitizedStringLength { get; set; } = 10000; + + /// + /// Gets or sets a value indicating whether Unicode control characters should be sanitized. + /// Includes characters in ranges 0x00-0x1F, 0x7F-0x9F. + /// Default: true + /// + public bool SanitizeUnicodeControlChars { get; set; } = true; + + /// + /// Gets or sets a value indicating whether file path sanitization is enabled. + /// When enabled, file paths will be sanitized to prevent directory traversal attacks (CWE-73/CWE-22). + /// Default: true + /// + public bool EnableFilePathSanitization { get; set; } = true; + + /// + /// Gets or sets a value indicating whether parent directory traversal patterns should be checked and removed. + /// When enabled, patterns like "../" and "..\" will be removed from file paths. + /// This can be disabled in production environments where path traversal is handled by other security measures. + /// Default: true + /// + public bool EnableParentDirectoryTraversalCheck { get; set; } = true; + + /// + /// Gets or sets a value indicating whether to enable performance monitoring for sanitization operations. + /// When enabled, processing times are tracked and can be monitored. + /// Default: false (minimal overhead) + /// + public bool EnablePerformanceMonitoring { get; set; } = false; + + /// + /// Gets or sets the threshold in milliseconds for considering sanitization operations as slow. + /// Operations taking longer than this threshold may be logged for performance analysis. + /// Only used when is true. + /// Default: 1ms + /// + public double SlowOperationThresholdMs { get; set; } = 1.0; + + /// + /// Gets or sets the allowed base directories for file path validation. + /// When specified, file paths must resolve to within these directories. + /// Empty array means no base directory validation is performed. + /// Default: empty array (no base directory validation) + /// + public string[] AllowedBaseDirectories { get; set; } = Array.Empty(); + + /// + /// Gets or sets the allowed file extensions for file path validation. + /// When specified, file paths must end with one of these extensions (case-insensitive). + /// Empty array means no extension validation is performed. + /// Default: empty array (no extension validation) + /// + public string[] AllowedFileExtensions { get; set; } = Array.Empty(); + + /// + /// Gets or sets a value indicating whether to use strict validation mode for file paths. + /// When true, all validation rules are strictly enforced and violations result in rejection. + /// When false, some non-critical violations may be sanitized instead of rejected. + /// Default: true + /// + public bool UseStrictValidation { get; set; } = true; + + /// + /// Gets or sets the maximum allowed length for individual file path segments. + /// Path segments longer than this will be rejected to prevent potential issues. + /// Default: 255 characters (common max component length on Windows) + /// + public int MaxPathSegmentLength { get; set; } = 255; + + /// + /// Gets or sets a value indicating whether to log security events. + /// When enabled, blocked path traversal attempts and other security violations are logged. + /// Default: false (to prevent log spam and potential information disclosure) + /// + public bool LogSecurityEvents { get; set; } = false; + + /// + /// Gets or sets a value indicating whether to validate against Windows reserved device names. + /// When true, paths containing reserved names like CON, PRN, AUX, etc. will be rejected. + /// Default: true + /// + public bool ValidateWindowsReservedNames { get; set; } = true; + + /// + /// Gets or sets the sanitization strategy for handling control characters. + /// Determines how control characters are processed during log sanitization. + /// Default: Remove (aggressive sanitization for clean logs) + /// + public SanitizationStrategy LogSanitizationStrategy { get; set; } = SanitizationStrategy.Remove; + + /// + /// Gets or sets a value indicating whether to preserve tab characters. + /// Tab characters (\t) are control characters but are often safe in logging contexts. + /// Default: false (remove tabs for cleaner log output) + /// + public bool PreserveTabCharacters { get; set; } = false; + + /// + /// Gets or sets the correlation ID for security event logging. + /// When set, security events will include this correlation ID for traceability. + /// Default: null (no correlation ID) + /// + public string? CorrelationId { get; set; } = null; + + /// + /// Gets or sets a value indicating whether to allow Unicode characters in file paths. + /// When false, only ASCII characters are allowed to prevent Unicode-based attacks. + /// Default: true (allow Unicode for internationalization) + /// + public bool AllowUnicodeCharacters { get; set; } = true; + + /// + /// Gets or sets a value indicating whether sensitive data detection is enabled. + /// When enabled, sensitive data patterns will be detected and masked. + /// Default: true + /// + public bool EnableSensitiveDataDetection { get; set; } = true; + + /// + /// Gets or sets the character to use for masking detected sensitive data. + /// This character will replace detected sensitive patterns. + /// Default: '*' (asterisk) + /// + public char SensitiveDataMaskCharacter { get; set; } = '*'; + + /// + /// Gets or sets the detection strictness level for sensitive data. + /// Higher values result in more aggressive detection (more false positives). + /// Lower values result in more conservative detection (more false negatives). + /// Default: Medium + /// + public SensitiveDataDetectionStrictness SensitiveDataDetectionStrictness { get; set; } = SensitiveDataDetectionStrictness.Medium; + + // --- Regex Injection Sanitization Settings --- + + /// + /// Gets or sets a value indicating whether regex injection sanitization is enabled. + /// When enabled, regex patterns will be validated for safety before use. + /// Default: true + /// + public bool EnableRegexSanitization { get; set; } = true; + + /// + /// Gets or sets the maximum timeout for regex pattern validation. + /// Patterns that take longer than this to validate will be rejected. + /// Default: 5 seconds + /// + public TimeSpan MaxRegexValidationTimeout { get; set; } = TimeSpan.FromSeconds(5); + + /// + /// Gets or sets the maximum allowed length for regex patterns. + /// Patterns longer than this will be rejected to prevent complex attacks. + /// Default: 1000 characters + /// + public int MaxRegexPatternLength { get; set; } = 1000; + + /// + /// Gets or sets the maximum complexity score allowed for regex patterns. + /// Higher scores indicate more complex patterns that could cause performance issues. + /// Default: 100 + /// + public int MaxRegexComplexityScore { get; set; } = 100; + + /// + /// Gets or sets a value indicating whether to allow Unicode character categories in regex patterns. + /// When false, Unicode categories like \p{L} will be rejected. + /// Default: true (allow Unicode for internationalization) + /// + public bool AllowUnicodeCategories { get; set; } = true; + + /// + /// Gets or sets a value indicating whether to allow quantifiers in regex patterns. + /// When false, quantifiers like *, +, ?, {n,m} will be rejected. + /// Default: true (allow quantifiers for functionality) + /// + public bool AllowQuantifiers { get; set; } = true; + + /// + /// Gets or sets a value indicating whether to allow nested quantifiers in regex patterns. + /// Nested quantifiers are a common source of catastrophic backtracking. + /// Default: false (disallow nested quantifiers for safety) + /// + public bool AllowNestedQuantifiers { get; set; } = false; + + /// + /// Gets or sets a value indicating whether to allow backreferences in regex patterns. + /// Backreferences can lead to complex matching behavior. + /// Default: true (allow backreferences for advanced patterns) + /// + public bool AllowBackreferences { get; set; } = true; + + /// + /// Gets or sets a value indicating whether to allow lookaround assertions in regex patterns. + /// Lookarounds can increase pattern complexity significantly. + /// Default: true (allow lookarounds for advanced patterns) + /// + public bool AllowLookarounds { get; set; } = true; +} diff --git a/src/Features/PowerCSharp.Feature.Sanitization.Abstractions/SensitiveDataResult.cs b/src/Features/PowerCSharp.Feature.Sanitization.Abstractions/SensitiveDataResult.cs new file mode 100644 index 0000000..6a23f2a --- /dev/null +++ b/src/Features/PowerCSharp.Feature.Sanitization.Abstractions/SensitiveDataResult.cs @@ -0,0 +1,112 @@ +using PowerCSharp.Feature.Sanitization.Abstractions.Enums; + +namespace PowerCSharp.Feature.Sanitization.Abstractions; + +/// +/// Represents the result of a sensitive-data detection and masking operation. +/// This type is immutable and designed for high-performance scenarios with minimal allocations. +/// +/// +/// A plain class rather than a C# record, for the same netstandard2.0 compatibility reason +/// documented on . +/// +public sealed class SensitiveDataResult : IEquatable +{ + /// Gets the value after sensitive-data masking. Never null. + public string SanitizedValue { get; } + + /// Gets a value indicating whether sensitive data was found and the original value was modified. + public bool WasModified { get; } + + /// Gets the type of sanitization that was applied (always ). + public SanitizationType SanitizationType { get; } + + /// Gets the time taken to perform the sensitive-data detection and masking. + public TimeSpan ProcessingTime { get; } + + /// Gets a value indicating whether the input was rejected (always false for sensitive-data detection). + public bool IsRejected { get; } + + /// + /// Initializes a new instance of . Prefer the + /// and factory methods. + /// + /// The value after sensitive-data masking. + /// Whether sensitive data was found and masked. + /// The time taken to perform the detection and masking. + public SensitiveDataResult(string sanitizedValue, bool wasModified, TimeSpan processingTime) + { + SanitizedValue = sanitizedValue ?? string.Empty; + WasModified = wasModified; + SanitizationType = SanitizationType.SensitiveData; + ProcessingTime = processingTime; + IsRejected = false; + } + + /// + /// Gets a result that represents no sensitive data was found. Useful for fast-path scenarios + /// where sensitive-data detection is not needed. + /// + /// The original value that was not modified. + /// A indicating no sensitive data was found. + public static SensitiveDataResult Unchanged(string originalValue) + => new(originalValue ?? string.Empty, wasModified: false, TimeSpan.Zero); + + /// + /// Gets a result that represents sensitive data was found and masked. + /// + /// The value after masking sensitive data. + /// The time taken to perform the detection and masking. + /// A indicating sensitive data was found and masked. + public static SensitiveDataResult Modified(string sanitizedValue, TimeSpan processingTime) + => new(sanitizedValue ?? string.Empty, wasModified: true, processingTime); + + /// + public override bool Equals(object? obj) => obj is SensitiveDataResult other && Equals(other); + + /// + public bool Equals(SensitiveDataResult? other) + { + if (other is null) + { + return false; + } + + return WasModified == other.WasModified && + SanitizationType == other.SanitizationType && + IsRejected == other.IsRejected && + ProcessingTime.Equals(other.ProcessingTime) && + string.Equals(SanitizedValue, other.SanitizedValue, StringComparison.Ordinal); + } + + /// + public override int GetHashCode() + { + var hash = 17; + hash = hash * 31 + SanitizedValue.GetHashCode(); + hash = hash * 31 + WasModified.GetHashCode(); + hash = hash * 31 + SanitizationType.GetHashCode(); + hash = hash * 31 + ProcessingTime.GetHashCode(); + hash = hash * 31 + IsRejected.GetHashCode(); + return hash; + } + + /// Equality operator. + public static bool operator ==(SensitiveDataResult? left, SensitiveDataResult? right) + { + if (ReferenceEquals(left, right)) + { + return true; + } + + if (left is null || right is null) + { + return false; + } + + return left.Equals(right); + } + + /// Inequality operator. + public static bool operator !=(SensitiveDataResult? left, SensitiveDataResult? right) => !(left == right); +} diff --git a/src/Features/PowerCSharp.Feature.Sanitization/PowerCSharp.Feature.Sanitization.csproj b/src/Features/PowerCSharp.Feature.Sanitization/PowerCSharp.Feature.Sanitization.csproj new file mode 100644 index 0000000..3c380ea --- /dev/null +++ b/src/Features/PowerCSharp.Feature.Sanitization/PowerCSharp.Feature.Sanitization.csproj @@ -0,0 +1,26 @@ + + + + net8.0 + enable + enable + true + + + PowerCSharp.Feature.Sanitization + $(PowerCSharpFeatureSanitizationVersion) + PowerCSharp Sanitization Feature + Sanitization feature module, options, and DI/Features-Framework wiring for log-injection, file-path traversal, sensitive-data, and regex-injection sanitization. Pair with PowerCSharp.Feature.Sanitization.Abstractions. + csharp;dotnet;features;feature-flags;security;sanitization;clean-architecture + + + + + + + + + + + + diff --git a/src/Features/PowerCSharp.Feature.Sanitization/README.md b/src/Features/PowerCSharp.Feature.Sanitization/README.md new file mode 100644 index 0000000..69d2b6e --- /dev/null +++ b/src/Features/PowerCSharp.Feature.Sanitization/README.md @@ -0,0 +1,70 @@ +# PowerCSharp.Feature.Sanitization + +![PowerCSharp Banner](https://raw.githubusercontent.com/marioarce/PowerCSharp/0191ee12092c28ccf5a578e59977583117a3ff00/docs/images/PowerCSharp_Banner.png) + +Sanitization feature module, options, and DI/ASP.NET Core wiring for the PowerCSharp Sanitization feature. + +Pair this package with `PowerCSharp.Feature.Sanitization.Abstractions` (engine + contracts + NoOp). Unlike the Cache feature family, Sanitization has no swappable-backend provider package — this module registers the concrete implementation directly. + +## Contents + +- **`SanitizationFeatureOptions`** — options bound from `PowerFeatures:Sanitization`, mirroring the full `SanitizationSettings` surface. +- **`SanitizationFeatureModule`** — auto-discoverable module; registers the real service when enabled, `NoOpSanitizationService` when disabled. +- **`SanitizationService`** — the DI-facing `ISanitizationService` implementation, delegating to the engine. +- **`SanitizationSettingsProvider`** — bridges bound options into `SanitizationSettings` for the engine. +- **`SanitizationFeatureExtensions.AddSanitizationFeature`** — explicit registration extension; registers the real service directly (no NoOp floor, since there is no separate provider package to defer to). +- **`SanitizationEngineServiceProviderExtensions.ConfigureSanitizationEngine`** — wires the DI-resolved settings provider into the static `SanitizationEngine`, so extension-method call sites (`input.SanitizeForLog()`) also reflect host configuration. + +## Namespaces + +- `PowerCSharp.Feature.Sanitization` — options, module, service, and extension methods. + +> The engine, contracts, and NoOp implementation live in `PowerCSharp.Feature.Sanitization.Abstractions`. + +## Usage + +### Auto-discovery (ASP.NET Core) + +```csharp +using PowerCSharp.Feature.Sanitization; + +builder.Services.AddPowerFeatures(builder.Configuration, o => + o.ScanAssemblies(typeof(SanitizationFeatureModule).Assembly)); + +var app = builder.Build(); +app.UsePowerFeatures(); // also wires the static engine via ConfigurePipeline +``` + +### Explicit registration + +```csharp +using PowerCSharp.Feature.Sanitization; + +builder.Services.AddSanitizationFeature(builder.Configuration); + +var app = builder.Build(); +app.Services.ConfigureSanitizationEngine(); // wire the static engine explicitly +``` + +```json +{ + "PowerFeatures": { + "Sanitization": { + "Enabled": true, + "EnableLogSanitization": true, + "EnableFilePathSanitization": true, + "EnableSensitiveDataDetection": true, + "EnableRegexSanitization": true + } + } +} +``` + +Then resolve `ISanitizationService` from DI, or call the extension methods in `PowerCSharp.Feature.Sanitization.Abstractions` directly — both reflect the same configuration once `ConfigureSanitizationEngine` has run. + +## Details + +- **Package ID:** `PowerCSharp.Feature.Sanitization` +- **Depends on:** `PowerCSharp.Features.Abstractions` + `PowerCSharp.Feature.Sanitization.Abstractions` +- **Target framework:** `net8.0` (requires ASP.NET Core host) +- See: `docs/PowerCSharp.Features.Authoring-Guide.md`, `docs/PowerCSharp.Feature.Sanitization.md` diff --git a/src/Features/PowerCSharp.Feature.Sanitization/SanitizationEngineServiceProviderExtensions.cs b/src/Features/PowerCSharp.Feature.Sanitization/SanitizationEngineServiceProviderExtensions.cs new file mode 100644 index 0000000..c8ed47f --- /dev/null +++ b/src/Features/PowerCSharp.Feature.Sanitization/SanitizationEngineServiceProviderExtensions.cs @@ -0,0 +1,33 @@ +using Microsoft.Extensions.DependencyInjection; +using PowerCSharp.Feature.Sanitization.Abstractions; + +namespace PowerCSharp.Feature.Sanitization; + +/// +/// Wires a built 's into +/// the static , so call sites that use the engine directly (e.g. +/// SanitizationExtensions.SanitizeForLog) reflect the host's configured settings without +/// needing to resolve from DI. +/// +public static class SanitizationEngineServiceProviderExtensions +{ + /// + /// Resolves from , + /// if registered, and calls with it. + /// A no-op when the Sanitization feature is disabled (no settings provider registered) or when + /// only the explicit AddSanitizationFeature NoOp registration path was used. + /// + /// The built application service provider. + /// The same , for chaining. + public static IServiceProvider ConfigureSanitizationEngine(this IServiceProvider serviceProvider) + { + var settingsProvider = serviceProvider.GetService(); + + if (settingsProvider is not null) + { + SanitizationEngine.SetConfigurationProvider(settingsProvider.GetCurrentSettings); + } + + return serviceProvider; + } +} diff --git a/src/Features/PowerCSharp.Feature.Sanitization/SanitizationFeatureExtensions.cs b/src/Features/PowerCSharp.Feature.Sanitization/SanitizationFeatureExtensions.cs new file mode 100644 index 0000000..945a816 --- /dev/null +++ b/src/Features/PowerCSharp.Feature.Sanitization/SanitizationFeatureExtensions.cs @@ -0,0 +1,31 @@ +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.DependencyInjection.Extensions; +using PowerCSharp.Feature.Sanitization.Abstractions; + +namespace PowerCSharp.Feature.Sanitization; + +/// Explicit (no-reflection) registration for the Sanitization feature contracts and options. +public static class SanitizationFeatureExtensions +{ + /// + /// Binds from PowerFeatures:Sanitization and + /// registers the real . Unlike + /// CacheFeatureExtensions.AddCacheFeature, this registers the concrete implementation + /// directly rather than a NoOp floor: Sanitization has no separate provider package to defer + /// to, so explicit opt-in here means the caller wants sanitization active. Call + /// on the + /// built to also wire the static + /// for non-DI call sites. + /// + public static IServiceCollection AddSanitizationFeature(this IServiceCollection services, IConfiguration configuration) + { + services.Configure( + configuration.GetSection($"PowerFeatures:{SanitizationFeatureModule.Key}")); + + services.TryAddSingleton(); + services.TryAddSingleton(); + + return services; + } +} diff --git a/src/Features/PowerCSharp.Feature.Sanitization/SanitizationFeatureModule.cs b/src/Features/PowerCSharp.Feature.Sanitization/SanitizationFeatureModule.cs new file mode 100644 index 0000000..90961c0 --- /dev/null +++ b/src/Features/PowerCSharp.Feature.Sanitization/SanitizationFeatureModule.cs @@ -0,0 +1,61 @@ +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.DependencyInjection.Extensions; +using Microsoft.Extensions.Logging; +using PowerCSharp.Feature.Sanitization.Abstractions; +using PowerCSharp.Feature.Sanitization.Abstractions.NoOp; +using PowerCSharp.Features.Abstractions; +using System.Reflection; + +namespace PowerCSharp.Feature.Sanitization; + +/// +/// Sanitization feature module. Binds options and registers either the real +/// (feature enabled) or +/// (feature disabled). Unlike the Cache feature family, Sanitization has no swappable-backend +/// provider package, so this module registers the concrete implementation directly rather than +/// deferring to one. Supports auto-discovery and the explicit +/// . +/// +public sealed class SanitizationFeatureModule : IFeatureModule +{ + /// The feature key. + public const string Key = "Sanitization"; + + /// + public string FeatureKey => Key; + + /// + public int Order => 20; + + /// + public void ConfigureServices(IFeatureRegistrationContext context) + { + // Force-load the abstractions assembly so the CLR can resolve its types during discovery. + _ = Assembly.Load("PowerCSharp.Feature.Sanitization.Abstractions"); + + context.Services.Configure( + context.Configuration.GetSection($"PowerFeatures:{Key}")); + + if (!context.Flags.IsEnabled(FeatureKey)) + { + context.Logger.LogInformation("Sanitization feature disabled; registering NoOp sanitization service."); + context.Services.TryAddSingleton(); + return; + } + + context.Services.TryAddSingleton(); + context.Services.TryAddSingleton(); + } + + /// + /// + /// Contributes no middleware. Used solely to bridge the DI-resolved + /// into the static + /// once the application's has been built, so extension-method + /// call sites (e.g. input.SanitizeForLog()) reflect the host's configuration too. + /// + public void ConfigurePipeline(IFeaturePipelineContext context) + { + context.App.ApplicationServices.ConfigureSanitizationEngine(); + } +} diff --git a/src/Features/PowerCSharp.Feature.Sanitization/SanitizationFeatureOptions.cs b/src/Features/PowerCSharp.Feature.Sanitization/SanitizationFeatureOptions.cs new file mode 100644 index 0000000..86878bc --- /dev/null +++ b/src/Features/PowerCSharp.Feature.Sanitization/SanitizationFeatureOptions.cs @@ -0,0 +1,117 @@ +using Microsoft.Extensions.Logging; +using PowerCSharp.Feature.Sanitization.Abstractions.Enums; +using PowerCSharp.Features.Abstractions; + +namespace PowerCSharp.Feature.Sanitization; + +/// +/// Options for the Sanitization feature, bound from PowerFeatures:Sanitization. Mirrors the +/// full configurable surface of (excluding +/// CorrelationId, which is per-call/per-request rather than a global setting) so every +/// sanitization concern — log injection, file path, sensitive data, regex injection — can be tuned +/// via configuration without code changes. +/// +public sealed class SanitizationFeatureOptions : FeatureOptionsBase +{ + // --- Log Injection Sanitization Settings --- + + /// Whether log sanitization is enabled. Default: true. + public bool EnableLogSanitization { get; set; } = true; + + /// Whether Unicode control characters should be sanitized. Default: true. + public bool SanitizeUnicodeControlChars { get; set; } = true; + + /// Whether tab characters are preserved rather than sanitized. Default: false. + public bool PreserveTabCharacters { get; set; } = false; + + /// The strategy used to handle control characters during log sanitization. Default: Remove. + public SanitizationStrategy LogSanitizationStrategy { get; set; } = SanitizationStrategy.Remove; + + /// Maximum length for sanitized strings before truncation. Default: 10000. + public int MaxSanitizedStringLength { get; set; } = 10000; + + // --- Failure / Security Event Logging --- + + /// Whether sanitization failures are logged. Default: false. + public bool LogSanitizationFailures { get; set; } = false; + + /// The log level used when logging sanitization failures. Default: Warning. + public LogLevel SanitizationFailureLogLevel { get; set; } = LogLevel.Warning; + + /// Whether file-path security events are logged. Default: false. + public bool LogSecurityEvents { get; set; } = false; + + // --- Performance Monitoring --- + + /// Whether performance monitoring is enabled. Default: false. + public bool EnablePerformanceMonitoring { get; set; } = false; + + /// The threshold, in milliseconds, above which an operation is considered slow. Default: 1.0. + public double SlowOperationThresholdMs { get; set; } = 1.0; + + // --- File Path Sanitization Settings --- + + /// Whether file-path sanitization is enabled. Default: true. + public bool EnableFilePathSanitization { get; set; } = true; + + /// Whether parent-directory traversal patterns are checked and removed. Default: true. + public bool EnableParentDirectoryTraversalCheck { get; set; } = true; + + /// Whether strict allowlist validation is used for file paths. Default: true. + public bool UseStrictValidation { get; set; } = true; + + /// Whether Windows reserved device names (CON, PRN, AUX, etc.) are rejected. Default: true. + public bool ValidateWindowsReservedNames { get; set; } = true; + + /// Whether Unicode characters are allowed in file paths. Default: true. + public bool AllowUnicodeCharacters { get; set; } = true; + + /// Maximum allowed length for an individual path segment. Default: 255. + public int MaxPathSegmentLength { get; set; } = 255; + + /// Base directories file paths must resolve within. Empty means no restriction. Default: empty. + public string[] AllowedBaseDirectories { get; set; } = Array.Empty(); + + /// File extensions a file path must end with (case-insensitive). Empty means no restriction. Default: empty. + public string[] AllowedFileExtensions { get; set; } = Array.Empty(); + + // --- Sensitive Data Sanitization Settings --- + + /// Whether sensitive-data detection and masking is enabled. Default: true. + public bool EnableSensitiveDataDetection { get; set; } = true; + + /// The character used to mask detected sensitive data. Default: '*'. + public char SensitiveDataMaskCharacter { get; set; } = '*'; + + /// The detection strictness level for sensitive data. Default: Medium. + public SensitiveDataDetectionStrictness SensitiveDataDetectionStrictness { get; set; } = SensitiveDataDetectionStrictness.Medium; + + // --- Regex Injection Sanitization Settings --- + + /// Whether regex-pattern safety validation is enabled. Default: true. + public bool EnableRegexSanitization { get; set; } = true; + + /// Maximum time allowed to validate a single regex pattern. Default: 5 seconds. + public TimeSpan MaxRegexValidationTimeout { get; set; } = TimeSpan.FromSeconds(5); + + /// Maximum allowed length for a regex pattern. Default: 1000. + public int MaxRegexPatternLength { get; set; } = 1000; + + /// Maximum allowed complexity score for a regex pattern. Default: 100. + public int MaxRegexComplexityScore { get; set; } = 100; + + /// Whether Unicode category classes (e.g. \p{L}) are allowed in patterns. Default: true. + public bool AllowUnicodeCategories { get; set; } = true; + + /// Whether quantifiers (*, +, ?, {n,m}) are allowed in patterns. Default: true. + public bool AllowQuantifiers { get; set; } = true; + + /// Whether nested quantifiers (a common catastrophic-backtracking source) are allowed. Default: false. + public bool AllowNestedQuantifiers { get; set; } = false; + + /// Whether backreferences are allowed in patterns. Default: true. + public bool AllowBackreferences { get; set; } = true; + + /// Whether lookaround assertions are allowed in patterns. Default: true. + public bool AllowLookarounds { get; set; } = true; +} diff --git a/src/Features/PowerCSharp.Feature.Sanitization/SanitizationService.cs b/src/Features/PowerCSharp.Feature.Sanitization/SanitizationService.cs new file mode 100644 index 0000000..515de1d --- /dev/null +++ b/src/Features/PowerCSharp.Feature.Sanitization/SanitizationService.cs @@ -0,0 +1,56 @@ +using PowerCSharp.Feature.Sanitization.Abstractions; + +namespace PowerCSharp.Feature.Sanitization; + +/// +/// Configuration-aware implementation. Delegates every +/// operation to the static , sourcing settings from an injected +/// so the options-to-settings mapping lives in exactly +/// one place. +/// +public sealed class SanitizationService : ISanitizationService +{ + private readonly ISanitizationSettingsProvider _settingsProvider; + + /// Initializes a new instance of . + /// The settings provider used to source current settings. + public SanitizationService(ISanitizationSettingsProvider settingsProvider) + { + _settingsProvider = settingsProvider ?? throw new ArgumentNullException(nameof(settingsProvider)); + } + + /// + public SanitizationResult SanitizeForLogInjection(string? input) + => SanitizationEngine.SanitizeForLogInjection(input, GetCurrentSettings()); + + /// + public SanitizationResult SanitizeForLogInjection(string? input, SanitizationSettings? settings) + => SanitizationEngine.SanitizeForLogInjection(input, settings); + + /// + public SanitizationResult SanitizeForFilePath(string? input) + => SanitizationEngine.SanitizeForFilePath(input, GetCurrentSettings()); + + /// + public SanitizationResult SanitizeForFilePath(string? input, SanitizationSettings? settings) + => SanitizationEngine.SanitizeForFilePath(input, settings); + + /// + public SensitiveDataResult SanitizeForSensitiveData(string? input) + => SanitizationEngine.SanitizeForSensitiveData(input, GetCurrentSettings()); + + /// + public SensitiveDataResult SanitizeForSensitiveData(string? input, SanitizationSettings? settings) + => SanitizationEngine.SanitizeForSensitiveData(input, settings); + + /// + public SanitizationResult SanitizeForRegexInjection(string? pattern) + => SanitizationEngine.SanitizeForRegexInjection(pattern, GetCurrentSettings()); + + /// + public SanitizationResult SanitizeForRegexInjection(string? pattern, SanitizationSettings? settings) + => SanitizationEngine.SanitizeForRegexInjection(pattern, settings); + + /// + public SanitizationSettings GetCurrentSettings() => _settingsProvider.GetCurrentSettings(); +} diff --git a/src/Features/PowerCSharp.Feature.Sanitization/SanitizationSettingsProvider.cs b/src/Features/PowerCSharp.Feature.Sanitization/SanitizationSettingsProvider.cs new file mode 100644 index 0000000..9ed408b --- /dev/null +++ b/src/Features/PowerCSharp.Feature.Sanitization/SanitizationSettingsProvider.cs @@ -0,0 +1,71 @@ +using Microsoft.Extensions.Options; +using PowerCSharp.Feature.Sanitization.Abstractions; + +namespace PowerCSharp.Feature.Sanitization; + +/// +/// Bridges (bound from PowerFeatures:Sanitization) +/// into a instance for the static +/// , via . +/// +/// +/// Deliberately takes no ILogger dependency. can invoke the +/// settings provider from within its own security-event logging path; taking a logger dependency +/// here would risk a recursive logging loop. +/// +public sealed class SanitizationSettingsProvider : ISanitizationSettingsProvider +{ + private readonly IOptionsMonitor _options; + + /// Initializes a new instance of . + /// The monitored Sanitization feature options. + public SanitizationSettingsProvider(IOptionsMonitor options) + { + _options = options ?? throw new ArgumentNullException(nameof(options)); + } + + /// + public SanitizationSettings GetCurrentSettings() + { + var options = _options.CurrentValue; + + return new SanitizationSettings + { + EnableLogSanitization = options.EnableLogSanitization, + SanitizeUnicodeControlChars = options.SanitizeUnicodeControlChars, + PreserveTabCharacters = options.PreserveTabCharacters, + LogSanitizationStrategy = options.LogSanitizationStrategy, + MaxSanitizedStringLength = options.MaxSanitizedStringLength, + + LogSanitizationFailures = options.LogSanitizationFailures, + SanitizationFailureLogLevel = options.SanitizationFailureLogLevel, + LogSecurityEvents = options.LogSecurityEvents, + + EnablePerformanceMonitoring = options.EnablePerformanceMonitoring, + SlowOperationThresholdMs = options.SlowOperationThresholdMs, + + EnableFilePathSanitization = options.EnableFilePathSanitization, + EnableParentDirectoryTraversalCheck = options.EnableParentDirectoryTraversalCheck, + UseStrictValidation = options.UseStrictValidation, + ValidateWindowsReservedNames = options.ValidateWindowsReservedNames, + AllowUnicodeCharacters = options.AllowUnicodeCharacters, + MaxPathSegmentLength = options.MaxPathSegmentLength, + AllowedBaseDirectories = options.AllowedBaseDirectories, + AllowedFileExtensions = options.AllowedFileExtensions, + + EnableSensitiveDataDetection = options.EnableSensitiveDataDetection, + SensitiveDataMaskCharacter = options.SensitiveDataMaskCharacter, + SensitiveDataDetectionStrictness = options.SensitiveDataDetectionStrictness, + + EnableRegexSanitization = options.EnableRegexSanitization, + MaxRegexValidationTimeout = options.MaxRegexValidationTimeout, + MaxRegexPatternLength = options.MaxRegexPatternLength, + MaxRegexComplexityScore = options.MaxRegexComplexityScore, + AllowUnicodeCategories = options.AllowUnicodeCategories, + AllowQuantifiers = options.AllowQuantifiers, + AllowNestedQuantifiers = options.AllowNestedQuantifiers, + AllowBackreferences = options.AllowBackreferences, + AllowLookarounds = options.AllowLookarounds + }; + } +} diff --git a/src/Features/PowerCSharp.Features.Abstractions/README.md b/src/Features/PowerCSharp.Features.Abstractions/README.md index a84142a..a236122 100644 --- a/src/Features/PowerCSharp.Features.Abstractions/README.md +++ b/src/Features/PowerCSharp.Features.Abstractions/README.md @@ -1,5 +1,7 @@ # PowerCSharp.Features.Abstractions +![PowerCSharp Banner](https://raw.githubusercontent.com/marioarce/PowerCSharp/0191ee12092c28ccf5a578e59977583117a3ff00/docs/images/PowerCSharp_Banner.png) + Contracts for the PowerCSharp Features system. Zero third-party dependencies so any feature can reference it cheaply (it relies only on the shared ASP.NET Core framework). diff --git a/src/Features/PowerCSharp.Features/README.md b/src/Features/PowerCSharp.Features/README.md index 3d799b3..6cdcdeb 100644 --- a/src/Features/PowerCSharp.Features/README.md +++ b/src/Features/PowerCSharp.Features/README.md @@ -1,5 +1,7 @@ # PowerCSharp.Features +![PowerCSharp Banner](https://raw.githubusercontent.com/marioarce/PowerCSharp/0191ee12092c28ccf5a578e59977583117a3ff00/docs/images/PowerCSharp_Banner.png) + The Features engine: feature discovery (hybrid auto-scan + explicit), composite flag resolution, DI orchestration, a feature registry, and diagnostics. diff --git a/src/PowerCSharp.Compatibility/CLAUDE.md b/src/PowerCSharp.Compatibility/CLAUDE.md new file mode 100644 index 0000000..704b867 --- /dev/null +++ b/src/PowerCSharp.Compatibility/CLAUDE.md @@ -0,0 +1,73 @@ +# CLAUDE.md — PowerCSharp.Compatibility + +> Scope: this project only. Read alongside the root `CLAUDE.md`. + +## Sensitivity level: high — actively used in production + +Confirmed with the maintainer: this package is **actively depended on by real .NET Framework +consumers**, not a legacy shim kept for completeness. Treat every change as if it ships to +production the moment it's merged. This is the opposite default from "legacy code you can +modernize freely" — the constraint here is backward compatibility, not code quality improvement. + +## 1. What Makes This Package Different From the Rest of the Repo + +- **Target frameworks:** `net48;net462;net472` only — no `net8.0`, no `netstandard2.0`. This is + the *only* package in the repo that does not multi-target `netstandard2.0` or `net8.0`. + (`PowerCSharp.Compatibility.csproj`.) +- **`LangVersion` is pinned to `11.0`**, not `latest` — an explicit override of the repo-wide + default set in `Directory.Build.props`. Do not "fix" this to match the rest of the repo; it is + pinned because the target frameworks and consuming apps constrain available language features. + If a change requires a newer C# feature, that is a signal the change may not belong in this + package. +- **References classic ASP.NET, not ASP.NET Core:** `System.Web` and `Microsoft.CSharp` are + referenced per-TFM (`net48`, `net462`, `net472` conditional `ItemGroup`s). `HttpRequestBase` / + `HttpRequestExtensions` in this package operate against System.Web types + (`Http/HttpRequestBaseExtensions.cs`), which have no equivalent in, and must never be confused + with, the ASP.NET Core `HttpRequest` extensions in `PowerCSharp.Extensions.AspNetCore`. +- **Own version family:** `PowerCSharpCompatibilityVersion`, bumped by manual edit to + `Directory.Build.props` (not currently wired to the `workflow_dispatch` `package_family` choices + the way `core`/`features`/`cache` are — see Section 3 before assuming otherwise). +- **Package dependencies are deliberately narrow and framework-appropriate**: `System.Text.Json` + (for `net48`/`net462`/`net472`, where `System.Text.Json` isn't part of the framework), the + `System.Net.Http` 4.3.4 back-compat package, and `Microsoft.CSharp` for dynamic support. Do not + add a dependency here that assumes a modern BCL surface is present. + +## 2. The CI Gap — Read Before Assuming Test Coverage + +- `PowerCSharp.Compatibility.Extensions.Tests` was removed from `PowerCSharp.sln` and from the CI + build matrix in commit `86464f7` ("ci: revert CI to single Ubuntu runner and remove + Compatibility.Extensions.Tests from solution"). +- The test project now lives **only** in the separate `PowerCSharp-Compatibility.sln`, which + `.github/workflows/ci-cd.yml` never builds or runs. +- **Practical consequence:** a change to `PowerCSharp.Compatibility` today does not get automatic + test coverage from the standard `dotnet build/test PowerCSharp.sln` pipeline used everywhere + else in this repo. If you modify this package: + - Manually build and run its tests via `PowerCSharp-Compatibility.sln`: + ```bash + dotnet test PowerCSharp-Compatibility.sln --configuration Release + ``` + - Say so explicitly in your response (`` per the root `CLAUDE.md`) — do not report + "tests pass" based on the main solution's green CI run, since that run does not exercise this + package's tests at all. +- This gap is a known, named issue, not something to silently "fix" by re-adding the test project + to the main solution without asking — the removal in `86464f7` may have been a deliberate, + considered call (e.g. avoiding a cross-platform matrix cost) rather than an oversight. Flag it + as an open question rather than reverting it unilaterally. + +## 3. Versioning + +`PowerCSharpCompatibilityVersion` in `Directory.Build.props` is edited by hand; it is **not** one +of the `core` / `features` / `cache` choices in the `ci-cd.yml` `workflow_dispatch` input today. If +you're asked to release a Compatibility change, confirm with the maintainer whether to extend the +`workflow_dispatch` `package_family` list to include `compatibility`, or continue with the manual +edit + manual tag flow — do not assume either path silently. + +## 4. Working in This Package + +- Read `docs/PowerCSharp.Compatibility.md` for the documented public surface before adding to it. +- Any new extension method needs the "safe" null-handling style used elsewhere in the repo (see + `Extensions/StringExtensions.cs` for the existing pattern in this package specifically). +- Do not introduce a dependency on any `net8.0`-only package or language feature — verify + buildability against all three target frameworks (`net48`, `net462`, `net472`) before considering + a change complete, using `PowerCSharp-Compatibility.sln` since the main solution's CI won't catch + a regression here (Section 2). diff --git a/src/PowerCSharp.Compatibility/README.md b/src/PowerCSharp.Compatibility/README.md index 45f3978..461cdaf 100644 --- a/src/PowerCSharp.Compatibility/README.md +++ b/src/PowerCSharp.Compatibility/README.md @@ -1,6 +1,6 @@ # PowerCSharp.Compatibility -![PowerCSharp Banner](../../docs/images/PowerCSharp_Banner.png) +![PowerCSharp Banner](https://raw.githubusercontent.com/marioarce/PowerCSharp/0191ee12092c28ccf5a578e59977583117a3ff00/docs/images/PowerCSharp_Banner.png) [![PowerCSharp.Compatibility](https://img.shields.io/badge/PowerCSharp.Compatibility-v0.1.0-blue.svg)](https://github.com/marioarce/PowerCSharp) [![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](https://opensource.org/licenses/MIT) diff --git a/src/PowerCSharp.Core/Collections/ListExtensions.cs b/src/PowerCSharp.Core/Collections/ListExtensions.cs index 39c6888..88058eb 100644 --- a/src/PowerCSharp.Core/Collections/ListExtensions.cs +++ b/src/PowerCSharp.Core/Collections/ListExtensions.cs @@ -38,12 +38,39 @@ public static class ListExtensions var clonedList = new List(sourceList.Count); foreach (var item in sourceList) { - if (item != null) + clonedList.Add(item != null ? (T)item.Clone() : default); + } + + return clonedList; + } + + /// + /// Returns a new list containing only the first occurrence of each unique item based on the specified key selector. + /// + /// The type of elements in the list. + /// The type of the key to compare for uniqueness. + /// The source list to deduplicate. + /// A function to extract the key for each element. + /// A new list with duplicates removed based on the specified key, or null if the input is null. + public static List? DistinctBy(this List? sourceList, Func keySelector) + { + if (sourceList == null) + { + return null; + } + + var seenKeys = new HashSet(); + var result = new List(); + + foreach (var item in sourceList) + { + var key = keySelector(item); + if (seenKeys.Add(key)) { - clonedList.Add((T)item.Clone()); + result.Add(item); } } - return clonedList; + return result; } } diff --git a/src/PowerCSharp.Core/README.md b/src/PowerCSharp.Core/README.md index e3ca22e..24556fd 100644 --- a/src/PowerCSharp.Core/README.md +++ b/src/PowerCSharp.Core/README.md @@ -1,6 +1,6 @@ # PowerCSharp.Core -![PowerCSharp Banner](../docs/images/PowerCSharp_Banner.png) +![PowerCSharp Banner](https://raw.githubusercontent.com/marioarce/PowerCSharp/0191ee12092c28ccf5a578e59977583117a3ff00/docs/images/PowerCSharp_Banner.png) [![PowerCSharp.Core](https://img.shields.io/badge/PowerCSharp.Core-v0.3.0-blue.svg)](https://github.com/marioarce/PowerCSharp) [![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](https://opensource.org/licenses/MIT) diff --git a/src/PowerCSharp.Extensions.AspNetCore/README.md b/src/PowerCSharp.Extensions.AspNetCore/README.md index ff4d8a8..7c41467 100644 --- a/src/PowerCSharp.Extensions.AspNetCore/README.md +++ b/src/PowerCSharp.Extensions.AspNetCore/README.md @@ -1,6 +1,6 @@ # PowerCSharp.Extensions.AspNetCore -![PowerCSharp Banner](../../docs/images/PowerCSharp_Banner.png) +![PowerCSharp Banner](https://raw.githubusercontent.com/marioarce/PowerCSharp/0191ee12092c28ccf5a578e59977583117a3ff00/docs/images/PowerCSharp_Banner.png) [![PowerCSharp.Extensions.AspNetCore](https://img.shields.io/badge/PowerCSharp.Extensions.AspNetCore-v0.3.0-blue.svg)](https://github.com/marioarce/PowerCSharp) [![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](https://opensource.org/licenses/MIT) diff --git a/src/PowerCSharp.Extensions/README.md b/src/PowerCSharp.Extensions/README.md index 84da19d..7f7d4b5 100644 --- a/src/PowerCSharp.Extensions/README.md +++ b/src/PowerCSharp.Extensions/README.md @@ -1,6 +1,6 @@ # PowerCSharp.Extensions -![PowerCSharp Banner](../docs/images/PowerCSharp_Banner.png) +![PowerCSharp Banner](https://raw.githubusercontent.com/marioarce/PowerCSharp/0191ee12092c28ccf5a578e59977583117a3ff00/docs/images/PowerCSharp_Banner.png) [![PowerCSharp.Extensions](https://img.shields.io/badge/PowerCSharp.Extensions-v0.3.0-blue.svg)](https://github.com/marioarce/PowerCSharp) [![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](https://opensource.org/licenses/MIT) diff --git a/src/PowerCSharp.Extensions/Strings/StringExtensions.cs b/src/PowerCSharp.Extensions/Strings/StringExtensions.cs index 88aee76..a7d31a4 100644 --- a/src/PowerCSharp.Extensions/Strings/StringExtensions.cs +++ b/src/PowerCSharp.Extensions/Strings/StringExtensions.cs @@ -661,6 +661,17 @@ public static string Convert9DigitZipTo5Digit(this string zipCode) ?? zipCode; } + /// + /// Returns the raw value when it has content; otherwise the current fallback value. + /// + /// The candidate raw value. + /// The value to keep when the candidate is empty. + /// The overriding value or the existing fallback. + public static string? Coalesce(this string? rawValue, string? fallback) + { + return string.IsNullOrEmpty(rawValue) ? fallback : rawValue; + } + /// /// Returns a copy of the string after removing Html Tags and Entities like ® or  . /// diff --git a/src/PowerCSharp.Helpers/README.md b/src/PowerCSharp.Helpers/README.md index dbf7b25..e2483e6 100644 --- a/src/PowerCSharp.Helpers/README.md +++ b/src/PowerCSharp.Helpers/README.md @@ -1,6 +1,6 @@ # PowerCSharp.Helpers -![PowerCSharp Banner](../docs/images/PowerCSharp_Banner.png) +![PowerCSharp Banner](https://raw.githubusercontent.com/marioarce/PowerCSharp/0191ee12092c28ccf5a578e59977583117a3ff00/docs/images/PowerCSharp_Banner.png) [![PowerCSharp.Helpers](https://img.shields.io/badge/PowerCSharp.Helpers-v0.2.0-blue.svg)](https://github.com/marioarce/PowerCSharp) [![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](https://opensource.org/licenses/MIT) diff --git a/src/PowerCSharp.Utilities/README.md b/src/PowerCSharp.Utilities/README.md index 90e4f98..39d6f51 100644 --- a/src/PowerCSharp.Utilities/README.md +++ b/src/PowerCSharp.Utilities/README.md @@ -1,6 +1,6 @@ # PowerCSharp.Utilities -![PowerCSharp Banner](../docs/images/PowerCSharp_Banner.png) +![PowerCSharp Banner](https://raw.githubusercontent.com/marioarce/PowerCSharp/0191ee12092c28ccf5a578e59977583117a3ff00/docs/images/PowerCSharp_Banner.png) [![PowerCSharp.Utilities](https://img.shields.io/badge/PowerCSharp.Utilities-v0.2.0-blue.svg)](https://github.com/marioarce/PowerCSharp) [![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](https://opensource.org/licenses/MIT) diff --git a/tests/PowerCSharp.BuiltInFeatures.Tests/PowerCSharp.BuiltInFeatures.Tests.csproj b/tests/PowerCSharp.BuiltInFeatures.Tests/PowerCSharp.BuiltInFeatures.Tests.csproj index 62e1870..94d18e5 100644 --- a/tests/PowerCSharp.BuiltInFeatures.Tests/PowerCSharp.BuiltInFeatures.Tests.csproj +++ b/tests/PowerCSharp.BuiltInFeatures.Tests/PowerCSharp.BuiltInFeatures.Tests.csproj @@ -20,9 +20,9 @@ - - - + + + diff --git a/tests/PowerCSharp.Core.Tests/DictionaryExtensionsTests.cs b/tests/PowerCSharp.Core.Tests/DictionaryExtensionsTests.cs index 9a368bd..bbaf674 100644 --- a/tests/PowerCSharp.Core.Tests/DictionaryExtensionsTests.cs +++ b/tests/PowerCSharp.Core.Tests/DictionaryExtensionsTests.cs @@ -120,7 +120,7 @@ public void TryAdd_WithNullDictionary_ShouldThrowArgumentNullException() Dictionary? nullDictionary = null; // Act & Assert - Assert.Throws(() => nullDictionary!.TryAdd("key", 1)); + Assert.Throws(() => nullDictionary!.TryAdd("key", 1)); } [Fact] diff --git a/tests/PowerCSharp.Core.Tests/ListExtensionsTests.cs b/tests/PowerCSharp.Core.Tests/ListExtensionsTests.cs new file mode 100644 index 0000000..2de7850 --- /dev/null +++ b/tests/PowerCSharp.Core.Tests/ListExtensionsTests.cs @@ -0,0 +1,267 @@ +using PowerCSharp.Core.Collections; +using Xunit; + +namespace PowerCSharp.Core.Tests; + +public class ListExtensionsTests +{ + #region DistinctBy Tests + + [Fact] + public void DistinctBy_WithNullList_ShouldReturnNull() + { + // Arrange + List? nullList = null; + + // Act + var result = nullList.DistinctBy(x => x); + + // Assert + Assert.Null(result); + } + + [Fact] + public void DistinctBy_WithEmptyList_ShouldReturnEmptyList() + { + // Arrange + var emptyList = new List(); + + // Act + var result = emptyList.DistinctBy(x => x); + + // Assert + Assert.NotNull(result); + Assert.Empty(result); + } + + [Fact] + public void DistinctBy_WithNoDuplicates_ShouldReturnAllItems() + { + // Arrange + var list = new List { 1, 2, 3, 4, 5 }; + + // Act + var result = list.DistinctBy(x => x); + + // Assert + Assert.Equal(5, result.Count); + Assert.Equal(list, result); + } + + [Fact] + public void DistinctBy_WithDuplicates_ShouldRemoveDuplicates() + { + // Arrange + var list = new List { 1, 2, 2, 3, 4, 4, 5 }; + + // Act + var result = list.DistinctBy(x => x); + + // Assert + Assert.Equal(5, result.Count); + Assert.Equal(1, result[0]); + Assert.Equal(2, result[1]); + Assert.Equal(3, result[2]); + Assert.Equal(4, result[3]); + Assert.Equal(5, result[4]); + } + + [Fact] + public void DistinctBy_WithComplexType_ShouldDedulicateBySpecifiedField() + { + // Arrange + var list = new List + { + new TestPerson { Id = 1, Name = "John" }, + new TestPerson { Id = 2, Name = "Jane" }, + new TestPerson { Id = 1, Name = "John Duplicate" }, + new TestPerson { Id = 3, Name = "Bob" }, + new TestPerson { Id = 2, Name = "Jane Duplicate" } + }; + + // Act + var result = list.DistinctBy(x => x.Id); + + // Assert + Assert.Equal(3, result.Count); + Assert.Equal(1, result[0].Id); + Assert.Equal("John", result[0].Name); // First occurrence kept + Assert.Equal(2, result[1].Id); + Assert.Equal("Jane", result[1].Name); // First occurrence kept + Assert.Equal(3, result[2].Id); + Assert.Equal("Bob", result[2].Name); + } + + [Fact] + public void DistinctBy_WithStringKey_ShouldDedulicateByStringField() + { + // Arrange + var list = new List + { + new TestPerson { Id = 1, Name = "John" }, + new TestPerson { Id = 2, Name = "Jane" }, + new TestPerson { Id = 3, Name = "John" }, + new TestPerson { Id = 4, Name = "Bob" } + }; + + // Act + var result = list.DistinctBy(x => x.Name); + + // Assert + Assert.Equal(3, result.Count); + Assert.Equal("John", result[0].Name); + Assert.Equal("Jane", result[1].Name); + Assert.Equal("Bob", result[2].Name); + } + + [Fact] + public void DistinctBy_WithAllDuplicates_ShouldReturnSingleItem() + { + // Arrange + var list = new List { 5, 5, 5, 5, 5 }; + + // Act + var result = list.DistinctBy(x => x); + + // Assert + Assert.Single(result); + Assert.Equal(5, result[0]); + } + + [Fact] + public void DistinctBy_ShouldPreserveOrderOfFirstOccurrences() + { + // Arrange + var list = new List { 3, 1, 2, 1, 3, 2 }; + + // Act + var result = list.DistinctBy(x => x); + + // Assert + Assert.Equal(3, result.Count); + Assert.Equal(3, result[0]); // First occurrence of 3 + Assert.Equal(1, result[1]); // First occurrence of 1 + Assert.Equal(2, result[2]); // First occurrence of 2 + } + + #endregion + + #region DeepClone Tests + + [Fact] + public void DeepClone_WithNullList_ShouldReturnNull() + { + // Arrange + List? nullList = null; + + // Act + var result = nullList.DeepClone(); + + // Assert + Assert.Null(result); + } + + [Fact] + public void DeepClone_WithEmptyList_ShouldReturnEmptyList() + { + // Arrange + var emptyList = new List(); + + // Act + var result = emptyList.DeepClone(); + + // Assert + Assert.NotNull(result); + Assert.Empty(result); + Assert.NotSame(emptyList, result); + } + + [Fact] + public void DeepClone_WithCloneableItems_ShouldCreateDeepCopy() + { + // Arrange + var list = new List + { + new TestCloneable { Value = 1 }, + new TestCloneable { Value = 2 }, + new TestCloneable { Value = 3 } + }; + + // Act + var result = list.DeepClone(); + + // Assert + Assert.NotNull(result); + Assert.Equal(list.Count, result.Count); + Assert.NotSame(list, result); + + for (int i = 0; i < list.Count; i++) + { + Assert.NotSame(list[i], result[i]); + Assert.Equal(list[i].Value, result[i].Value); + } + } + + [Fact] + public void DeepClone_WithNullItems_ShouldHandleNullItems() + { + // Arrange + var list = new List + { + new TestCloneable { Value = 1 }, + null, + new TestCloneable { Value = 3 } + }; + + // Act + var result = list.DeepClone(); + + // Assert + Assert.NotNull(result); + Assert.Equal(list.Count, result.Count); + Assert.NotNull(result[0]); + Assert.Null(result[1]); + Assert.NotNull(result[2]); + } + + [Fact] + public void DeepClone_ModifyingCloneShouldNotAffectOriginal() + { + // Arrange + var list = new List + { + new TestCloneable { Value = 10 }, + new TestCloneable { Value = 20 } + }; + + // Act + var result = list.DeepClone(); + result[0].Value = 999; + + // Assert + Assert.Equal(10, list[0].Value); + Assert.Equal(999, result[0].Value); + } + + #endregion + + #region Test Helper Classes + + private class TestPerson + { + public int Id { get; set; } + public string Name { get; set; } = string.Empty; + } + + private class TestCloneable : ICloneable + { + public int Value { get; set; } + + public object Clone() + { + return new TestCloneable { Value = this.Value }; + } + } + + #endregion +} diff --git a/tests/PowerCSharp.Core.Tests/PowerCSharp.Core.Tests.csproj b/tests/PowerCSharp.Core.Tests/PowerCSharp.Core.Tests.csproj index 5c6f25b..9463f5d 100644 --- a/tests/PowerCSharp.Core.Tests/PowerCSharp.Core.Tests.csproj +++ b/tests/PowerCSharp.Core.Tests/PowerCSharp.Core.Tests.csproj @@ -20,8 +20,9 @@ - - + + + diff --git a/tests/PowerCSharp.Core.Tests/UnitTest1.cs b/tests/PowerCSharp.Core.Tests/StringExtensionsTests.cs similarity index 94% rename from tests/PowerCSharp.Core.Tests/UnitTest1.cs rename to tests/PowerCSharp.Core.Tests/StringExtensionsTests.cs index 7108a56..5a3775f 100644 --- a/tests/PowerCSharp.Core.Tests/UnitTest1.cs +++ b/tests/PowerCSharp.Core.Tests/StringExtensionsTests.cs @@ -1,5 +1,6 @@ using System; using PowerCSharp.Core; +using PowerCSharp.Extensions.Strings; using Xunit; namespace PowerCSharp.Core.Tests; diff --git a/tests/PowerCSharp.Extensions.Tests/StringExtensionsTests.cs b/tests/PowerCSharp.Extensions.Tests/StringExtensionsTests.cs new file mode 100644 index 0000000..86eb6ab --- /dev/null +++ b/tests/PowerCSharp.Extensions.Tests/StringExtensionsTests.cs @@ -0,0 +1,109 @@ +using PowerCSharp.Extensions.Strings; +using Xunit; + +namespace PowerCSharp.Extensions.Tests; + +public class StringExtensionsTests +{ + #region Coalesce Tests + + [Fact] + public void Coalesce_WithNonNullRawValue_ShouldReturnRawValue() + { + // Arrange + string rawValue = "hello"; + string fallback = "fallback"; + + // Act + var result = rawValue.Coalesce(fallback); + + // Assert + Assert.Equal(rawValue, result); + } + + [Fact] + public void Coalesce_WithEmptyRawValue_ShouldReturnFallback() + { + // Arrange + string rawValue = ""; + string fallback = "fallback"; + + // Act + var result = rawValue.Coalesce(fallback); + + // Assert + Assert.Equal(fallback, result); + } + + [Fact] + public void Coalesce_WithNullRawValue_ShouldReturnFallback() + { + // Arrange + string? rawValue = null; + string fallback = "fallback"; + + // Act + var result = rawValue.Coalesce(fallback); + + // Assert + Assert.Equal(fallback, result); + } + + [Fact] + public void Coalesce_WithWhitespaceRawValue_ShouldReturnRawValue() + { + // Arrange + string rawValue = " "; + string fallback = "fallback"; + + // Act + var result = rawValue.Coalesce(fallback); + + // Assert + Assert.Equal(rawValue, result); + } + + [Fact] + public void Coalesce_WithNullFallback_ShouldReturnFallback() + { + // Arrange + string rawValue = ""; + string? fallback = null; + + // Act + var result = rawValue.Coalesce(fallback); + + // Assert + Assert.Null(result); + } + + [Fact] + public void Coalesce_WithBothNull_ShouldReturnNull() + { + // Arrange + string? rawValue = null; + string? fallback = null; + + // Act + var result = rawValue.Coalesce(fallback); + + // Assert + Assert.Null(result); + } + + [Fact] + public void Coalesce_WithBothEmpty_ShouldReturnFallback() + { + // Arrange + string rawValue = ""; + string fallback = ""; + + // Act + var result = rawValue.Coalesce(fallback); + + // Assert + Assert.Equal(fallback, result); + } + + #endregion +} diff --git a/tests/PowerCSharp.Feature.Sanitization.Tests/AssemblyInfo.cs b/tests/PowerCSharp.Feature.Sanitization.Tests/AssemblyInfo.cs new file mode 100644 index 0000000..3ea0106 --- /dev/null +++ b/tests/PowerCSharp.Feature.Sanitization.Tests/AssemblyInfo.cs @@ -0,0 +1,7 @@ +// The static SanitizationEngine holds process-wide mutable state (the configuration provider set +// via SetConfigurationProvider, the security-event logger, and performance counters). Tests that +// exercise DI/module wiring mutate that shared state; running them concurrently with other tests +// in this assembly could make an unrelated test observe settings it never configured. Disabling +// parallelization keeps the suite deterministic — the tradeoff is acceptable given this project's +// size. +[assembly: CollectionBehavior(DisableTestParallelization = true)] diff --git a/tests/PowerCSharp.Feature.Sanitization.Tests/FilePathSanitizationTests.cs b/tests/PowerCSharp.Feature.Sanitization.Tests/FilePathSanitizationTests.cs new file mode 100644 index 0000000..957f3ef --- /dev/null +++ b/tests/PowerCSharp.Feature.Sanitization.Tests/FilePathSanitizationTests.cs @@ -0,0 +1,166 @@ +using PowerCSharp.Feature.Sanitization.Abstractions; + +namespace PowerCSharp.Feature.Sanitization.Tests; + +/// +/// Covers and +/// (CWE-22). Every call passes an +/// explicit instance rather than relying on the ambient +/// configuration provider. +/// +public class FilePathSanitizationTests +{ + [Fact] + public void Null_Input_Returns_Unchanged_Empty() + { + var result = SanitizationEngine.SanitizeForFilePath(null, new SanitizationSettings()); + + Assert.False(result.WasModified); + Assert.False(result.IsRejected); + Assert.Equal(string.Empty, result.SanitizedValue); + } + + [Fact] + public void Disabled_Setting_Passes_Path_Through_Unvalidated() + { + var settings = new SanitizationSettings { EnableFilePathSanitization = false }; + + var result = SanitizationEngine.SanitizeForFilePath("../../etc/passwd", settings); + + Assert.False(result.IsRejected); + Assert.Equal("../../etc/passwd", result.SanitizedValue); + } + + [Fact] + public void Strict_Mode_Accepts_A_Plain_Relative_Path() + { + var result = SanitizationEngine.SanitizeForFilePath("folder/file.txt", new SanitizationSettings()); + + Assert.False(result.IsRejected); + Assert.Equal("folder/file.txt", result.SanitizedValue); + } + + [Theory] + [InlineData("../../etc/passwd")] + [InlineData("/etc/passwd")] + [InlineData(@"C:\Windows\System32\cmd.exe")] + [InlineData("..%2f..%2fsecret")] + public void Strict_Mode_Rejects_Traversal_And_Absolute_Paths(string path) + { + var result = SanitizationEngine.SanitizeForFilePath(path, new SanitizationSettings()); + + Assert.True(result.IsRejected); + } + + [Fact] + public void Strict_Mode_Rejects_Windows_Reserved_Device_Names() + { + var result = SanitizationEngine.SanitizeForFilePath("CON.txt", new SanitizationSettings()); + + Assert.True(result.IsRejected); + } + + [Fact] + public void Strict_Mode_With_Reserved_Name_Validation_Disabled_Accepts_Reserved_Name() + { + var settings = new SanitizationSettings { ValidateWindowsReservedNames = false }; + + var result = SanitizationEngine.SanitizeForFilePath("CON.txt", settings); + + Assert.False(result.IsRejected); + } + + [Fact] + public void Strict_Mode_Extension_Allowlist_Accepts_Matching_Extension() + { + var settings = new SanitizationSettings { AllowedFileExtensions = new[] { "txt" } }; + + var result = SanitizationEngine.SanitizeForFilePath("notes.txt", settings); + + Assert.False(result.IsRejected); + } + + [Fact] + public void Strict_Mode_Extension_Allowlist_Rejects_Non_Matching_Extension() + { + var settings = new SanitizationSettings { AllowedFileExtensions = new[] { "txt" } }; + + var result = SanitizationEngine.SanitizeForFilePath("notes.exe", settings); + + Assert.True(result.IsRejected); + } + + [Fact] + public void Strict_Mode_Base_Directory_Allowlist_Accepts_Path_Within_Directory() + { + var baseDir = Path.Combine(Path.GetTempPath(), $"sanitization-test-{Guid.NewGuid()}"); + var settings = new SanitizationSettings { AllowedBaseDirectories = new[] { baseDir } }; + + var result = SanitizationEngine.SanitizeForFilePath("subfolder/file.txt", settings); + + Assert.False(result.IsRejected); + } + + [Fact] + public void Strict_Mode_Base_Directory_Allowlist_Rejects_Escape_Attempt() + { + var baseDir = Path.Combine(Path.GetTempPath(), $"sanitization-test-{Guid.NewGuid()}"); + var settings = new SanitizationSettings { AllowedBaseDirectories = new[] { baseDir } }; + + var result = SanitizationEngine.SanitizeForFilePath("../escape.txt", settings); + + Assert.True(result.IsRejected); + } + + [Fact] + public void Legacy_Mode_Strips_Parent_Directory_References() + { + var settings = new SanitizationSettings { UseStrictValidation = false }; + + var result = SanitizationEngine.SanitizeForFilePath("a/../b", settings); + + Assert.True(result.WasModified); + Assert.Equal("a/b", result.SanitizedValue); + } + + [Fact] + public void Legacy_Mode_Leaves_Already_Clean_Path_Unchanged() + { + var settings = new SanitizationSettings { UseStrictValidation = false }; + + var result = SanitizationEngine.SanitizeForFilePath("folder/file.txt", settings); + + Assert.False(result.WasModified); + Assert.Equal("folder/file.txt", result.SanitizedValue); + } + + [Fact] + public void SanitizeCorrelationIdForPath_Replaces_Invalid_Characters() + { + // SanitizeCorrelationIdForPath consults Path.GetInvalidFileNameChars(), which is + // platform-dependent: on Windows it includes ':' and '*', but those are legal in Unix file + // names, so this assertion sticks to the small set (NUL and '/') that is invalid on every + // platform .NET targets, to keep the test portable. + var result = SanitizationEngine.SanitizeCorrelationIdForPath("abc\0def/ghi"); + + Assert.DoesNotContain('\0', result); + Assert.DoesNotContain('/', result); + } + + [Fact] + public void SanitizeCorrelationIdForPath_Truncates_To_Fifty_Characters() + { + var longId = new string('x', 100); + + var result = SanitizationEngine.SanitizeCorrelationIdForPath(longId); + + Assert.Equal(50, result.Length); + } + + [Fact] + public void SanitizeCorrelationIdForPath_Null_Or_Empty_Returns_Empty() + { + Assert.Equal(string.Empty, SanitizationEngine.SanitizeCorrelationIdForPath(null!)); + Assert.Equal(string.Empty, SanitizationEngine.SanitizeCorrelationIdForPath(string.Empty)); + } +} diff --git a/tests/PowerCSharp.Feature.Sanitization.Tests/LogInjectionSanitizationTests.cs b/tests/PowerCSharp.Feature.Sanitization.Tests/LogInjectionSanitizationTests.cs new file mode 100644 index 0000000..ebb6cd0 --- /dev/null +++ b/tests/PowerCSharp.Feature.Sanitization.Tests/LogInjectionSanitizationTests.cs @@ -0,0 +1,118 @@ +using PowerCSharp.Feature.Sanitization.Abstractions; +using PowerCSharp.Feature.Sanitization.Abstractions.Enums; + +namespace PowerCSharp.Feature.Sanitization.Tests; + +/// +/// Covers (CWE-117). Every call passes an +/// explicit instance rather than relying on the ambient +/// configuration provider, so these tests are independent of any DI/module wiring under test +/// elsewhere in this assembly. +/// +public class LogInjectionSanitizationTests +{ + [Fact] + public void Null_Input_Returns_Unchanged_Empty() + { + var result = SanitizationEngine.SanitizeForLogInjection(null, new SanitizationSettings()); + + Assert.False(result.WasModified); + Assert.Equal(string.Empty, result.SanitizedValue); + Assert.Equal(SanitizationType.LogInjection, result.SanitizationType); + } + + [Fact] + public void Empty_Input_Returns_Unchanged_Empty() + { + var result = SanitizationEngine.SanitizeForLogInjection(string.Empty, new SanitizationSettings()); + + Assert.False(result.WasModified); + Assert.Equal(string.Empty, result.SanitizedValue); + } + + [Fact] + public void Input_Without_Control_Characters_Is_Unchanged() + { + var result = SanitizationEngine.SanitizeForLogInjection("a perfectly normal log line", new SanitizationSettings()); + + Assert.False(result.WasModified); + Assert.Equal("a perfectly normal log line", result.SanitizedValue); + } + + [Fact] + public void Disabled_Setting_Passes_Control_Characters_Through() + { + var settings = new SanitizationSettings { EnableLogSanitization = false }; + + var result = SanitizationEngine.SanitizeForLogInjection("line1\r\nline2", settings); + + Assert.False(result.WasModified); + Assert.Equal("line1\r\nline2", result.SanitizedValue); + } + + [Fact] + public void Remove_Strategy_Strips_CrLf_And_Html_Encodes_Remainder() + { + var settings = new SanitizationSettings { LogSanitizationStrategy = SanitizationStrategy.Remove }; + + var result = SanitizationEngine.SanitizeForLogInjection("value\r\nwith", settings); + + Assert.True(result.WasModified); + Assert.DoesNotContain('\r', result.SanitizedValue); + Assert.DoesNotContain('\n', result.SanitizedValue); + Assert.Equal("valuewith<tag>", result.SanitizedValue); + } + + [Fact] + public void ReplaceWithSpace_Strategy_Replaces_Control_Characters_With_Spaces() + { + var settings = new SanitizationSettings { LogSanitizationStrategy = SanitizationStrategy.ReplaceWithSpace }; + + var result = SanitizationEngine.SanitizeForLogInjection("a\r\nb", settings); + + Assert.True(result.WasModified); + Assert.Equal("a b", result.SanitizedValue); + } + + [Fact] + public void PreserveTabCharacters_Keeps_Tabs_But_Still_Removes_Other_Control_Characters() + { + var settings = new SanitizationSettings + { + LogSanitizationStrategy = SanitizationStrategy.Remove, + PreserveTabCharacters = true + }; + + var result = SanitizationEngine.SanitizeForLogInjection("a\tb\r\nc", settings); + + Assert.Contains('\t', result.SanitizedValue); + Assert.DoesNotContain('\r', result.SanitizedValue); + Assert.DoesNotContain('\n', result.SanitizedValue); + Assert.Equal("a\tbc", result.SanitizedValue); + } + + [Theory] + [InlineData(SanitizationStrategy.HtmlEncode)] + [InlineData(SanitizationStrategy.UrlEncode)] + [InlineData(SanitizationStrategy.JsonEncode)] + public void Encoding_Strategies_Remove_Raw_Control_Characters_Without_Throwing(SanitizationStrategy strategy) + { + var settings = new SanitizationSettings { LogSanitizationStrategy = strategy }; + + var result = SanitizationEngine.SanitizeForLogInjection("inject\r\nme