diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index c3839df..2872685 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -1,13 +1,22 @@ name: Release on: - push: - tags: ["v*"] + workflow_dispatch: + inputs: + tag: + description: "Existing release tag to (re)publish, e.g. v0.4.0" + required: true + type: string permissions: contents: write # create the GitHub Release id-token: write # NuGet Trusted Publishing (OIDC) +# Release is manual-only: the tag to publish comes from the workflow_dispatch input. +# Every job/step uses REF_NAME (not GITHUB_REF_NAME) and checks out this tag. +env: + REF_NAME: ${{ github.event.inputs.tag }} + jobs: release: runs-on: ubuntu-latest @@ -16,10 +25,10 @@ jobs: - name: Verify tag matches VersionPrefix run: | - VERSION="${GITHUB_REF_NAME#v}" + VERSION="${REF_NAME#v}" PREFIX=$(grep -oPm1 '(?<=)[^<]+' Directory.Build.props) if [ "$VERSION" != "$PREFIX" ]; then - echo "Tag $GITHUB_REF_NAME does not match VersionPrefix $PREFIX in Directory.Build.props" >&2 + echo "Tag $REF_NAME does not match VersionPrefix $PREFIX in Directory.Build.props" >&2 exit 1 fi @@ -59,12 +68,17 @@ jobs: env: GH_TOKEN: ${{ github.token }} run: | - args=(--title "BotWire $GITHUB_REF_NAME" --generate-notes) - notes="docs/release-notes/$GITHUB_REF_NAME.md" + args=(--title "BotWire $REF_NAME" --generate-notes) + notes="docs/release-notes/$REF_NAME.md" if [ -f "$notes" ]; then args+=(--notes-file "$notes") fi - gh release create "$GITHUB_REF_NAME" artifacts/*.nupkg artifacts/*.snupkg "${args[@]}" + # On a re-run the release may already exist; create it, else just upload assets. + if gh release view "$REF_NAME" >/dev/null 2>&1; then + gh release upload "$REF_NAME" artifacts/*.nupkg artifacts/*.snupkg --clobber + else + gh release create "$REF_NAME" artifacts/*.nupkg artifacts/*.snupkg "${args[@]}" + fi npm: needs: release # only publish the JS SDK once the .NET release succeeded @@ -80,10 +94,10 @@ jobs: - name: Verify tag matches package.json version run: | - VERSION="${GITHUB_REF_NAME#v}" + VERSION="${REF_NAME#v}" PKG=$(node -p "require('./package.json').version") if [ "$VERSION" != "$PKG" ]; then - echo "Tag $GITHUB_REF_NAME does not match botwire-js version $PKG in package.json" >&2 + echo "Tag $REF_NAME does not match botwire-js version $PKG in package.json" >&2 exit 1 fi diff --git a/.gitignore b/.gitignore index fc4c216..a1e7b4e 100644 --- a/.gitignore +++ b/.gitignore @@ -31,5 +31,6 @@ yarn-error.log* .DS_Store Thumbs.db .mcp.json +.claude/settings.json samples/RedisShop/web/tsconfig.tsbuildinfo samples/RedisShop/web/.vite/ diff --git a/Directory.Build.props b/Directory.Build.props index ea7217c..fe430fe 100644 --- a/Directory.Build.props +++ b/Directory.Build.props @@ -5,7 +5,7 @@ latest - 0.3.0 + 0.4.0 Object IT Limited diff --git a/README.md b/README.md index f720002..291ef54 100644 --- a/README.md +++ b/README.md @@ -400,10 +400,11 @@ provider charges. When you deploy BotWire: ### Customer PII Handling your customers' personal data is **your responsibility**. BotWire ships -a best-effort PII guard (enabled by default) that **blocks** user messages -matching common patterns — email addresses, phone numbers, and credit-card-like -numbers — before they are sent to the AI provider. Add your own patterns via -`PiiGuard.AdditionalPatterns`: +a best-effort PII guard (enabled by default), backed by +[RedactWire](https://github.com/adamy/RedactWire), that **blocks** user messages +containing personal data — email addresses, credit-card numbers, IP addresses, +IBANs, API keys/secrets, and country-specific identifiers — before they are sent +to the AI provider. Add your own patterns via `PiiGuard.AdditionalPatterns`: ```csharp builder.Services.AddBotWire(opts => @@ -412,7 +413,24 @@ builder.Services.AddBotWire(opts => }); ``` -This guard is regex-based and **not exhaustive**: it will not catch every form +For full control — extra country rule packs, custom `IPiiRule`s, overlap strategy — +configure the underlying RedactWire detector via `PiiGuard.ConfigureDetector`: + +```csharp +using System.Globalization; +using RedactWire; + +builder.Services.AddBotWire(opts => +{ + opts.PiiGuard.ConfigureDetector = b => b + .AddCulture(new CultureInfo("en-US")); // enable a country's ID rule pack +}); +``` + +Secret detection and the culture-agnostic rules (email, credit card, IP, IBAN) +are on by default; the default culture is `CultureInfo.CurrentCulture`. + +This guard is regex/checksum-based and **not exhaustive**: it will not catch every form of personal data, and it rejects rather than redacts. You must confirm, for your own jurisdiction and data, that no personal data you are not permitted to share is sent to your AI provider — for example by tuning the patterns, restricting diff --git a/npm/botwire-js/package.json b/npm/botwire-js/package.json index 7439fd4..c4c43c9 100644 --- a/npm/botwire-js/package.json +++ b/npm/botwire-js/package.json @@ -1,6 +1,6 @@ { "name": "botwire-js", - "version": "0.3.0", + "version": "0.4.0", "description": "Framework-agnostic JS/TS client for the BotWire support API — chat, SSE streaming, and session management with zero DOM dependencies.", "license": "AGPL-3.0-or-later", "author": "Object IT Limited", diff --git a/npm/botwire-js/src/types.ts b/npm/botwire-js/src/types.ts index 11f3d12..567d6d7 100644 --- a/npm/botwire-js/src/types.ts +++ b/npm/botwire-js/src/types.ts @@ -20,7 +20,7 @@ export interface BotWireConfig { /** Result of a non-streaming {@link BotWireClient.chat} call. */ export interface BotWireResponse { - /** Server status, e.g. `Answered`, `NeedHuman`, `TicketCreated`, `Blocked`, `RateLimited`. */ + /** Server status, e.g. `Answered`, `NeedHuman`, `TicketCreated`, `Blocked`, `PiiBlocked`, `RateLimited`. */ status: string; /** The assistant message (or status explanation). */ message: string; diff --git a/npm/botwire-js/src/widget.ts b/npm/botwire-js/src/widget.ts index bad0db0..6861b79 100644 --- a/npm/botwire-js/src/widget.ts +++ b/npm/botwire-js/src/widget.ts @@ -541,9 +541,14 @@ class BotWireWidget extends HTMLElement { if (err instanceof DOMException && err.name === 'AbortError') { // user navigated away / aborted — stay silent } else if (err instanceof BotWireError) { - // server rejected the turn — surface the host-configured message this.errorOccurred = true; - this.appendMessage('sys', this.errorMessage); + // Guard rejections (PII, prompt-injection, message-too-long, rate-limit) carry a + // deliberate, user-facing message from the server — surface it so the user knows + // why their message was refused (e.g. it contained personal data) instead of a + // generic failure. True transport/server faults fall back to the configured error. + const GUARD_STATUSES = ['PiiBlocked', 'Blocked', 'RateLimited']; + const msg = GUARD_STATUSES.includes(err.status) && err.message ? err.message : this.errorMessage; + this.appendMessage('sys', msg); } else { this.appendMessage('sys', 'Connection error. Please try again.'); } diff --git a/npm/botwire-js/tests/widget.spec.ts b/npm/botwire-js/tests/widget.spec.ts index f7eae56..46fa123 100644 --- a/npm/botwire-js/tests/widget.spec.ts +++ b/npm/botwire-js/tests/widget.spec.ts @@ -147,7 +147,21 @@ describe('widget session self-healing', () => { expect(calls.filter(c => c.url.endsWith('/chat/stream'))).toHaveLength(1); expect(calls.filter(c => c.url.endsWith('/session'))).toHaveLength(0); expect(sessionStorage.getItem(STORAGE_KEY)).toBe('valid-token'); - expect(messagesOf(el, '.msg-sys')).toHaveLength(1); + // Guard rejections surface the server's specific message, not the generic error. + expect(messagesOf(el, '.msg-sys')).toEqual(['Message too long.']); + }); + + it('shows the server message for a PiiBlocked rejection, not the generic error', async () => { + sessionStorage.setItem(STORAGE_KEY, 'valid-token'); + const pii = 'Your message contains sensitive information and cannot be processed.'; + stubFetch(() => + jsonResponse(400, { status: 'PiiBlocked', message: pii, sessionToken: 'valid-token' })); + + const el = mountWidget(); + sendMessage(el, 'email me at a@b.com'); + await streamFinished(el); + + expect(messagesOf(el, '.msg-sys')).toEqual([pii]); }); }); diff --git a/samples/RedisShop/web/src/ChatWidget.tsx b/samples/RedisShop/web/src/ChatWidget.tsx index b3bbe5c..d8ea2e4 100644 --- a/samples/RedisShop/web/src/ChatWidget.tsx +++ b/samples/RedisShop/web/src/ChatWidget.tsx @@ -79,7 +79,13 @@ export const ChatWidget = forwardRef(function ChatWidget(_props, ref } } catch (e) { if (controller.signal.aborted) return; // reset/close superseded this turn - const msg = e instanceof BotWireError ? e.message : 'Something went wrong. Please try again.'; + // Guard rejections (PII, prompt-injection, message-too-long, rate-limit) carry a + // deliberate, user-facing message from the server — surface it so the user knows why + // their message was refused (e.g. it contained personal data). Duck-typed on `status` + // so it holds across botwire-js versions; transport faults get a generic message. + const err = e as Partial; + const isGuard = !!err?.status && ['PiiBlocked', 'Blocked', 'RateLimited'].includes(err.status); + const msg = isGuard && err.message ? err.message : 'Something went wrong. Please try again.'; setMessages((m) => replaceLastBotIfEmpty(m, msg)); } finally { if (abortRef.current === controller) { diff --git a/src/BotWire.AspNetCore/BotWireChatService.cs b/src/BotWire.AspNetCore/BotWireChatService.cs index 1cfcce7..43531cd 100644 --- a/src/BotWire.AspNetCore/BotWireChatService.cs +++ b/src/BotWire.AspNetCore/BotWireChatService.cs @@ -67,6 +67,7 @@ internal sealed class BotWireChatService private readonly IAuditLogger _audit; private readonly IOptions _options; private readonly IOptions _piiOptions; + private readonly IOptions _injectionOptions; public BotWireChatService( IAnswerProvider answers, @@ -80,20 +81,22 @@ public BotWireChatService( ISummaryCompressor compressor, IAuditLogger audit, IOptions options, - IOptions piiOptions) + IOptions piiOptions, + IOptions injectionOptions) { - _answers = answers; - _sessions = sessions; - _tokens = tokens; - _piiGuard = piiGuard; - _injectionGuard = injectionGuard; - _rateLimiter = rateLimiter; - _rl = rl; - _rlOptions = rlOptions.Value; - _compressor = compressor; - _audit = audit; - _options = options; - _piiOptions = piiOptions; + _answers = answers; + _sessions = sessions; + _tokens = tokens; + _piiGuard = piiGuard; + _injectionGuard = injectionGuard; + _rateLimiter = rateLimiter; + _rl = rl; + _rlOptions = rlOptions.Value; + _compressor = compressor; + _audit = audit; + _options = options; + _piiOptions = piiOptions; + _injectionOptions = injectionOptions; } /// @@ -441,13 +444,13 @@ private static int CountUserMessages(ConversationSession session) if (pii.Blocked) { await _audit.LogAsync(AuditEvents.GuardBlocked(sessionId, "pii"), ct); - return new ChatResult("Blocked", _piiOptions.Value.RejectionMessage, sessionId, null, 400); + return new ChatResult("PiiBlocked", _piiOptions.Value.RejectionMessage, sessionId, null, 400); } if (_injectionGuard.IsInjectionAttempt(message)) { await _audit.LogAsync(AuditEvents.GuardBlocked(sessionId, "prompt_injection"), ct); - return new ChatResult("Blocked", _piiOptions.Value.RejectionMessage, sessionId, null, 400); + return new ChatResult("Blocked", _injectionOptions.Value.RejectionMessage, sessionId, null, 400); } return null; diff --git a/src/BotWire.AspNetCore/BotWireEndpointExtensions.cs b/src/BotWire.AspNetCore/BotWireEndpointExtensions.cs index 2facfeb..b3e3854 100644 --- a/src/BotWire.AspNetCore/BotWireEndpointExtensions.cs +++ b/src/BotWire.AspNetCore/BotWireEndpointExtensions.cs @@ -200,6 +200,10 @@ await service.CommitStreamAsync( private static readonly byte[] _widgetJs = LoadWidgetJs(); + // Content-hash ETag so browsers revalidate cheaply (304) yet pick up a rebuilt widget + // immediately — a long max-age would otherwise serve a stale bundle for up to an hour. + private static readonly string _widgetJsETag = ComputeETag(_widgetJs); + private static byte[] LoadWidgetJs() { var asm = typeof(BotWireEndpointExtensions).Assembly; @@ -210,10 +214,26 @@ private static byte[] LoadWidgetJs() return ms.ToArray(); } + private static string ComputeETag(byte[] bytes) + { + var hash = System.Security.Cryptography.SHA256.HashData(bytes); + return $"\"{Convert.ToHexString(hash, 0, 8)}\""; + } + private static async Task HandleWidgetJs(HttpContext context) { - context.Response.ContentType = "application/javascript; charset=utf-8"; - context.Response.Headers.CacheControl = "public, max-age=3600"; + // must-revalidate: cache is allowed but the browser must check the ETag on every use, + // so a new widget build is served the moment it changes (no hour-long staleness). + context.Response.Headers.CacheControl = "no-cache"; + context.Response.Headers.ETag = _widgetJsETag; + + if (context.Request.Headers.IfNoneMatch.ToString().Contains(_widgetJsETag)) + { + context.Response.StatusCode = StatusCodes.Status304NotModified; + return; + } + + context.Response.ContentType = "application/javascript; charset=utf-8"; await context.Response.Body.WriteAsync(_widgetJs); } diff --git a/src/BotWire.AspNetCore/BotWireOptions.cs b/src/BotWire.AspNetCore/BotWireOptions.cs index f1b2f33..057e7a2 100644 --- a/src/BotWire.AspNetCore/BotWireOptions.cs +++ b/src/BotWire.AspNetCore/BotWireOptions.cs @@ -80,9 +80,9 @@ public sealed class BotWireOptions public PromptInjectionOptions PromptInjection { get; set; } = new(); /// - /// PII detection settings. Enabled by default — blocks user messages matching - /// common personal-data patterns (email, phone, credit-card) before they reach - /// the AI provider. Add your own patterns via . + /// PII detection settings. Enabled by default — blocks user messages containing + /// personal data (email, phone, credit-card, secrets, ...) before they reach the AI + /// provider. Backed by RedactWire; customize via . /// public PiiGuardOptions PiiGuard { get; set; } = new(); diff --git a/src/BotWire.AspNetCore/BotWireServiceCollectionExtensions.cs b/src/BotWire.AspNetCore/BotWireServiceCollectionExtensions.cs index 77e4884..c6ab12b 100644 --- a/src/BotWire.AspNetCore/BotWireServiceCollectionExtensions.cs +++ b/src/BotWire.AspNetCore/BotWireServiceCollectionExtensions.cs @@ -93,11 +93,13 @@ public static IServiceCollection AddBotWire( o.Enabled = opts.PiiGuard.Enabled; o.RejectionMessage = opts.PiiGuard.RejectionMessage; o.AdditionalPatterns = opts.PiiGuard.AdditionalPatterns; + o.ConfigureDetector = opts.PiiGuard.ConfigureDetector; }, configureRateLimit: o => o.MaxRequestsPerIpPerMinute = opts.MaxRequestsPerIpPerMinute, configureInjection: o => { o.Enabled = opts.PromptInjection.Enabled; + o.RejectionMessage = opts.PromptInjection.RejectionMessage; o.AdditionalPatterns = opts.PromptInjection.AdditionalPatterns; }); diff --git a/src/BotWire.AspNetCore/botwire.js b/src/BotWire.AspNetCore/botwire.js index 2afa9f1..004cf37 100644 --- a/src/BotWire.AspNetCore/botwire.js +++ b/src/BotWire.AspNetCore/botwire.js @@ -1,5 +1,5 @@ -"use strict";(()=>{var m="/support",o=class extends Error{constructor(e,i,s){super(i);this.status=e;this.httpStatus=s;this.name="BotWireError"}},l=class{constructor(t={}){this._sessionToken=null;this.endpoint=x(t.endpoint??m),this.publicKey=t.publicKey;let e=t.fetch??globalThis.fetch;if(!e)throw new Error("BotWireClient: no global fetch available \u2014 pass config.fetch");this._fetch=e.bind(globalThis)}getSessionToken(){return this._sessionToken}setSessionToken(t){this._sessionToken=t}async initSession(t){let e=await this.post(`${this.endpoint}/session`,{},t);if(!e.ok)throw await this.toError(e);let i=await e.json();return this._sessionToken=i.sessionToken,{sessionToken:i.sessionToken,needsName:i.needsName??!1,errorMessage:i.errorMessage}}async chat(t,e={}){await this.ensureSession(e.signal);let i=await this.post(`${this.endpoint}/chat`,this.body(t,e),e.signal);await this.staleSession(i)&&(await this.initSession(e.signal),i=await this.post(`${this.endpoint}/chat`,this.body(t,e),e.signal));let s;try{s=await i.json()}catch{throw await this.toError(i)}return s.sessionToken&&(this._sessionToken=s.sessionToken),s}async*streamChat(t,e={}){await this.ensureSession(e.signal);let i=await this.post(`${this.endpoint}/chat/stream`,this.body(t,e),e.signal);if(await this.staleSession(i)&&(await this.initSession(e.signal),i=await this.post(`${this.endpoint}/chat/stream`,this.body(t,e),e.signal)),!i.ok||!i.body)throw await this.toError(i);yield*this.parseSse(i.body)}async ensureSession(t){this._sessionToken||await this.initSession(t)}body(t,e){let i={message:t,sessionToken:this._sessionToken};return e.contactEmail&&(i.contactEmail=e.contactEmail),i}async staleSession(t){if(t.status!==400)return!1;try{if((await t.clone().json()).status==="InvalidSession")return this._sessionToken=null,!0}catch{}return!1}async*parseSse(t){let e=t.getReader(),i=new TextDecoder,s="";try{for(;;){let{value:a,done:r}=await e.read();if(r)break;s+=i.decode(a,{stream:!0});let c;for(;(c=s.indexOf(` -`))!==-1;){let p=s.slice(0,c);if(s=s.slice(c+1),!p.startsWith("data: "))continue;let u=p.slice(6);if(u==="[DONE]"){yield{type:"done"};return}let g=v(u);g&&(yield g)}}}finally{e.releaseLock()}}post(t,e,i){let s={"Content-Type":"application/json"};return this.publicKey&&(s["X-BotWire-Key"]=this.publicKey),this._fetch(t,{method:"POST",headers:s,body:JSON.stringify(e),signal:i})}async toError(t){let e="Error",i=`BotWire request failed (HTTP ${t.status})`;try{let s=await t.clone().json();s.status&&(e=s.status),s.message&&(i=s.message)}catch{}return new o(e,i,t.status)}};function x(n){let t=n.length;for(;t>0&&n.charCodeAt(t-1)===47;)t--;return n.slice(0,t)}function v(n){let t;try{t=JSON.parse(n)}catch{return null}switch(t.type){case"token":return{type:"delta",delta:t.value??""};case"collect_contact":return{type:"collect_contact"};case"escalated":return{type:"escalated",ticketId:t.ticketId??"",message:t.message??""};case"blocked":return{type:"blocked",reason:t.reason??""};default:return null}}var d="botwire_session",f={en:{title:"Support",greeting:"How can we help you today?",placeholder:"Type a message\u2026",sendLabel:"Send",contactPrompt:"Please leave your email address so our team can follow up with you.",emailPlaceholder:"your@email.com",submitLabel:"Submit",cancelLabel:"Cancel",cancelMessage:"You have ended this conversation."},"zh-CN":{title:"\u5728\u7EBF\u5BA2\u670D",greeting:"\u8BF7\u95EE\u6709\u4EC0\u4E48\u53EF\u4EE5\u5E2E\u60A8\uFF1F",placeholder:"\u8F93\u5165\u6D88\u606F\u2026",sendLabel:"\u53D1\u9001",contactPrompt:"\u8BF7\u7559\u4E0B\u60A8\u7684\u90AE\u7BB1\uFF0C\u65B9\u4FBF\u6211\u4EEC\u7684\u56E2\u961F\u8DDF\u8FDB\u3002",emailPlaceholder:"your@email.com",submitLabel:"\u63D0\u4EA4",cancelLabel:"\u53D6\u6D88",cancelMessage:"\u60A8\u5DF2\u7ED3\u675F\u672C\u6B21\u4F1A\u8BDD\u3002"},ja:{title:"\u30B5\u30DD\u30FC\u30C8",greeting:"\u3054\u7528\u4EF6\u3092\u304A\u77E5\u3089\u305B\u304F\u3060\u3055\u3044\u3002",placeholder:"\u30E1\u30C3\u30BB\u30FC\u30B8\u3092\u5165\u529B\u2026",sendLabel:"\u9001\u4FE1",contactPrompt:"\u30E1\u30FC\u30EB\u30A2\u30C9\u30EC\u30B9\u3092\u3054\u8A18\u5165\u3044\u305F\u3060\u3051\u308C\u3070\u3001\u62C5\u5F53\u8005\u3088\u308A\u3054\u9023\u7D61\u3044\u305F\u3057\u307E\u3059\u3002",emailPlaceholder:"your@email.com",submitLabel:"\u9001\u4FE1\u3059\u308B",cancelLabel:"\u30AD\u30E3\u30F3\u30BB\u30EB",cancelMessage:"\u3053\u306E\u4F1A\u8A71\u3092\u7D42\u4E86\u3057\u307E\u3057\u305F\u3002"}};function y(n){if(!n)return"en";let t=n.toLowerCase();return t==="zh"||t.startsWith("zh-")||t.startsWith("zh_")?"zh-CN":t==="ja"||t.startsWith("ja-")||t.startsWith("ja_")?"ja":"en"}function w(n){return n.replace(/\\/g,"\\\\").replace(/'/g,"\\'")}function k(n,t,e){let s=t==="bottom-left"?"left":"right";return` +"use strict";(()=>{var m="/support",o=class extends Error{constructor(e,i,s){super(i);this.status=e;this.httpStatus=s;this.name="BotWireError"}},c=class{constructor(t={}){this._sessionToken=null;this.endpoint=x(t.endpoint??m),this.publicKey=t.publicKey;let e=t.fetch??globalThis.fetch;if(!e)throw new Error("BotWireClient: no global fetch available \u2014 pass config.fetch");this._fetch=e.bind(globalThis)}getSessionToken(){return this._sessionToken}setSessionToken(t){this._sessionToken=t}async initSession(t){let e=await this.post(`${this.endpoint}/session`,{},t);if(!e.ok)throw await this.toError(e);let i=await e.json();return this._sessionToken=i.sessionToken,{sessionToken:i.sessionToken,needsName:i.needsName??!1,errorMessage:i.errorMessage}}async chat(t,e={}){await this.ensureSession(e.signal);let i=await this.post(`${this.endpoint}/chat`,this.body(t,e),e.signal);await this.staleSession(i)&&(await this.initSession(e.signal),i=await this.post(`${this.endpoint}/chat`,this.body(t,e),e.signal));let s;try{s=await i.json()}catch{throw await this.toError(i)}return s.sessionToken&&(this._sessionToken=s.sessionToken),s}async*streamChat(t,e={}){await this.ensureSession(e.signal);let i=await this.post(`${this.endpoint}/chat/stream`,this.body(t,e),e.signal);if(await this.staleSession(i)&&(await this.initSession(e.signal),i=await this.post(`${this.endpoint}/chat/stream`,this.body(t,e),e.signal)),!i.ok||!i.body)throw await this.toError(i);yield*this.parseSse(i.body)}async ensureSession(t){this._sessionToken||await this.initSession(t)}body(t,e){let i={message:t,sessionToken:this._sessionToken};return e.contactEmail&&(i.contactEmail=e.contactEmail),i}async staleSession(t){if(t.status!==400)return!1;try{if((await t.clone().json()).status==="InvalidSession")return this._sessionToken=null,!0}catch{}return!1}async*parseSse(t){let e=t.getReader(),i=new TextDecoder,s="";try{for(;;){let{value:a,done:r}=await e.read();if(r)break;s+=i.decode(a,{stream:!0});let l;for(;(l=s.indexOf(` +`))!==-1;){let d=s.slice(0,l);if(s=s.slice(l+1),!d.startsWith("data: "))continue;let u=d.slice(6);if(u==="[DONE]"){yield{type:"done"};return}let g=v(u);g&&(yield g)}}}finally{e.releaseLock()}}post(t,e,i){let s={"Content-Type":"application/json"};return this.publicKey&&(s["X-BotWire-Key"]=this.publicKey),this._fetch(t,{method:"POST",headers:s,body:JSON.stringify(e),signal:i})}async toError(t){let e="Error",i=`BotWire request failed (HTTP ${t.status})`;try{let s=await t.clone().json();s.status&&(e=s.status),s.message&&(i=s.message)}catch{}return new o(e,i,t.status)}};function x(n){let t=n.length;for(;t>0&&n.charCodeAt(t-1)===47;)t--;return n.slice(0,t)}function v(n){let t;try{t=JSON.parse(n)}catch{return null}switch(t.type){case"token":return{type:"delta",delta:t.value??""};case"collect_contact":return{type:"collect_contact"};case"escalated":return{type:"escalated",ticketId:t.ticketId??"",message:t.message??""};case"blocked":return{type:"blocked",reason:t.reason??""};default:return null}}var h="botwire_session",f={en:{title:"Support",greeting:"How can we help you today?",placeholder:"Type a message\u2026",sendLabel:"Send",contactPrompt:"Please leave your email address so our team can follow up with you.",emailPlaceholder:"your@email.com",submitLabel:"Submit",cancelLabel:"Cancel",cancelMessage:"You have ended this conversation."},"zh-CN":{title:"\u5728\u7EBF\u5BA2\u670D",greeting:"\u8BF7\u95EE\u6709\u4EC0\u4E48\u53EF\u4EE5\u5E2E\u60A8\uFF1F",placeholder:"\u8F93\u5165\u6D88\u606F\u2026",sendLabel:"\u53D1\u9001",contactPrompt:"\u8BF7\u7559\u4E0B\u60A8\u7684\u90AE\u7BB1\uFF0C\u65B9\u4FBF\u6211\u4EEC\u7684\u56E2\u961F\u8DDF\u8FDB\u3002",emailPlaceholder:"your@email.com",submitLabel:"\u63D0\u4EA4",cancelLabel:"\u53D6\u6D88",cancelMessage:"\u60A8\u5DF2\u7ED3\u675F\u672C\u6B21\u4F1A\u8BDD\u3002"},ja:{title:"\u30B5\u30DD\u30FC\u30C8",greeting:"\u3054\u7528\u4EF6\u3092\u304A\u77E5\u3089\u305B\u304F\u3060\u3055\u3044\u3002",placeholder:"\u30E1\u30C3\u30BB\u30FC\u30B8\u3092\u5165\u529B\u2026",sendLabel:"\u9001\u4FE1",contactPrompt:"\u30E1\u30FC\u30EB\u30A2\u30C9\u30EC\u30B9\u3092\u3054\u8A18\u5165\u3044\u305F\u3060\u3051\u308C\u3070\u3001\u62C5\u5F53\u8005\u3088\u308A\u3054\u9023\u7D61\u3044\u305F\u3057\u307E\u3059\u3002",emailPlaceholder:"your@email.com",submitLabel:"\u9001\u4FE1\u3059\u308B",cancelLabel:"\u30AD\u30E3\u30F3\u30BB\u30EB",cancelMessage:"\u3053\u306E\u4F1A\u8A71\u3092\u7D42\u4E86\u3057\u307E\u3057\u305F\u3002"}};function y(n){if(!n)return"en";let t=n.toLowerCase();return t==="zh"||t.startsWith("zh-")||t.startsWith("zh_")?"zh-CN":t==="ja"||t.startsWith("ja-")||t.startsWith("ja_")?"ja":"en"}function w(n){return n.replace(/\\/g,"\\\\").replace(/'/g,"\\'")}function k(n,t,e){let s=t==="bottom-left"?"left":"right";return` *,*::before,*::after{box-sizing:border-box;margin:0;padding:0} #bubble{ @@ -146,7 +146,7 @@ border-radius:0;border-top-left-radius:16px;border-top-right-radius:16px} #bubble{bottom:16px;${s}:16px} } -`}var b='',E='',T='',h=class extends HTMLElement{constructor(){super();this.streaming=!1;this.streamAbort=null;this.awaitingEmail=!1;this.ticketCreated=!1;this.errorOccurred=!1;this.errorMessage="Something went wrong. Please try again.";this.shadow=this.attachShadow({mode:"open"})}get endpoint(){return this.dataset.endpoint??"/support"}get primary(){return this.dataset.primaryColor??"#6366f1"}get position(){return this.dataset.position??"bottom-right"}get publicKey(){return this.dataset.publicKey}get offtopicMessage(){return this.dataset.offtopicMessage}get resetEnabled(){return this.dataset.reset!=="false"}get resetConfirm(){return this.dataset.resetConfirm!=="false"}get langKey(){return y(this.dataset.lang)}t(e){let i=this.dataset[e];return i!==void 0?i:f[this.langKey]?.[e]??f.en[e]}get widgetTitle(){return this.t("title")}get placeholder(){return this.t("placeholder")}get contactPrompt(){return this.t("contactPrompt")}get emailPlaceholder(){return this.t("emailPlaceholder")}get sendLabel(){return this.t("sendLabel")}get submitLabel(){return this.t("submitLabel")}get cancelLabel(){return this.t("cancelLabel")}get cancelMessage(){return this.t("cancelMessage")}get greeting(){return this.t("greeting")}get starters(){return(this.dataset.starters??"").split("|").map(e=>e.trim()).filter(e=>e.length>0)}connectedCallback(){this.mount(),this.client=new l({endpoint:this.endpoint,publicKey:this.publicKey}),this.client.setSessionToken(sessionStorage.getItem(d)),this.client.getSessionToken()||this.initSession()}mount(){this.shadow.innerHTML=` +`}var b='',E='',T='',p=class extends HTMLElement{constructor(){super();this.streaming=!1;this.streamAbort=null;this.awaitingEmail=!1;this.ticketCreated=!1;this.errorOccurred=!1;this.errorMessage="Something went wrong. Please try again.";this.shadow=this.attachShadow({mode:"open"})}get endpoint(){return this.dataset.endpoint??"/support"}get primary(){return this.dataset.primaryColor??"#6366f1"}get position(){return this.dataset.position??"bottom-right"}get publicKey(){return this.dataset.publicKey}get offtopicMessage(){return this.dataset.offtopicMessage}get resetEnabled(){return this.dataset.reset!=="false"}get resetConfirm(){return this.dataset.resetConfirm!=="false"}get langKey(){return y(this.dataset.lang)}t(e){let i=this.dataset[e];return i!==void 0?i:f[this.langKey]?.[e]??f.en[e]}get widgetTitle(){return this.t("title")}get placeholder(){return this.t("placeholder")}get contactPrompt(){return this.t("contactPrompt")}get emailPlaceholder(){return this.t("emailPlaceholder")}get sendLabel(){return this.t("sendLabel")}get submitLabel(){return this.t("submitLabel")}get cancelLabel(){return this.t("cancelLabel")}get cancelMessage(){return this.t("cancelMessage")}get greeting(){return this.t("greeting")}get starters(){return(this.dataset.starters??"").split("|").map(e=>e.trim()).filter(e=>e.length>0)}connectedCallback(){this.mount(),this.client=new c({endpoint:this.endpoint,publicKey:this.publicKey}),this.client.setSessionToken(sessionStorage.getItem(h)),this.client.getSessionToken()||this.initSession()}mount(){this.shadow.innerHTML=` -`,this.panel=this.q("#panel"),this.bubble=this.q("#bubble"),this.messages=this.q("#messages"),this.startersBox=this.q("#starters"),this.resetBtn=this.q("#reset"),this.typing=this.q("#typing"),this.inputArea=this.q("#input-area"),this.input=this.q("#input"),this.sendBtn=this.q("#send"),this.contact=this.q("#contact-form"),this.emailIn=this.q("#email-input"),this.cancelBtn=this.q("#contact-cancel"),this.ticket=this.q("#ticket-card"),this.bubble.addEventListener("click",()=>this.toggle()),this.q("#close").addEventListener("click",()=>this.close()),this.resetBtn.addEventListener("click",()=>this.handleReset()),this.sendBtn.addEventListener("click",()=>this.handleSend()),this.input.addEventListener("keydown",e=>{e.key==="Enter"&&!e.shiftKey&&(e.preventDefault(),this.handleSend())}),this.input.addEventListener("input",()=>this.autoResize()),this.contact.addEventListener("submit",e=>{e.preventDefault(),this.handleContactSubmit()}),this.cancelBtn.addEventListener("click",()=>this.handleContactCancel()),this.resetBtn.hidden=!this.resetEnabled,this.renderStarters()}async initSession(){try{let e=await this.client.initSession();sessionStorage.setItem(d,e.sessionToken),e.errorMessage&&(this.errorMessage=e.errorMessage)}catch{}}toggle(){this.panel.hidden?this.open():this.ticketCreated?(this.resetConversation(),this.input.focus()):this.close()}open(){this.ticketCreated&&this.resetConversation(),this.panel.hidden=!1,this.bubble.innerHTML=E,this.bubble.setAttribute("aria-expanded","true"),this.awaitingEmail?this.emailIn.focus():this.input.focus()}resetConversation(){this.streamAbort?.abort(),this.streamAbort=null,this.messages.innerHTML="",this.ticketCreated=!1,this.awaitingEmail=!1,this.streaming=!1,this.errorOccurred=!1,this.contact.hidden=!0,this.ticket.hidden=!0,this.inputArea.hidden=!1,this.sendBtn.disabled=!1,this.emailIn.value="",this.client.setSessionToken(null),sessionStorage.removeItem(d),this.renderStarters(),this.initSession()}handleReset(){if(this.resetConfirm){let e=this.dataset.resetConfirmMessage??"Start a new conversation?";if(typeof confirm=="function"&&!confirm(e))return}this.resetConversation(),!this.panel.hidden&&!this.awaitingEmail&&this.input.focus()}renderStarters(){this.startersBox.innerHTML="";let e=this.starters;if(e.length===0){this.startersBox.hidden=!0;return}for(let i of e){let s=document.createElement("button");s.type="button",s.className="starter",s.textContent=i,s.addEventListener("click",()=>{this.input.value=i,this.handleSend()}),this.startersBox.appendChild(s)}this.startersBox.hidden=!1}close(){this.errorOccurred&&this.resetConversation(),this.panel.hidden=!0,this.bubble.innerHTML=b,this.bubble.setAttribute("aria-expanded","false")}handleSend(){if(this.streaming||this.awaitingEmail)return;let e=this.input.value.trim();e&&(this.input.value="",this.autoResize(),this.startersBox.hidden=!0,this.appendMessage("user",e),this.stream(e))}handleContactSubmit(){let e=this.emailIn.value.trim();e&&(this.contact.hidden=!0,this.awaitingEmail=!1,this.stream("",e))}handleContactCancel(){this.contact.hidden=!0,this.awaitingEmail=!1,this.ticketCreated=!0,this.appendMessage("sys",this.cancelMessage)}async stream(e,i){if(this.streaming)return;this.streaming=!0,this.sendBtn.disabled=!0,this.typing.hidden=!1;let s=new AbortController;this.streamAbort=s;let a=null;try{for await(let r of this.client.streamChat(e,{contactEmail:i,signal:s.signal}))switch(this.typing.hidden=!0,r.type){case"delta":a||(a=this.appendMessage("bot","")),a.textContent+=r.delta,this.scrollBottom();break;case"collect_contact":this.inputArea.hidden=!0,this.contact.hidden=!1,this.awaitingEmail=!0,requestAnimationFrame(()=>{this.scrollBottom(),this.emailIn.focus()});break;case"escalated":this.ticketCreated=!0,this.ticket.hidden=!1,this.ticket.textContent=r.message,this.inputArea.hidden=!0;break;case"blocked":this.appendMessage("bot",this.offtopicMessage??r.reason);break;case"done":break}}catch(r){if(s.signal.aborted)return;this.typing.hidden=!0,r instanceof DOMException&&r.name==="AbortError"||(r instanceof o?(this.errorOccurred=!0,this.appendMessage("sys",this.errorMessage)):this.appendMessage("sys","Connection error. Please try again."))}finally{if(this.streamAbort===s){this.streamAbort=null;let r=this.client.getSessionToken();r&&sessionStorage.setItem(d,r),this.typing.hidden=!0,this.streaming=!1,!this.awaitingEmail&&!this.ticketCreated&&(this.sendBtn.disabled=!1,this.panel.hidden||this.input.focus())}}}appendMessage(e,i){let s=document.createElement("div");return s.className=`msg msg-${e}`,s.textContent=i,this.messages.appendChild(s),this.scrollBottom(),s}scrollBottom(){this.messages.scrollTop=this.messages.scrollHeight}autoResize(){this.input.style.height="auto",this.input.style.height=`${Math.min(this.input.scrollHeight,100)}px`}esc(e){return e.replace(/&/g,"&").replace(//g,">").replace(/"/g,""")}q(e){return this.shadow.querySelector(e)}};customElements.define("botwire-widget",h);})(); +`,this.panel=this.q("#panel"),this.bubble=this.q("#bubble"),this.messages=this.q("#messages"),this.startersBox=this.q("#starters"),this.resetBtn=this.q("#reset"),this.typing=this.q("#typing"),this.inputArea=this.q("#input-area"),this.input=this.q("#input"),this.sendBtn=this.q("#send"),this.contact=this.q("#contact-form"),this.emailIn=this.q("#email-input"),this.cancelBtn=this.q("#contact-cancel"),this.ticket=this.q("#ticket-card"),this.bubble.addEventListener("click",()=>this.toggle()),this.q("#close").addEventListener("click",()=>this.close()),this.resetBtn.addEventListener("click",()=>this.handleReset()),this.sendBtn.addEventListener("click",()=>this.handleSend()),this.input.addEventListener("keydown",e=>{e.key==="Enter"&&!e.shiftKey&&(e.preventDefault(),this.handleSend())}),this.input.addEventListener("input",()=>this.autoResize()),this.contact.addEventListener("submit",e=>{e.preventDefault(),this.handleContactSubmit()}),this.cancelBtn.addEventListener("click",()=>this.handleContactCancel()),this.resetBtn.hidden=!this.resetEnabled,this.renderStarters()}async initSession(){try{let e=await this.client.initSession();sessionStorage.setItem(h,e.sessionToken),e.errorMessage&&(this.errorMessage=e.errorMessage)}catch{}}toggle(){this.panel.hidden?this.open():this.ticketCreated?(this.resetConversation(),this.input.focus()):this.close()}open(){this.ticketCreated&&this.resetConversation(),this.panel.hidden=!1,this.bubble.innerHTML=E,this.bubble.setAttribute("aria-expanded","true"),this.awaitingEmail?this.emailIn.focus():this.input.focus()}resetConversation(){this.streamAbort?.abort(),this.streamAbort=null,this.messages.innerHTML="",this.ticketCreated=!1,this.awaitingEmail=!1,this.streaming=!1,this.errorOccurred=!1,this.contact.hidden=!0,this.ticket.hidden=!0,this.inputArea.hidden=!1,this.sendBtn.disabled=!1,this.emailIn.value="",this.client.setSessionToken(null),sessionStorage.removeItem(h),this.renderStarters(),this.initSession()}handleReset(){if(this.resetConfirm){let e=this.dataset.resetConfirmMessage??"Start a new conversation?";if(typeof confirm=="function"&&!confirm(e))return}this.resetConversation(),!this.panel.hidden&&!this.awaitingEmail&&this.input.focus()}renderStarters(){this.startersBox.innerHTML="";let e=this.starters;if(e.length===0){this.startersBox.hidden=!0;return}for(let i of e){let s=document.createElement("button");s.type="button",s.className="starter",s.textContent=i,s.addEventListener("click",()=>{this.input.value=i,this.handleSend()}),this.startersBox.appendChild(s)}this.startersBox.hidden=!1}close(){this.errorOccurred&&this.resetConversation(),this.panel.hidden=!0,this.bubble.innerHTML=b,this.bubble.setAttribute("aria-expanded","false")}handleSend(){if(this.streaming||this.awaitingEmail)return;let e=this.input.value.trim();e&&(this.input.value="",this.autoResize(),this.startersBox.hidden=!0,this.appendMessage("user",e),this.stream(e))}handleContactSubmit(){let e=this.emailIn.value.trim();e&&(this.contact.hidden=!0,this.awaitingEmail=!1,this.stream("",e))}handleContactCancel(){this.contact.hidden=!0,this.awaitingEmail=!1,this.ticketCreated=!0,this.appendMessage("sys",this.cancelMessage)}async stream(e,i){if(this.streaming)return;this.streaming=!0,this.sendBtn.disabled=!0,this.typing.hidden=!1;let s=new AbortController;this.streamAbort=s;let a=null;try{for await(let r of this.client.streamChat(e,{contactEmail:i,signal:s.signal}))switch(this.typing.hidden=!0,r.type){case"delta":a||(a=this.appendMessage("bot","")),a.textContent+=r.delta,this.scrollBottom();break;case"collect_contact":this.inputArea.hidden=!0,this.contact.hidden=!1,this.awaitingEmail=!0,requestAnimationFrame(()=>{this.scrollBottom(),this.emailIn.focus()});break;case"escalated":this.ticketCreated=!0,this.ticket.hidden=!1,this.ticket.textContent=r.message,this.inputArea.hidden=!0;break;case"blocked":this.appendMessage("bot",this.offtopicMessage??r.reason);break;case"done":break}}catch(r){if(s.signal.aborted)return;if(this.typing.hidden=!0,!(r instanceof DOMException&&r.name==="AbortError"))if(r instanceof o){this.errorOccurred=!0;let d=["PiiBlocked","Blocked","RateLimited"].includes(r.status)&&r.message?r.message:this.errorMessage;this.appendMessage("sys",d)}else this.appendMessage("sys","Connection error. Please try again.")}finally{if(this.streamAbort===s){this.streamAbort=null;let r=this.client.getSessionToken();r&&sessionStorage.setItem(h,r),this.typing.hidden=!0,this.streaming=!1,!this.awaitingEmail&&!this.ticketCreated&&(this.sendBtn.disabled=!1,this.panel.hidden||this.input.focus())}}}appendMessage(e,i){let s=document.createElement("div");return s.className=`msg msg-${e}`,s.textContent=i,this.messages.appendChild(s),this.scrollBottom(),s}scrollBottom(){this.messages.scrollTop=this.messages.scrollHeight}autoResize(){this.input.style.height="auto",this.input.style.height=`${Math.min(this.input.scrollHeight,100)}px`}esc(e){return e.replace(/&/g,"&").replace(//g,">").replace(/"/g,""")}q(e){return this.shadow.querySelector(e)}};customElements.define("botwire-widget",p);})(); diff --git a/src/BotWire.Core/BotWire.Core.csproj b/src/BotWire.Core/BotWire.Core.csproj index 228eb2c..190d25a 100644 --- a/src/BotWire.Core/BotWire.Core.csproj +++ b/src/BotWire.Core/BotWire.Core.csproj @@ -29,6 +29,7 @@ + diff --git a/src/BotWire.Core/Guard/GuardServiceCollectionExtensions.cs b/src/BotWire.Core/Guard/GuardServiceCollectionExtensions.cs index 9b05a11..d18bb82 100644 --- a/src/BotWire.Core/Guard/GuardServiceCollectionExtensions.cs +++ b/src/BotWire.Core/Guard/GuardServiceCollectionExtensions.cs @@ -47,13 +47,13 @@ public static IServiceCollection AddBotWireGuard( services.AddSingleton(sp => { var opts = sp.GetRequiredService>(); - var logger = sp.GetRequiredService>(); + var logger = sp.GetRequiredService>(); if (!opts.Value.Enabled) { logger.LogInformation("BotWire: PiiGuard disabled."); return NullPiiGuard.Instance; } - return new RegexPiiGuard(opts, logger); + return new RedactWirePiiGuard(opts, logger); }); var rlBuilder = services.AddOptions(); diff --git a/src/BotWire.Core/Guard/PiiGuardOptions.cs b/src/BotWire.Core/Guard/PiiGuardOptions.cs index a41091c..2f8ed6c 100644 --- a/src/BotWire.Core/Guard/PiiGuardOptions.cs +++ b/src/BotWire.Core/Guard/PiiGuardOptions.cs @@ -15,24 +15,39 @@ // along with this program. If not, see . using System.ComponentModel.DataAnnotations; +using RedactWire; namespace BotWire.Core.Guard; -/// Configuration for PII detection. +/// Configuration for PII detection. Detection is backed by +/// RedactWire; use +/// to customize the underlying detector directly. public sealed class PiiGuardOptions { - /// Enables PII pattern matching. When false a is used and no patterns are evaluated. Defaults to . + /// Enables PII detection. When false a is used and no rules are evaluated. Defaults to . public bool Enabled { get; set; } = true; - /// Message returned to the caller when a PII pattern is matched. + /// Message returned to the caller when PII is detected. [Required] public string RejectionMessage { get; set; } = "Your message contains sensitive information and cannot be processed."; /// - /// Additional regex patterns (case-insensitive) to block, appended after the built-in defaults. + /// Additional regex patterns (case-insensitive) to block, added as culture-agnostic + /// custom rules after the built-in defaults. Each becomes a RedactWire + /// rule; matches report as invariant:custom-{index}. /// Invalid regex strings are skipped with a warning log at startup. /// public IList AdditionalPatterns { get; set; } = []; + /// + /// Optional hook to configure the RedactWire directly — + /// add cultures, custom IPiiRules, or an overlap strategy. The builder starts from + /// (invariant rules) with + /// already applied and any + /// bridged in; when this is the + /// detector runs those defaults against the default culture + /// (). + /// + public Action? ConfigureDetector { get; set; } } diff --git a/src/BotWire.Core/Guard/PromptInjectionOptions.cs b/src/BotWire.Core/Guard/PromptInjectionOptions.cs index 97ee5c5..0f82b9a 100644 --- a/src/BotWire.Core/Guard/PromptInjectionOptions.cs +++ b/src/BotWire.Core/Guard/PromptInjectionOptions.cs @@ -14,6 +14,8 @@ // You should have received a copy of the GNU Affero General Public License // along with this program. If not, see . +using System.ComponentModel.DataAnnotations; + namespace BotWire.Core.Guard; /// Configuration for heuristic prompt injection detection. @@ -25,6 +27,14 @@ public sealed class PromptInjectionOptions /// public bool Enabled { get; set; } = true; + /// + /// Message returned to the caller when a prompt-injection attempt is blocked. Kept + /// deliberately neutral (it does not reveal that injection detection fired). + /// + [Required] + public string RejectionMessage { get; set; } = + "Your message couldn't be processed. Please rephrase and try again."; + /// /// Additional regex patterns (case-insensitive) appended after the built-in defaults. /// Invalid regex strings are skipped with a warning log at startup. diff --git a/src/BotWire.Core/Guard/RedactWirePiiGuard.cs b/src/BotWire.Core/Guard/RedactWirePiiGuard.cs new file mode 100644 index 0000000..997b51b --- /dev/null +++ b/src/BotWire.Core/Guard/RedactWirePiiGuard.cs @@ -0,0 +1,86 @@ +// BotWire +// Copyright (C) 2026 Object IT Limited +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + +using BotWire.Core.Abstractions; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Options; +using RedactWire; + +namespace BotWire.Core.Guard; + +/// +/// PII guard backed by RedactWire. +/// Builds a single immutable at construction time from the default +/// invariant rules plus secret/credential detection, running against the default culture. +/// Consumers can customize the detector via . +/// +internal sealed class RedactWirePiiGuard : IPiiGuard +{ + // Cap on how long any one user-supplied pattern may scan a message, to bound ReDoS risk. + private static readonly TimeSpan PatternTimeout = TimeSpan.FromMilliseconds(100); + + private readonly bool _enabled; + private readonly PiiDetector _detector; + + public RedactWirePiiGuard(IOptions options, ILogger logger) + { + var opts = options.Value; + _enabled = opts.Enabled; + + // CreateDefault() = invariant rules (email, credit card, IP, IBAN); AddSecretDetection() + // = API keys/tokens. With no AddCulture, Build() resolves against CurrentCulture. + var builder = PiiDetectorBuilder.CreateDefault().AddSecretDetection(); + + // Bridge user regex patterns into culture-agnostic custom rules (case-insensitive, + // matching the legacy guard). Compiled with a match timeout to bound ReDoS. Invalid + // patterns are skipped with a warning, not thrown. + for (var i = 0; i < opts.AdditionalPatterns.Count; i++) + { + try + { + builder.AddInvariantRule( + new TimeoutRegexRule($"custom-{i}", opts.AdditionalPatterns[i], PatternTimeout, logger)); + } + catch (ArgumentException ex) + { + logger.LogWarning(ex, "BotWire: AdditionalPatterns[{Index}] is not a valid regex and was skipped.", i); + } + } + + opts.ConfigureDetector?.Invoke(builder); + _detector = builder.Build(); + + logger.LogInformation("BotWire: PiiGuard enabled (RedactWire), secret detection on."); + } + + public bool IsEnabled => _enabled; + + public PiiCheckResult Check(string message) + { + // Blank input can hold no PII — skip detection (and its per-message allocation). + if (!_enabled || string.IsNullOrWhiteSpace(message)) + return new PiiCheckResult(false, null); + + var result = _detector.Detect(message); + if (!result.HasPii) + return new PiiCheckResult(false, null); + + // Report the earliest match in the text (overlap resolution runs per group, so + // AllMatches is not position-ordered). Rule id e.g. "invariant:Email", "en-US:SSN". + var first = result.AllMatches.OrderBy(m => m.Start).First(); + return new PiiCheckResult(true, first.Rule); + } +} diff --git a/src/BotWire.Core/Guard/RegexPiiGuard.cs b/src/BotWire.Core/Guard/RegexPiiGuard.cs deleted file mode 100644 index edd028c..0000000 --- a/src/BotWire.Core/Guard/RegexPiiGuard.cs +++ /dev/null @@ -1,85 +0,0 @@ -// BotWire -// Copyright (C) 2026 Object IT Limited -// -// This program is free software: you can redistribute it and/or modify -// it under the terms of the GNU Affero General Public License as published by -// the Free Software Foundation, either version 3 of the License, or -// (at your option) any later version. -// -// This program is distributed in the hope that it will be useful, -// but WITHOUT ANY WARRANTY; without even the implied warranty of -// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the -// GNU Affero General Public License for more details. -// -// You should have received a copy of the GNU Affero General Public License -// along with this program. If not, see . - -using System.Text.RegularExpressions; -using BotWire.Core.Abstractions; -using Microsoft.Extensions.Logging; -using Microsoft.Extensions.Options; - -namespace BotWire.Core.Guard; - -/// -/// Regex-based PII guard. Compiles default patterns (email, phone-cn, phone-intl, credit-card) -/// plus any user-supplied patterns at construction time and checks every message against them. -/// -internal sealed class RegexPiiGuard : IPiiGuard -{ - private static readonly (string Pattern, string Name)[] DefaultPatterns = - [ - (@"[a-zA-Z0-9._%+\-]+@[a-zA-Z0-9.\-]+\.[a-zA-Z]{2,}", "email"), - (@"1[3-9]\d{9}", "phone-cn"), - (@"\+\d{7,15}", "phone-intl"), - (@"\b(?:\d[ -]?){13,16}\b", "credit-card"), - ]; - - private readonly bool _enabled; - private readonly (Regex Regex, string Name)[] _compiled; - - public RegexPiiGuard(IOptions options, ILogger logger) - { - var opts = options.Value; - _enabled = opts.Enabled; - - var list = new List<(Regex, string)>(DefaultPatterns.Length + opts.AdditionalPatterns.Count); - - foreach (var (pattern, name) in DefaultPatterns) - list.Add((new Regex(pattern, RegexOptions.Compiled | RegexOptions.IgnoreCase | RegexOptions.CultureInvariant), name)); - - for (var i = 0; i < opts.AdditionalPatterns.Count; i++) - { - try - { - list.Add(( - new Regex(opts.AdditionalPatterns[i], RegexOptions.Compiled | RegexOptions.IgnoreCase | RegexOptions.CultureInvariant), - $"custom-{i}")); - } - catch (ArgumentException ex) - { - logger.LogWarning(ex, "BotWire: AdditionalPatterns[{Index}] is not a valid regex and was skipped.", i); - } - } - - _compiled = [.. list]; - - logger.LogInformation("BotWire: PiiGuard enabled, patterns: {Patterns}", - string.Join(", ", _compiled.Select(p => p.Name))); - } - - public bool IsEnabled => _enabled; - - public PiiCheckResult Check(string message) - { - if (!_enabled) - return new PiiCheckResult(false, null); - - foreach (var (regex, name) in _compiled) - { - if (regex.IsMatch(message)) - return new PiiCheckResult(true, name); - } - return new PiiCheckResult(false, null); - } -} diff --git a/src/BotWire.Core/Guard/TimeoutRegexRule.cs b/src/BotWire.Core/Guard/TimeoutRegexRule.cs new file mode 100644 index 0000000..785e9f9 --- /dev/null +++ b/src/BotWire.Core/Guard/TimeoutRegexRule.cs @@ -0,0 +1,68 @@ +// BotWire +// Copyright (C) 2026 Object IT Limited +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + +using System.Text.RegularExpressions; +using Microsoft.Extensions.Logging; +using RedactWire; + +namespace BotWire.Core.Guard; + +/// +/// A custom RedactWire for a user-supplied regex pattern, compiled with a +/// match timeout so a catastrophic-backtracking pattern cannot hang message scanning (ReDoS). +/// On timeout the rule logs a warning and reports no match — a single slow pattern degrades to a +/// miss rather than stalling the request. Used to bridge . +/// +internal sealed class TimeoutRegexRule : IPiiRule +{ + private readonly Regex _re; + private readonly ILogger _logger; + + public string Name { get; } + public PiiType Type => PiiType.Custom; + public string? Subtype { get; } + + public TimeoutRegexRule(string name, string pattern, TimeSpan timeout, ILogger logger) + { + Name = name; + Subtype = name; + _logger = logger; + // Throws ArgumentException for an invalid pattern; the caller catches and skips it. + _re = new Regex(pattern, + RegexOptions.Compiled | RegexOptions.IgnoreCase | RegexOptions.CultureInvariant, + timeout); + } + + public IEnumerable Find(string text) + { + var hits = new List(); + try + { + foreach (Match m in _re.Matches(text)) + { + var g = m.Groups["v"].Success ? m.Groups["v"] : (Group)m; + hits.Add(new RuleHit(g.Value, g.Index, g.Length, 1.0, Subtype: Subtype)); + } + } + catch (RegexMatchTimeoutException ex) + { + _logger.LogWarning(ex, + "BotWire: PII custom pattern '{Name}' timed out scanning a message; treated as no match.", Name); + return Array.Empty(); + } + return hits; + } +} diff --git a/tests/BotWire.AspNetCore.IntegrationTests/ChatEndpointTests.cs b/tests/BotWire.AspNetCore.IntegrationTests/ChatEndpointTests.cs index 2683e72..48abaae 100644 --- a/tests/BotWire.AspNetCore.IntegrationTests/ChatEndpointTests.cs +++ b/tests/BotWire.AspNetCore.IntegrationTests/ChatEndpointTests.cs @@ -172,7 +172,7 @@ public async Task PiiGuard_Returns400() Assert.Equal(HttpStatusCode.BadRequest, resp.StatusCode); var body = await resp.Content.ReadFromJsonAsync(); - Assert.Equal("Blocked", body!.Status); + Assert.Equal("PiiBlocked", body!.Status); } [Fact] @@ -213,4 +213,25 @@ public async Task InvalidToken_StreamEndpoint_Returns400WithInvalidSessionStatus var body = await resp.Content.ReadFromJsonAsync(); Assert.Equal("InvalidSession", body!.Status); } + + [Fact] + public async Task WidgetJs_RevalidatesViaETag_NoLongCache() + { + await using var host = await BotWireTestHost.CreateAsync(); + + var first = await host.Client.GetAsync("/botwire/widget.js"); + first.EnsureSuccessStatusCode(); + var etag = first.Headers.ETag; + + // Must revalidate (no stale hour-long cache) and carry an ETag so updates propagate. + Assert.NotNull(etag); + Assert.True(first.Headers.CacheControl!.NoCache); + Assert.Null(first.Headers.CacheControl.MaxAge); + + // A conditional re-request with the same ETag is a cheap 304, not a re-download. + var conditional = new HttpRequestMessage(HttpMethod.Get, "/botwire/widget.js"); + conditional.Headers.IfNoneMatch.Add(etag!); + var second = await host.Client.SendAsync(conditional); + Assert.Equal(HttpStatusCode.NotModified, second.StatusCode); + } } diff --git a/tests/BotWire.AspNetCore.Tests/BotWireChatServiceAuditTests.cs b/tests/BotWire.AspNetCore.Tests/BotWireChatServiceAuditTests.cs index 921cd01..9976e06 100644 --- a/tests/BotWire.AspNetCore.Tests/BotWireChatServiceAuditTests.cs +++ b/tests/BotWire.AspNetCore.Tests/BotWireChatServiceAuditTests.cs @@ -57,7 +57,8 @@ private static (BotWireChatService svc, FakeAuditLogger audit) CreateAudited( new FakeSummaryCompressor(), audit, Options.Create(new BotWireOptions()), - Options.Create(new PiiGuardOptions())); + Options.Create(new PiiGuardOptions()), + Options.Create(new PromptInjectionOptions())); return (svc, audit); } diff --git a/tests/BotWire.AspNetCore.Tests/BotWireChatServiceTests.cs b/tests/BotWire.AspNetCore.Tests/BotWireChatServiceTests.cs index 8d05e5b..f047e14 100644 --- a/tests/BotWire.AspNetCore.Tests/BotWireChatServiceTests.cs +++ b/tests/BotWire.AspNetCore.Tests/BotWireChatServiceTests.cs @@ -64,7 +64,8 @@ private static (BotWireChatService svc, FakeConversationStore store) Create( new FakeSummaryCompressor(), NullAuditLogger.Instance, Options.Create(new BotWireOptions { MaxMessageLength = maxMsg }), - Options.Create(new PiiGuardOptions())); + Options.Create(new PiiGuardOptions()), + Options.Create(new PromptInjectionOptions())); return (svc, store); } @@ -169,7 +170,7 @@ public async Task AnswerAsync_PiiBlocked_Returns400() var (svc, _) = Create(piiBlocks: true); var result = await svc.AnswerAsync(ChatReq(), "1.2.3.4"); Assert.Equal(400, result.HttpStatusCode); - Assert.Equal("Blocked", result.Status); + Assert.Equal("PiiBlocked", result.Status); } [Fact] diff --git a/tests/BotWire.Core.Tests/Guard/RedactWirePiiGuardTests.cs b/tests/BotWire.Core.Tests/Guard/RedactWirePiiGuardTests.cs new file mode 100644 index 0000000..dc28626 --- /dev/null +++ b/tests/BotWire.Core.Tests/Guard/RedactWirePiiGuardTests.cs @@ -0,0 +1,156 @@ +// BotWire +// Copyright (C) 2026 Object IT Limited +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + +using System.Globalization; +using BotWire.Core.Guard; +using Microsoft.Extensions.Logging.Abstractions; +using Microsoft.Extensions.Options; +using RedactWire; + +namespace BotWire.Core.Tests.Guard; + +public class RedactWirePiiGuardTests +{ + private static RedactWirePiiGuard Create(Action? configure = null) + { + var opts = new PiiGuardOptions(); + configure?.Invoke(opts); + return new RedactWirePiiGuard(Options.Create(opts), NullLogger.Instance); + } + + // ----- IsEnabled ----- + + [Fact] + public void IsEnabled_ReturnsTrue_WhenEnabledOption() + { + Assert.True(Create().IsEnabled); + } + + [Fact] + public void Check_ReturnsNotBlocked_WhenDisabled() + { + var result = Create(o => o.Enabled = false).Check("contact me at user@example.com"); + Assert.False(result.Blocked); + Assert.Null(result.MatchedPattern); + } + + // ----- Default invariant rules block (culture-agnostic) ----- + + [Theory] + [InlineData("contact me at user@example.com please", "invariant:Email")] + [InlineData("card 4111 1111 1111 1111", "invariant:CreditCard")] + [InlineData("server is 192.168.0.1 today", "invariant:IPv4")] + public void Check_DefaultInvariantRules_Block(string message, string expectedRule) + { + var result = Create().Check(message); + Assert.True(result.Blocked); + Assert.Equal(expectedRule, result.MatchedPattern); + } + + // ----- Secret detection on by default (AddSecretDetection) ----- + + [Fact] + public void Check_SecretToken_Blocked() + { + var result = Create().Check("my key is sk-proj-abcdefghijklmnopqrstuvwxyz0123"); + Assert.True(result.Blocked); + Assert.Equal("invariant:OpenAiKey", result.MatchedPattern); + } + + // ----- Clean messages pass ----- + + [Theory] + [InlineData("Hello, I need help with my order.")] + [InlineData("How do I reset my password?")] + public void Check_CleanMessage_NotBlocked(string message) + { + var result = Create().Check(message); + Assert.False(result.Blocked); + Assert.Null(result.MatchedPattern); + } + + // ----- AdditionalPatterns (bridged to RedactWire custom rules) ----- + + [Fact] + public void Check_AdditionalPattern_Blocks() + { + var guard = Create(o => o.AdditionalPatterns.Add(@"\bSECRET\b")); + var result = guard.Check("The value is SECRET"); + Assert.True(result.Blocked); + Assert.Equal("invariant:custom-0", result.MatchedPattern); + } + + [Fact] + public void Check_InvalidAdditionalPattern_SkippedWithoutThrow() + { + var guard = Create(o => o.AdditionalPatterns.Add("[invalid")); + var result = guard.Check("Hello"); + Assert.False(result.Blocked); + } + + [Fact] + public void Check_AdditionalPatternAfterInvalidOne_StillWorks() + { + var guard = Create(o => + { + o.AdditionalPatterns.Add("[invalid"); + o.AdditionalPatterns.Add(@"\bTOKEN\b"); + }); + var result = guard.Check("my TOKEN here"); + Assert.True(result.Blocked); + Assert.Equal("invariant:custom-1", result.MatchedPattern); + } + + [Fact] + public void Check_CatastrophicAdditionalPattern_TimesOutAndDoesNotBlock() + { + // Classic exponential-backtracking pattern + non-matching tail. The match timeout + // must trip, degrade to "no match", and return quickly rather than hang. + var guard = Create(o => o.AdditionalPatterns.Add(@"(a+)+$")); + var input = new string('a', 40) + "!"; + + var sw = System.Diagnostics.Stopwatch.StartNew(); + var result = guard.Check(input); + sw.Stop(); + + Assert.False(result.Blocked); + Assert.True(sw.Elapsed < TimeSpan.FromSeconds(2), $"took {sw.ElapsedMilliseconds}ms"); + } + + // ----- ConfigureDetector escape hatch ----- + + [Fact] + public void Check_CustomRuleViaConfigureDetector_Blocks() + { + var guard = Create(o => o.ConfigureDetector = b => + b.AddInvariantRule(new RegexRule("AcmeAccount", PiiType.Custom, + @"(?\bACME-\d{6}\b)", subtype: "AcmeAccount"))); + + var result = guard.Check("ref ACME-123456 on file"); + Assert.True(result.Blocked); + Assert.Equal("invariant:AcmeAccount", result.MatchedPattern); + } + + [Fact] + public void Check_CultureRuleViaConfigureDetector_Blocks() + { + var guard = Create(o => o.ConfigureDetector = b => b.AddCulture(new CultureInfo("en-US"))); + + var result = guard.Check("my ssn is 123-45-6789"); + Assert.True(result.Blocked); + Assert.StartsWith("en-US:", result.MatchedPattern); + } +} diff --git a/tests/BotWire.Core.Tests/Guard/RegexPiiGuardTests.cs b/tests/BotWire.Core.Tests/Guard/RegexPiiGuardTests.cs deleted file mode 100644 index 64810fd..0000000 --- a/tests/BotWire.Core.Tests/Guard/RegexPiiGuardTests.cs +++ /dev/null @@ -1,100 +0,0 @@ -// BotWire -// Copyright (C) 2026 Object IT Limited -// -// This program is free software: you can redistribute it and/or modify -// it under the terms of the GNU Affero General Public License as published by -// the Free Software Foundation, either version 3 of the License, or -// (at your option) any later version. -// -// This program is distributed in the hope that it will be useful, -// but WITHOUT ANY WARRANTY; without even the implied warranty of -// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the -// GNU Affero General Public License for more details. -// -// You should have received a copy of the GNU Affero General Public License -// along with this program. If not, see . - -using BotWire.Core.Guard; -using Microsoft.Extensions.Logging.Abstractions; -using Microsoft.Extensions.Options; - -namespace BotWire.Core.Tests.Guard; - -public class RegexPiiGuardTests -{ - private static RegexPiiGuard Create(Action? configure = null) - { - var opts = new PiiGuardOptions(); - configure?.Invoke(opts); - return new RegexPiiGuard(Options.Create(opts), NullLogger.Instance); - } - - // ----- IsEnabled ----- - - [Fact] - public void IsEnabled_ReturnsTrue_WhenEnabledOption() - { - Assert.True(Create().IsEnabled); - } - - // ----- Default pattern blocks ----- - - [Theory] - [InlineData("contact me at user@example.com please", "email")] - [InlineData("my email is Test.User+tag@Sub.Domain.co.uk", "email")] - [InlineData("call me on 13812345678", "phone-cn")] - [InlineData("ring +447911123456", "phone-intl")] - [InlineData("card 4111 1111 1111 1111", "credit-card")] - [InlineData("card 4111-1111-1111-1111", "credit-card")] - public void Check_DefaultPatterns_Block(string message, string expectedPattern) - { - var result = Create().Check(message); - Assert.True(result.Blocked); - Assert.Equal(expectedPattern, result.MatchedPattern); - } - - // ----- Clean messages pass ----- - - [Theory] - [InlineData("Hello, I need help with my order.")] - [InlineData("My account number is 12345")] - [InlineData("How do I reset my password?")] - public void Check_CleanMessage_NotBlocked(string message) - { - var result = Create().Check(message); - Assert.False(result.Blocked); - Assert.Null(result.MatchedPattern); - } - - // ----- AdditionalPatterns ----- - - [Fact] - public void Check_AdditionalPattern_Blocks() - { - var guard = Create(o => o.AdditionalPatterns.Add(@"\bSECRET\b")); - var result = guard.Check("The value is SECRET"); - Assert.True(result.Blocked); - Assert.Equal("custom-0", result.MatchedPattern); - } - - [Fact] - public void Check_InvalidAdditionalPattern_SkippedWithoutThrow() - { - var guard = Create(o => o.AdditionalPatterns.Add("[invalid")); - var result = guard.Check("Hello"); - Assert.False(result.Blocked); - } - - [Fact] - public void Check_AdditionalPatternAfterInvalidOne_StillWorks() - { - var guard = Create(o => - { - o.AdditionalPatterns.Add("[invalid"); - o.AdditionalPatterns.Add(@"\bTOKEN\b"); - }); - var result = guard.Check("my TOKEN here"); - Assert.True(result.Blocked); - Assert.Equal("custom-1", result.MatchedPattern); - } -}