From 9e851ddf9f090e66c9d320e45ad5a532f45c243b Mon Sep 17 00:00:00 2001 From: Justin Middler Date: Wed, 29 Jul 2026 21:17:57 +1000 Subject: [PATCH] feat(privacy): emit SOC sensitive_data_detected events High-risk privacy findings now enqueue a first-class security event so the SOC is notified. Never includes original secrets. README documents behaviour. --- README.md | 10 +- docs/privacy/privacy-secret-engine.md | 29 +++ policies/default/sensitive-data-detected.yaml | 36 ++++ .../PipelineAbstractions.cs | 12 +- src/Bower.Contracts/SecurityEventTypes.cs | 8 + src/Bower.Core/SecurityEventProcessor.cs | 191 +++++++++++++++++- src/Bower.Redaction/Privacy/PrivacyEngine.cs | 55 ++++- src/Bower.Redaction/Privacy/PrivacyPolicy.cs | 7 + .../SecurityEventProcessorTests.cs | 119 ++++++++++- tests/Bower.UnitTests/TestSupport.cs | 44 ++++ 10 files changed, 495 insertions(+), 16 deletions(-) create mode 100644 policies/default/sensitive-data-detected.yaml diff --git a/README.md b/README.md index ac86740..6b99912 100644 --- a/README.md +++ b/README.md @@ -50,7 +50,13 @@ Configurable actions per detector: Allow, Remove, Replace, Mask, SHA-256, HMAC, Encrypt, AlertOnly. Events emit `privacy` metadata describing what was detected and which action ran — **never** the original values. -Full catalogue, policy configuration and extension points: +**Security team notification:** high-risk findings (regulated AU identifiers, +secrets, crypto material, payment cards, field-name secrets) also enqueue a +first-class semantic event `sensitive_data_detected` (`privacy-control`) so the +SOC can alert in Sentinel/SIEM. Routine email/phone masking alone does not fire +that event (noise control). Original values are never included on the alert. + +Full catalogue, policy configuration, SOC event shape and extension points: [Privacy & Secret Protection Engine](docs/privacy/privacy-secret-engine.md). > [!WARNING] @@ -101,7 +107,7 @@ Full design: [architecture](docs/architecture/overview.md). | Linux | `linux-x64`, `linux-arm64` publishing configured by release workflow | | macOS | `osx-x64`, `osx-arm64` publishing configured by release workflow | | Local collector HTTP | Implemented; loopback default | -| Privacy & Secret Protection Engine | Implemented; AU identifiers (TFN, CRN, Medicare, IHI, ABN/ACN, …), secrets, crypto; policy actions + metadata | +| Privacy & Secret Protection Engine | Implemented; AU identifiers (TFN, CRN, Medicare, IHI, ABN/ACN, …), secrets, crypto; policy actions + metadata; high-risk findings emit `sensitive_data_detected` for SOC | | Durable SQLite queue | Implemented | | AMA companion spool | Implemented | | Logs Ingestion API | Real Azure SDK client implemented; tenant test required | diff --git a/docs/privacy/privacy-secret-engine.md b/docs/privacy/privacy-secret-engine.md index 531570c..ee88a99 100644 --- a/docs/privacy/privacy-secret-engine.md +++ b/docs/privacy/privacy-secret-engine.md @@ -71,6 +71,35 @@ When findings exist, the engine attaches (never with original values): `SecurityEventEnvelope.Privacy` maps the same shape. +## Security team notification + +Redaction alone is not enough for operations. When **high-risk** detectors fire, +`SecurityEventProcessor` also enqueues a first-class semantic event: + +| Field | Value | +| --- | --- | +| `eventCategory` | `privacy-control` | +| `eventType` | `sensitive_data_detected` | +| `eventAction` | `privacy.control.applied` | +| `target.id` | source event id | +| `privacy.detected` / `privacy.actions` | detector ids + actions only | + +**Never** includes original TFNs, secrets, tokens or card numbers. + +Alert-worthy by default (SOC signal): + +- Australian regulated ids: TFN, CRN, Medicare, IHI, passport, licence, DVA +- Payment: credit card, BSB/account, IBAN, PayID +- Secrets / crypto / field-name secrets / env vars / API keys +- Protective security markings (when opt-in detector enabled) + +**Not** alert-worthy by default (still redacted on the source event): + +- Routine email / phone mask alone (noise for SOC; still in source `privacy` metadata) + +Policy: `policies/default/sensitive-data-detected.yaml` (`BWR-POL-PRIVACY-DETECT`). +Sentinel / SIEM can alert on `eventType == sensitive_data_detected`. + ## Detector modules ### Australian identifiers diff --git a/policies/default/sensitive-data-detected.yaml b/policies/default/sensitive-data-detected.yaml new file mode 100644 index 0000000..0414662 --- /dev/null +++ b/policies/default/sensitive-data-detected.yaml @@ -0,0 +1,36 @@ +apiVersion: bower.security/v1 +kind: TelemetryPolicy + +metadata: + id: BWR-POL-PRIVACY-DETECT + name: Sensitive data detected by privacy engine + version: 1.0.0 + owner: Security Operations + reviewDate: 2027-01-01 + +match: + eventCategories: + - privacy-control + eventTypes: + - sensitive_data_detected + +requirements: + requiredFields: + - timeGenerated + - eventType + - eventResult + - application.name + - target.id + recommendedFields: + - request.correlationId + - eventOutcomeReason + - labels.sourceEventId + +decision: + action: accept + minimumValueScore: 80 + neverSample: true + +routing: + profile: privacy-controls + destination: security-events diff --git a/src/Bower.Abstractions/PipelineAbstractions.cs b/src/Bower.Abstractions/PipelineAbstractions.cs index 2e12337..6d12ac1 100644 --- a/src/Bower.Abstractions/PipelineAbstractions.cs +++ b/src/Bower.Abstractions/PipelineAbstractions.cs @@ -22,7 +22,17 @@ public sealed record RedactionResult( string? RedactedJson, IReadOnlyList RemovedPaths, IReadOnlyList MaskedPaths, - string? FailureCode); + string? FailureCode, + /// + /// Detector ids observed during privacy scan (e.g. au.tfn, secret.jwt). + /// Never contains original sensitive values. + /// + IReadOnlyList? PrivacyDetected = null, + /// + /// Per-detector action labels applied during privacy scan (e.g. Removed, SHA256). + /// Never contains original sensitive values. + /// + IReadOnlyDictionary? PrivacyActions = null); public interface ITelemetryPolicyEvaluator { diff --git a/src/Bower.Contracts/SecurityEventTypes.cs b/src/Bower.Contracts/SecurityEventTypes.cs index d8074be..4aafab0 100644 --- a/src/Bower.Contracts/SecurityEventTypes.cs +++ b/src/Bower.Contracts/SecurityEventTypes.cs @@ -7,6 +7,12 @@ public static class SecurityEventTypes public const string AccountLockout = "account_lockout"; public const string RoleMembershipChanged = "role_membership_changed"; public const string SensitiveDataExported = "sensitive_data_exported"; + /// + /// Privacy engine detected regulated identifiers, secrets or other sensitive + /// material in inbound telemetry and applied a control action. + /// Never carries original secret values — detector ids and actions only. + /// + public const string SensitiveDataDetected = "sensitive_data_detected"; public const string CollectorStarted = "collector_started"; public const string CollectorUploadFailed = "collector_upload_failed"; public const string TelemetryAggregationSummary = "telemetry_aggregation_summary"; @@ -22,4 +28,6 @@ public static class SecurityEventCategories public const string ApplicationSecurity = "application-security"; public const string ApiSecurity = "api-security"; public const string CollectorHealth = "collector-health"; + /// Privacy controls, DLP-style findings, data minimisation actions. + public const string PrivacyControl = "privacy-control"; } diff --git a/src/Bower.Core/SecurityEventProcessor.cs b/src/Bower.Core/SecurityEventProcessor.cs index a73ff53..fcda88f 100644 --- a/src/Bower.Core/SecurityEventProcessor.cs +++ b/src/Bower.Core/SecurityEventProcessor.cs @@ -1,6 +1,7 @@ using System.Text.Json; using Bower.Abstractions; using Bower.Contracts; +using Bower.Redaction.Privacy; namespace Bower.Core; @@ -43,25 +44,40 @@ public async Task ProcessAsync( List validationFailures = Validate(candidate, clock.UtcNow); if (validationFailures.Count > 0) { + // Still notify SOC when high-risk secrets/PII were present in invalid payloads. + string? alertId = await TryEnqueuePrivacyAlertAsync( + candidate, + redaction, + collector, + cancellationToken); + return new ProcessingResult( DecisionAction.Quarantine, candidate.EventId, false, false, validationFailures, - null); + null, + alertId); } PolicyDecision decision = policyEvaluator.Evaluate(candidate); if (decision.Action is not (DecisionAction.Accept or DecisionAction.RedactAndAccept)) { + string? rejectedAlertId = await TryEnqueuePrivacyAlertAsync( + candidate, + redaction, + collector, + cancellationToken); + return new ProcessingResult( decision.Action, candidate.EventId, false, false, decision.Reasons.Concat(decision.Warnings).ToArray(), - decision); + decision, + rejectedAlertId); } SecurityEventEnvelope arranged = candidate with @@ -73,7 +89,9 @@ public async Task ProcessAsync( PolicyVersion = decision.PolicyVersion, PolicyHash = decision.PolicyHash, ValueScore = decision.Score, - Decision = redaction.RemovedPaths.Count > 0 || redaction.MaskedPaths.Count > 0 + Decision = redaction.RemovedPaths.Count > 0 + || redaction.MaskedPaths.Count > 0 + || (redaction.PrivacyDetected?.Count > 0) ? DecisionAction.RedactAndAccept : DecisionAction.Accept, ClassificationReasons = decision.Reasons @@ -96,6 +114,12 @@ public async Task ProcessAsync( clock.UtcNow); EnqueueResult enqueue = await queue.EnqueueAsync(queued, cancellationToken); + string? privacyAlertEventId = await TryEnqueuePrivacyAlertAsync( + arranged, + redaction, + collector, + cancellationToken); + List reasons = [.. decision.Reasons, .. decision.Warnings]; if (redaction.RemovedPaths.Count > 0) { @@ -107,6 +131,11 @@ public async Task ProcessAsync( reasons.Add($"Masked {redaction.MaskedPaths.Count} personal field(s)"); } + if (privacyAlertEventId is not null) + { + reasons.Add($"Emitted privacy alert event {privacyAlertEventId}"); + } + if (enqueue.Duplicate) { reasons.Add("Duplicate fingerprint already present"); @@ -118,9 +147,160 @@ public async Task ProcessAsync( enqueue.Enqueued, enqueue.Duplicate, reasons, - decision); + decision, + privacyAlertEventId); + } + + private async Task TryEnqueuePrivacyAlertAsync( + SecurityEventEnvelope source, + RedactionResult redaction, + CollectorIdentity collector, + CancellationToken cancellationToken) + { + IReadOnlyList detected = redaction.PrivacyDetected ?? []; + IReadOnlyDictionary actions = + redaction.PrivacyActions ?? new Dictionary(); + + List alertWorthy = detected + .Where(PrivacyEngine.IsSecurityEventWorthy) + .Distinct(StringComparer.Ordinal) + .OrderBy(id => id, StringComparer.Ordinal) + .ToList(); + + if (alertWorthy.Count == 0) + { + return null; + } + + Dictionary alertActions = new(StringComparer.Ordinal); + foreach (string id in alertWorthy) + { + string root = id; + int colon = id.IndexOf(':', StringComparison.Ordinal); + if (colon > 0) + { + root = id[..colon]; + } + + if (actions.TryGetValue(root, out string? label)) + { + alertActions[root] = label; + } + else if (actions.TryGetValue(id, out string? fullLabel)) + { + alertActions[id] = fullLabel; + } + } + + EventSeverity severity = ResolveSeverity(alertWorthy); + DateTimeOffset now = clock.UtcNow; + // Stable correlation for fingerprint: one alert per source event + detector set. + string detectorFingerprint = string.Join(',', alertWorthy); + string alertEventId = TruncateId($"privacy-{source.EventId}-{Guid.NewGuid():N}"); + SecurityEventEnvelope alert = new() + { + SchemaVersion = SecurityEventEnvelope.CurrentSchemaVersion, + EventId = alertEventId, + EventOriginalId = TruncateId($"{source.EventId}:{detectorFingerprint}"), + TimeGenerated = now, + TimeObserved = now, + EventCategory = SecurityEventCategories.PrivacyControl, + EventType = SecurityEventTypes.SensitiveDataDetected, + EventAction = "privacy.control.applied", + EventResult = EventResult.Success, + EventSeverity = severity, + EventOutcomeReason = + $"Privacy engine applied controls for {alertWorthy.Count} high-risk finding(s)", + Application = source.Application, + Actor = new ActorContext + { + Type = ActorType.System, + Username = "bower.privacy-engine", + DisplayName = "Bower Privacy & Secret Protection Engine" + }, + Target = new TargetContext + { + Type = "telemetry_event", + Id = source.EventId, + Name = source.EventType + }, + Source = source.Source, + Request = source.Request is null + ? new RequestContext { CorrelationId = source.EventId } + : source.Request with + { + CorrelationId = source.Request.CorrelationId ?? source.EventId + }, + Labels = new Dictionary(StringComparer.Ordinal) + { + ["sourceEventId"] = source.EventId, + ["sourceEventType"] = source.EventType, + ["sourceEventCategory"] = source.EventCategory, + ["detectorFingerprint"] = detectorFingerprint + }, + Privacy = new PrivacyContext + { + Detected = alertWorthy, + Actions = alertActions + }, + Collector = new CollectorContext + { + Id = collector.Id, + Version = collector.Version, + SourceAdapter = collector.SourceAdapter, + ConfigurationHash = collector.ConfigurationHash, + ReceivedAt = now + } + }; + + PolicyDecision alertDecision = policyEvaluator.Evaluate(alert); + if (alertDecision.Action is not (DecisionAction.Accept or DecisionAction.RedactAndAccept)) + { + return null; + } + + SecurityEventEnvelope arrangedAlert = alert with + { + Security = new SecurityDecisionContext + { + PolicyId = alertDecision.PolicyId, + PolicyVersion = alertDecision.PolicyVersion, + PolicyHash = alertDecision.PolicyHash, + ValueScore = alertDecision.Score, + Decision = DecisionAction.Accept, + ClassificationReasons = alertDecision.Reasons + } + }; + + string payload = JsonSerializer.Serialize(arrangedAlert, BowerJson.Options); + QueuedEvent queued = new( + arrangedAlert.EventId, + EventFingerprint.Create(arrangedAlert), + payload, + now); + EnqueueResult enqueue = await queue.EnqueueAsync(queued, cancellationToken); + return enqueue.Enqueued || enqueue.Duplicate ? arrangedAlert.EventId : null; + } + + private static EventSeverity ResolveSeverity(IReadOnlyList detectors) + { + foreach (string id in detectors) + { + string root = id.Split(':')[0]; + if (root.StartsWith("secret.", StringComparison.Ordinal) + || root.StartsWith("crypto.", StringComparison.Ordinal) + || root is DetectorIds.CreditCard or DetectorIds.Tfn or DetectorIds.FieldNameSecret) + { + return EventSeverity.High; + } + } + + return EventSeverity.Medium; } + private static string TruncateId(string value) => + value.Length <= 128 ? value : value[..128]; + private static List Validate( SecurityEventEnvelope candidate, DateTimeOffset now) @@ -176,7 +356,8 @@ public sealed record ProcessingResult( bool Queued, bool Duplicate, IReadOnlyList Reasons, - PolicyDecision? PolicyDecision) + PolicyDecision? PolicyDecision, + string? PrivacyAlertEventId = null) { public static ProcessingResult Failed(DecisionAction action, string reason) { diff --git a/src/Bower.Redaction/Privacy/PrivacyEngine.cs b/src/Bower.Redaction/Privacy/PrivacyEngine.cs index 7a9e6c9..4df1eaa 100644 --- a/src/Bower.Redaction/Privacy/PrivacyEngine.cs +++ b/src/Bower.Redaction/Privacy/PrivacyEngine.cs @@ -36,6 +36,52 @@ public PrivacyEngine( public RedactionResult Redact(string json) => RedactJson(json).ToRedactionResult(); + /// + /// Detector ids that warrant a first-class SOC security event when observed. + /// High-risk regulated AU identifiers, secrets and crypto — not routine email mask noise. + /// + public static bool IsSecurityEventWorthy(string detectorId) + { + if (string.IsNullOrEmpty(detectorId)) + { + return false; + } + + // SubKind suffixes appear as "secret.api-key:OpenAI" in metadata Detected list. + string root = detectorId; + int colon = detectorId.IndexOf(':', StringComparison.Ordinal); + if (colon > 0) + { + root = detectorId[..colon]; + } + + return root is DetectorIds.FieldNameSecret + or DetectorIds.Tfn + or DetectorIds.Crn + or DetectorIds.Medicare + or DetectorIds.Ihi + or DetectorIds.Passport + or DetectorIds.DriverLicence + or DetectorIds.Dva + or DetectorIds.CreditCard + or DetectorIds.BsbAccount + or DetectorIds.Iban + or DetectorIds.PayId + or DetectorIds.Aws + or DetectorIds.Azure + or DetectorIds.Entra + or DetectorIds.Gcp + or DetectorIds.Jwt + or DetectorIds.OAuth + or DetectorIds.ApiKey + or DetectorIds.Kubernetes + or DetectorIds.Docker + or DetectorIds.Database + or DetectorIds.EnvVar + or DetectorIds.CryptoMaterial + or DetectorIds.SecurityMarking; + } + public PrivacyScanResult RedactJson(string json) { if (string.IsNullOrWhiteSpace(json)) @@ -331,7 +377,14 @@ public static PrivacyScanResult Fail(string code) => new(false, null, [], PrivacyMetadata.Empty, [], [], code); public RedactionResult ToRedactionResult() => - new(Succeeded, RedactedJson, RemovedPaths, MaskedPaths, FailureCode); + new( + Succeeded, + RedactedJson, + RemovedPaths, + MaskedPaths, + FailureCode, + Metadata.Detected, + Metadata.Actions); } public sealed record PrivacyTextResult( diff --git a/src/Bower.Redaction/Privacy/PrivacyPolicy.cs b/src/Bower.Redaction/Privacy/PrivacyPolicy.cs index f380a27..40ebd0e 100644 --- a/src/Bower.Redaction/Privacy/PrivacyPolicy.cs +++ b/src/Bower.Redaction/Privacy/PrivacyPolicy.cs @@ -43,6 +43,13 @@ public sealed class PrivacyPolicy /// When true, injects privacy metadata object into JSON root. public bool EmitMetadata { get; init; } = true; + /// + /// When true (default), high-risk findings should surface as a separate + /// sensitive_data_detected security event for the SOC. The processor + /// owns enqueue; this flag documents intent for hosts wiring PrivacyEngine. + /// + public bool EmitSecurityEventOnFindings { get; init; } = true; + public bool IsDetectorEnabled(string detectorId) { if (DisabledDetectors.Contains(detectorId)) diff --git a/tests/Bower.UnitTests/SecurityEventProcessorTests.cs b/tests/Bower.UnitTests/SecurityEventProcessorTests.cs index f845669..565285c 100644 --- a/tests/Bower.UnitTests/SecurityEventProcessorTests.cs +++ b/tests/Bower.UnitTests/SecurityEventProcessorTests.cs @@ -23,7 +23,11 @@ public async Task Process_RedactsBeforePersistenceAndExplainsAcceptance() await store.InitializeAsync(cancellationToken); SecurityEventProcessor processor = new( new JsonEventRedactor(), - new DeterministicPolicyEvaluator([TestEvents.AuthenticationPolicy()]), + new DeterministicPolicyEvaluator( + [ + TestEvents.AuthenticationPolicy(), + TestEvents.PrivacyDetectionPolicy() + ]), store, clock); Dictionary attributes = new() @@ -40,7 +44,7 @@ public async Task Process_RedactsBeforePersistenceAndExplainsAcceptance() new CollectorIdentity("collector-1", "0.1.0", "test", "sha256:configuration"), cancellationToken); IReadOnlyList queued = await store.LeaseAsync( - 1, + 10, TimeSpan.FromMinutes(1), cancellationToken); @@ -49,10 +53,107 @@ public async Task Process_RedactsBeforePersistenceAndExplainsAcceptance() Assert.Contains( result.Reasons, reason => reason.StartsWith("Removed 1", StringComparison.Ordinal)); - QueuedEvent persisted = Assert.Single(queued); - Assert.DoesNotContain("never-persist", persisted.Payload, StringComparison.Ordinal); - Assert.DoesNotContain("\"password\"", persisted.Payload, StringComparison.Ordinal); - Assert.Contains("\"policyId\":\"BWR-POL-AUTH-FAILURE\"", persisted.Payload); + Assert.NotNull(result.PrivacyAlertEventId); + Assert.Equal(2, queued.Count); + QueuedEvent original = Assert.Single( + queued, + item => item.EventId == candidate.EventId); + Assert.DoesNotContain("never-persist", original.Payload, StringComparison.Ordinal); + Assert.DoesNotContain("\"password\"", original.Payload, StringComparison.Ordinal); + Assert.Contains("\"policyId\":\"BWR-POL-AUTH-FAILURE\"", original.Payload); + + QueuedEvent alert = Assert.Single( + queued, + item => item.EventId == result.PrivacyAlertEventId); + Assert.Contains(SecurityEventTypes.SensitiveDataDetected, alert.Payload, StringComparison.Ordinal); + Assert.Contains("field-name-secret", alert.Payload, StringComparison.Ordinal); + Assert.DoesNotContain("never-persist", alert.Payload, StringComparison.Ordinal); + } + + [Fact] + public async Task Process_EmitsPrivacyAlertForRegulatedIdentifier() + { + CancellationToken cancellationToken = TestContext.Current.CancellationToken; + using TemporaryDirectory directory = new(); + FakeClock clock = new(TestEvents.Now); + SqliteEventStore store = new( + Path.Combine(directory.Path, "queue.db"), + 10 * 1024 * 1024, + clock); + await store.InitializeAsync(cancellationToken); + SecurityEventProcessor processor = new( + new JsonEventRedactor(), + new DeterministicPolicyEvaluator( + [ + TestEvents.AuthenticationPolicy(), + TestEvents.PrivacyDetectionPolicy() + ]), + store, + clock); + + SecurityEventEnvelope candidate = TestEvents.AuthenticationFailure(clock.UtcNow) with + { + Attributes = new Dictionary + { + ["note"] = JsonSerializer.SerializeToElement("TFN 100000001") + } + }; + + ProcessingResult result = await processor.ProcessAsync( + JsonSerializer.Serialize(candidate, BowerJson.Options), + new CollectorIdentity("collector-1", "0.1.0", "test", "sha256:configuration"), + cancellationToken); + + Assert.True(result.Queued); + Assert.NotNull(result.PrivacyAlertEventId); + IReadOnlyList queued = await store.LeaseAsync( + 10, + TimeSpan.FromMinutes(1), + cancellationToken); + QueuedEvent alert = Assert.Single( + queued, + item => item.EventId == result.PrivacyAlertEventId); + Assert.Contains("au.tfn", alert.Payload, StringComparison.Ordinal); + Assert.DoesNotContain("100000001", alert.Payload, StringComparison.Ordinal); + Assert.Contains("privacy-control", alert.Payload, StringComparison.Ordinal); + } + + [Fact] + public async Task Process_DoesNotEmitPrivacyAlertForEmailOnly() + { + CancellationToken cancellationToken = TestContext.Current.CancellationToken; + using TemporaryDirectory directory = new(); + FakeClock clock = new(TestEvents.Now); + SqliteEventStore store = new( + Path.Combine(directory.Path, "queue.db"), + 10 * 1024 * 1024, + clock); + await store.InitializeAsync(cancellationToken); + SecurityEventProcessor processor = new( + new JsonEventRedactor(), + new DeterministicPolicyEvaluator( + [ + TestEvents.AuthenticationPolicy(), + TestEvents.PrivacyDetectionPolicy() + ]), + store, + clock); + + SecurityEventEnvelope candidate = TestEvents.AuthenticationFailure(clock.UtcNow) with + { + Attributes = new Dictionary + { + ["contact"] = JsonSerializer.SerializeToElement("alice@example.test") + } + }; + + ProcessingResult result = await processor.ProcessAsync( + JsonSerializer.Serialize(candidate, BowerJson.Options), + new CollectorIdentity("collector-1", "0.1.0", "test", "sha256:configuration"), + cancellationToken); + + Assert.True(result.Queued); + Assert.Null(result.PrivacyAlertEventId); } [Fact] @@ -68,7 +169,11 @@ public async Task Process_DeduplicatesStableSourceEvent() await store.InitializeAsync(cancellationToken); SecurityEventProcessor processor = new( new JsonEventRedactor(), - new DeterministicPolicyEvaluator([TestEvents.AuthenticationPolicy()]), + new DeterministicPolicyEvaluator( + [ + TestEvents.AuthenticationPolicy(), + TestEvents.PrivacyDetectionPolicy() + ]), store, clock); string json = JsonSerializer.Serialize( diff --git a/tests/Bower.UnitTests/TestSupport.cs b/tests/Bower.UnitTests/TestSupport.cs index 5b3af9d..a09ed65 100644 --- a/tests/Bower.UnitTests/TestSupport.cs +++ b/tests/Bower.UnitTests/TestSupport.cs @@ -111,4 +111,48 @@ public static LoadedPolicy AuthenticationPolicy() }; return new LoadedPolicy(policy, "sha256:test-policy", "test"); } + + public static LoadedPolicy PrivacyDetectionPolicy() + { + TelemetryPolicy policy = new() + { + ApiVersion = "bower.security/v1", + Kind = "TelemetryPolicy", + Metadata = new PolicyMetadata + { + Id = "BWR-POL-PRIVACY-DETECT", + Name = "Sensitive data detected by privacy engine", + Version = "1.0.0", + Owner = "Security Operations" + }, + Match = new PolicyMatch + { + EventCategories = [SecurityEventCategories.PrivacyControl], + EventTypes = [SecurityEventTypes.SensitiveDataDetected] + }, + Requirements = new PolicyRequirements + { + RequiredFields = + [ + "timeGenerated", + "eventType", + "eventResult", + "application.name", + "target.id" + ], + RecommendedFields = + [ + "request.correlationId", + "eventOutcomeReason" + ] + }, + Decision = new PolicyAction + { + Action = "accept", + MinimumValueScore = 80, + NeverSample = true + } + }; + return new LoadedPolicy(policy, "sha256:test-privacy-policy", "test"); + } }