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
10 changes: 8 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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]
Expand Down Expand Up @@ -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 |
Expand Down
29 changes: 29 additions & 0 deletions docs/privacy/privacy-secret-engine.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
36 changes: 36 additions & 0 deletions policies/default/sensitive-data-detected.yaml
Original file line number Diff line number Diff line change
@@ -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
12 changes: 11 additions & 1 deletion src/Bower.Abstractions/PipelineAbstractions.cs
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,17 @@ public sealed record RedactionResult(
string? RedactedJson,
IReadOnlyList<string> RemovedPaths,
IReadOnlyList<string> MaskedPaths,
string? FailureCode);
string? FailureCode,
/// <summary>
/// Detector ids observed during privacy scan (e.g. <c>au.tfn</c>, <c>secret.jwt</c>).
/// Never contains original sensitive values.
/// </summary>
IReadOnlyList<string>? PrivacyDetected = null,
/// <summary>
/// Per-detector action labels applied during privacy scan (e.g. Removed, SHA256).
/// Never contains original sensitive values.
/// </summary>
IReadOnlyDictionary<string, string>? PrivacyActions = null);

public interface ITelemetryPolicyEvaluator
{
Expand Down
8 changes: 8 additions & 0 deletions src/Bower.Contracts/SecurityEventTypes.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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";
/// <summary>
/// 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.
/// </summary>
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";
Expand All @@ -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";
/// <summary>Privacy controls, DLP-style findings, data minimisation actions.</summary>
public const string PrivacyControl = "privacy-control";
}
191 changes: 186 additions & 5 deletions src/Bower.Core/SecurityEventProcessor.cs
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
using System.Text.Json;
using Bower.Abstractions;
using Bower.Contracts;
using Bower.Redaction.Privacy;

namespace Bower.Core;

Expand Down Expand Up @@ -43,25 +44,40 @@ public async Task<ProcessingResult> ProcessAsync(
List<string> 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
Expand All @@ -73,7 +89,9 @@ public async Task<ProcessingResult> 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
Expand All @@ -96,6 +114,12 @@ public async Task<ProcessingResult> ProcessAsync(
clock.UtcNow);
EnqueueResult enqueue = await queue.EnqueueAsync(queued, cancellationToken);

string? privacyAlertEventId = await TryEnqueuePrivacyAlertAsync(
arranged,
redaction,
collector,
cancellationToken);

List<string> reasons = [.. decision.Reasons, .. decision.Warnings];
if (redaction.RemovedPaths.Count > 0)
{
Expand All @@ -107,6 +131,11 @@ public async Task<ProcessingResult> 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");
Expand All @@ -118,9 +147,160 @@ public async Task<ProcessingResult> ProcessAsync(
enqueue.Enqueued,
enqueue.Duplicate,
reasons,
decision);
decision,
privacyAlertEventId);
}

private async Task<string?> TryEnqueuePrivacyAlertAsync(
SecurityEventEnvelope source,
RedactionResult redaction,
CollectorIdentity collector,
CancellationToken cancellationToken)
{
IReadOnlyList<string> detected = redaction.PrivacyDetected ?? [];
IReadOnlyDictionary<string, string> actions =
redaction.PrivacyActions ?? new Dictionary<string, string>();

List<string> alertWorthy = detected
.Where(PrivacyEngine.IsSecurityEventWorthy)
.Distinct(StringComparer.Ordinal)
.OrderBy(id => id, StringComparer.Ordinal)
.ToList();

if (alertWorthy.Count == 0)
{
return null;
}

Dictionary<string, string> 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<string, string>(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<string> 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<string> Validate(
SecurityEventEnvelope candidate,
DateTimeOffset now)
Expand Down Expand Up @@ -176,7 +356,8 @@ public sealed record ProcessingResult(
bool Queued,
bool Duplicate,
IReadOnlyList<string> Reasons,
PolicyDecision? PolicyDecision)
PolicyDecision? PolicyDecision,
string? PrivacyAlertEventId = null)
{
public static ProcessingResult Failed(DecisionAction action, string reason)
{
Expand Down
Loading