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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
17 changes: 9 additions & 8 deletions .github/workflows/release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -5,9 +5,9 @@ on:
workflow_dispatch:
inputs:
version:
description: 'Package version (e.g. 0.2.0)'
description: 'Package version (e.g. 0.4.0)'
required: true
default: '0.2.0'
default: '0.4.0'

permissions:
contents: write # create the GitHub Release
Expand Down Expand Up @@ -39,12 +39,13 @@ jobs:
- run: dotnet test -c Release --no-build

- name: Pack
run: >
dotnet pack src/RedactWire/RedactWire.csproj
-c Release --no-build
-p:Version=${{ steps.ver.outputs.version }}
-p:ContinuousIntegrationBuild=true
-o artifacts
run: |
for proj in src/RedactWire/RedactWire.csproj src/RedactWire.Structured/RedactWire.Structured.csproj; do
dotnet pack "$proj" -c Release --no-build \
-p:Version=${{ steps.ver.outputs.version }} \
-p:ContinuousIntegrationBuild=true \
-o artifacts
done

- name: NuGet login (Trusted Publishing, OIDC)
uses: NuGet/login@8d196754b4036150537f80ac539e15c2f1028841
Expand Down
36 changes: 36 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
# Changelog

All notable changes to RedactWire. This project is pre-1.0 — breaking changes can land in
minor versions.

## 0.4.0

### ⚠️ Breaking

- **Structured scanning (JSON / XML / object) moved out of core** into a new opt-in package
**`RedactWire.Structured`**. The core `RedactWire` package no longer depends on
`System.Text.Json` — string-only consumers carry one less dependency.
- **`Redactor.DetectJson` / `DetectXml` / `DetectObject` removed.** Reference the
`RedactWire.Structured` package and call them on a detector instead:
```csharp
// before
Redactor.DetectJson(json);
// after (after `dotnet add package RedactWire.Structured`)
Redactor.Default.DetectJson(json); // shared static detector
detector.DetectJson(json, culture); // or your own builder/detector
```
`DetectJson/DetectXml/DetectObject` are extension methods on `PiiDetector` (namespace
`RedactWire`), so they light up automatically once the add-on is referenced. Method
signatures and detection behavior are unchanged — this is a packaging move only.

### Added

- **Secret detection** — `AddSecretDetection()` enables provider-prefixed API keys/tokens
(OpenAI, AWS access-key-id, GitHub, Stripe, Slack, Google, SendGrid, npm), JWT and PEM
private keys, as `PiiType.Secret` (provider in `Subtype`). On by default in `Redactor`.
- **Remove / replace built-in rules** — `RemoveRule(type[, subtype])`, `RemoveRules(predicate)`,
`ReplaceInvariantRule(rule)`, `ReplaceRule(culture, rule)`. `IPiiRule` now exposes `Subtype`.
- **Region-based culture resolution** — a country's languages share one pack
(`en-IN`/`hi-IN`, `en-CA`/`fr-CA`, Singapore's four languages); same language across
countries stays distinct (`zh-CN`/`zh-TW`/`zh-HK`/`zh-SG`).
- More country packs (now 50+), incl. Malaysia (MyKad) and Australia Medicare.
17 changes: 9 additions & 8 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -22,14 +22,15 @@
namespace). Use `Redactor.Detect/HasPii/Redact` for zero-config calls.

## Architecture
- Core library (`src/RedactWire`) multi-targets **net8.0;netstandard2.0**. Dependencies:
`Microsoft.Extensions.DependencyInjection.Abstractions` (contracts-only, both TFMs) so
the DI bootstrap ships in core; `System.Text.Json` only on netstandard2.0 (built into
net8). We deliberately keep the package count low — no separate extension NuGets.
- Structured scanning lives in `Structured/`: `DetectJson` (System.Text.Json),
`DetectXml` (System.Xml, **XXE-safe**: DtdProcessing.Prohibit + null resolver),
`DetectObject` (reflection; cycle detection, depth cap, collections, skips framework
types). All are extension methods on `PiiDetector`; string values only.
- Core library (`src/RedactWire`) targets **netstandard2.0**; its only dependency is
`Microsoft.Extensions.DependencyInjection.Abstractions` (contracts-only) so the DI
bootstrap ships in core. **No `System.Text.Json`** — keep it that way.
- Structured scanning is a separate opt-in package, **`src/RedactWire.Structured`**
(depends on core + `System.Text.Json`): `DetectJson` (System.Text.Json), `DetectXml`
(System.Xml, **XXE-safe**: DtdProcessing.Prohibit + null resolver), `DetectObject`
(reflection; cycle detection, depth cap, collections, skips framework types). All are
extension methods on `PiiDetector` in namespace `RedactWire`; string values only. The
static `Redactor` has NO structured forwarders — call `Redactor.Default.DetectJson(...)`.
- Three ways to use it:
1. Static `Redactor` facade — zero bootstrap.
2. `PiiDetectorBuilder` — manual configuration.
Expand Down
32 changes: 21 additions & 11 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -37,9 +37,9 @@ that drives overlap resolution.
- **Severity model** (`Critical > High > Medium > Low`): the primary key for overlap
resolution — a higher-severity match wins over an overlapping lower-severity one,
even at lower confidence.
- **Structured scanning:** detect PII inside **JSON**, **XML**, and **object graphs**,
with each hit located by JSONPath / XPath / property path. Standard libraries only
(`System.Text.Json`, `System.Xml`, reflection); XML is parsed safely (no XXE).
- **Structured scanning** (opt-in **`RedactWire.Structured`** add-on): detect PII inside
**JSON**, **XML**, and **object graphs**, each hit located by JSONPath / XPath / property
path. Kept out of core so plain-string users don't pull `System.Text.Json`.
- **Redaction:** mask (length-preserving), remove, type-label, or a custom replacement.
- **Honest "no PII":** a requested culture with no rule pack is flagged
`Supported = false` instead of silently "passing".
Expand Down Expand Up @@ -180,26 +180,35 @@ strings need entropy/context and are a later phase. See [`docs/rules/secrets.md`

## Structured scanning (JSON / XML / objects)

Scan structured data and get each match with its location. Available on `PiiDetector`
(and the static `Redactor`).
Add-on package — keeps `System.Text.Json` out of the core:

```bash
dotnet add package RedactWire.Structured
```

Scan structured data and get each match with its location. `DetectJson/DetectXml/DetectObject`
are extension methods on `PiiDetector`, so they light up once the add-on is referenced
(use `Redactor.Default.DetectJson(...)` for the shared static detector).

```csharp
foreach (var h in Redactor.DetectJson("""{"user":{"email":"a@b.com"},"ssn":"123-45-6789"}"""))
var detector = PiiDetectorBuilder.CreateDefault().AddCulture(new CultureInfo("en-US")).Build();

foreach (var h in detector.DetectJson("""{"user":{"email":"a@b.com"},"ssn":"123-45-6789"}"""))
Console.WriteLine($"{h.Path}: {h.Match.Type}");
// $.user.email: Email
// $.ssn: SocialSecurity

Redactor.DetectXml("<u email=\"a@b.com\"/>"); // -> /u/@email
Redactor.DetectObject(myPoco); // -> User.Contacts[0].Phone
detector.DetectXml("<u email=\"a@b.com\"/>"); // -> /u/@email
detector.DetectObject(myPoco); // -> User.Contacts[0].Phone
```

Notes:
- Only **string** values are scanned (a number that lost its formatting isn't reliable PII).
- XML is parsed with DTD processing prohibited and no external resolver — **XXE-safe**.
- Object scanning walks public properties/fields, handles collections/dictionaries,
detects cycles, caps depth, and does not recurse into framework types.
- `System.Text.Json` is built into modern .NET; on `netstandard2.0`/.NET Framework it
comes as a NuGet dependency (the library multi-targets so net8+ stays clean).
- The add-on uses `System.Text.Json` (JSON) and BCL `System.Xml` / reflection (XML, objects).
This is the only reason `System.Text.Json` is split out of the core package.

## Extending

Expand Down Expand Up @@ -284,7 +293,8 @@ yield return new RuleHit(value, start, length, 0.9,

| Path | What |
|---|---|
| `src/RedactWire` | the library (+ DI bootstrap) |
| `src/RedactWire` | the core library (detection / validation / redaction + DI bootstrap) |
| `src/RedactWire.Structured` | JSON/XML/object scanning add-on (`System.Text.Json`) |
| `src/RedactWire.Cli` | command-line scanner / redactor |
| `samples/RedactWire.Sample.Web` | ASP.NET Core Razor Pages PII tester |
| `tests/RedactWire.Tests` | xUnit tests |
Expand Down
2 changes: 2 additions & 0 deletions RedactWire.slnx
Original file line number Diff line number Diff line change
Expand Up @@ -4,9 +4,11 @@
</Folder>
<Folder Name="/src/">
<Project Path="src/RedactWire.Cli/RedactWire.Cli.csproj" />
<Project Path="src/RedactWire.Structured/RedactWire.Structured.csproj" />
<Project Path="src/RedactWire/RedactWire.csproj" />
</Folder>
<Folder Name="/tests/">
<Project Path="tests/RedactWire.Structured.Tests/RedactWire.Structured.Tests.csproj" />
<Project Path="tests/RedactWire.Tests/RedactWire.Tests.csproj" />
</Folder>
</Solution>
6 changes: 6 additions & 0 deletions samples/RedactWire.Sample.Web/Pages/Shared/_Layout.cshtml
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,12 @@
<li class="nav-item">
<a class="nav-link text-dark" asp-area="" asp-page="/Index">Home</a>
</li>
<li class="nav-item">
<a class="nav-link text-dark" asp-area="" asp-page="/StructuredJson">Structured · JSON</a>
</li>
<li class="nav-item">
<a class="nav-link text-dark" asp-area="" asp-page="/StructuredXml">Structured · XML</a>
</li>
<li class="nav-item">
<a class="nav-link text-dark" asp-area="" asp-page="/Privacy">Privacy</a>
</li>
Expand Down
62 changes: 62 additions & 0 deletions samples/RedactWire.Sample.Web/Pages/StructuredJson.cshtml
Original file line number Diff line number Diff line change
@@ -0,0 +1,62 @@
@* SPDX-License-Identifier: Apache-2.0 *@
@* Copyright 2026 Adam Yang, Object IT Limited, Auckland, NZ *@
@page
@model StructuredJsonModel
@{
ViewData["Title"] = "RedactWire — JSON scan";
}

<h1 class="h3 mb-3">Structured scan — JSON</h1>
<p class="text-muted">
<code>RedactWire.Structured</code> add-on: <code>detector.DetectJson(...)</code> locates
each PII hit by JSONPath.
</p>

<form method="post">
<div class="row g-2 align-items-end mb-2">
<div class="col-auto">
<label class="form-label mb-0" for="Culture">Culture</label>
<select class="form-select form-select-sm" id="Culture" name="Culture">
@foreach (var c in StructuredJsonModel.Cultures)
{
<option value="@c" selected="@(c == Model.Culture)">@c</option>
}
</select>
</div>
</div>
<div class="mb-3">
<textarea class="form-control font-monospace" id="Input" name="Input" rows="10">@Model.Input</textarea>
</div>
<button type="submit" class="btn btn-primary">Scan JSON</button>
</form>

@if (Model.Error is not null)
{
<div class="alert alert-danger mt-3 py-2">@Model.Error</div>
}

@if (Model.Results is not null)
{
<hr />
<p><strong>@Model.Results.Count match(es)</strong></p>
@if (Model.Results.Count > 0)
{
<table class="table table-sm table-striped">
<thead>
<tr><th>Path (JSONPath)</th><th>Type</th><th>Value</th><th>Severity</th><th>Conf</th></tr>
</thead>
<tbody>
@foreach (var h in Model.Results.OrderBy(h => h.Path))
{
<tr>
<td><code>@h.Path</code></td>
<td>@(h.Match.Subtype ?? h.Match.Type.ToString())</td>
<td><code>@h.Match.Value</code></td>
<td>@h.Match.Severity</td>
<td>@h.Match.Confidence.ToString("0.00")</td>
</tr>
}
</tbody>
</table>
}
}
44 changes: 44 additions & 0 deletions samples/RedactWire.Sample.Web/Pages/StructuredJson.cshtml.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
// SPDX-License-Identifier: Apache-2.0
// Copyright 2026 Adam Yang, Object IT Limited, Auckland, NZ

using System.Globalization;
using System.Text.Json;
using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.Mvc.RazorPages;
using RedactWire; // PiiDetector + DetectJson extension method (RedactWire.Structured)

namespace RedactWire.Sample.Web.Pages;

public class StructuredJsonModel : PageModel
{
private readonly PiiDetector _detector; // injected singleton (see Program.cs)

public StructuredJsonModel(PiiDetector detector) => _detector = detector;

[BindProperty]
public string? Input { get; set; }

// Scope to one country — the DI detector has every built-in pack, so scanning against
// all of them would surface a generic 4-digit "postcode" hit per country (noise).
[BindProperty]
public string Culture { get; set; } = "en-US";

public static IReadOnlyList<string> Cultures => PiiDetectorBuilder.AvailableCultures;

public IReadOnlyList<StructuredPiiMatch>? Results { get; private set; }
public string? Error { get; private set; }

private const string Sample =
"{\n \"user\": { \"email\": \"john@x.co.nz\", \"ssn\": \"123-45-6789\" },\n" +
" \"contacts\": [ { \"phone\": \"(415) 555-0132\" } ],\n" +
" \"card\": \"4242 4242 4242 4242\"\n}";

public void OnGet() => Input ??= Sample;

public void OnPost()
{
if (string.IsNullOrWhiteSpace(Input)) return;
try { Results = _detector.DetectJson(Input, new CultureInfo(Culture)); }
catch (JsonException ex) { Error = "Invalid JSON: " + ex.Message; }
}
}
62 changes: 62 additions & 0 deletions samples/RedactWire.Sample.Web/Pages/StructuredXml.cshtml
Original file line number Diff line number Diff line change
@@ -0,0 +1,62 @@
@* SPDX-License-Identifier: Apache-2.0 *@
@* Copyright 2026 Adam Yang, Object IT Limited, Auckland, NZ *@
@page
@model StructuredXmlModel
@{
ViewData["Title"] = "RedactWire — XML scan";
}

<h1 class="h3 mb-3">Structured scan — XML</h1>
<p class="text-muted">
<code>RedactWire.Structured</code> add-on: <code>detector.DetectXml(...)</code> locates
each PII hit by XPath. Parsed XXE-safe (DTDs prohibited).
</p>

<form method="post">
<div class="row g-2 align-items-end mb-2">
<div class="col-auto">
<label class="form-label mb-0" for="Culture">Culture</label>
<select class="form-select form-select-sm" id="Culture" name="Culture">
@foreach (var c in StructuredXmlModel.Cultures)
{
<option value="@c" selected="@(c == Model.Culture)">@c</option>
}
</select>
</div>
</div>
<div class="mb-3">
<textarea class="form-control font-monospace" id="Input" name="Input" rows="10">@Model.Input</textarea>
</div>
<button type="submit" class="btn btn-primary">Scan XML</button>
</form>

@if (Model.Error is not null)
{
<div class="alert alert-danger mt-3 py-2">@Model.Error</div>
}

@if (Model.Results is not null)
{
<hr />
<p><strong>@Model.Results.Count match(es)</strong></p>
@if (Model.Results.Count > 0)
{
<table class="table table-sm table-striped">
<thead>
<tr><th>Path (XPath)</th><th>Type</th><th>Value</th><th>Severity</th><th>Conf</th></tr>
</thead>
<tbody>
@foreach (var h in Model.Results.OrderBy(h => h.Path))
{
<tr>
<td><code>@h.Path</code></td>
<td>@(h.Match.Subtype ?? h.Match.Type.ToString())</td>
<td><code>@h.Match.Value</code></td>
<td>@h.Match.Severity</td>
<td>@h.Match.Confidence.ToString("0.00")</td>
</tr>
}
</tbody>
</table>
}
}
43 changes: 43 additions & 0 deletions samples/RedactWire.Sample.Web/Pages/StructuredXml.cshtml.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
// SPDX-License-Identifier: Apache-2.0
// Copyright 2026 Adam Yang, Object IT Limited, Auckland, NZ

using System.Globalization;
using System.Xml;
using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.Mvc.RazorPages;
using RedactWire; // PiiDetector + DetectXml extension method (RedactWire.Structured)

namespace RedactWire.Sample.Web.Pages;

public class StructuredXmlModel : PageModel
{
private readonly PiiDetector _detector; // injected singleton (see Program.cs)

public StructuredXmlModel(PiiDetector detector) => _detector = detector;

[BindProperty]
public string? Input { get; set; }

// Scope to one country — the DI detector has every built-in pack, so scanning against
// all of them would surface a generic 4-digit "postcode" hit per country (noise).
[BindProperty]
public string Culture { get; set; } = "en-US";

public static IReadOnlyList<string> Cultures => PiiDetectorBuilder.AvailableCultures;

public IReadOnlyList<StructuredPiiMatch>? Results { get; private set; }
public string? Error { get; private set; }

private const string Sample =
"<account email=\"john@x.co.nz\">\n <ssn>123-45-6789</ssn>\n" +
" <phone>(415) 555-0132</phone>\n <card>4242 4242 4242 4242</card>\n</account>";

public void OnGet() => Input ??= Sample;

public void OnPost()
{
if (string.IsNullOrWhiteSpace(Input)) return;
try { Results = _detector.DetectXml(Input, new CultureInfo(Culture)); }
catch (XmlException ex) { Error = "Invalid XML: " + ex.Message; }
}
}
Loading
Loading