1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
//! Security alert events emitted by the gateway.
//!
//! Currently hosts [`SecretAlert`] — fired when the policy engine's
//! credential scanner produces a non-empty `credential_findings` list
//! for an outbound payload (AAASM-1545).
//!
//! Security invariant: this struct stores only [`CredentialKind`] tags
//! and finding counts. The raw matched bytes never appear here and must
//! not be added to any field.
use aa_core::AgentId;
use aa_security::CredentialKind;
/// Broadcast event raised when one or more credential / sensitive-value
/// patterns are detected in an evaluated payload.
///
/// Consumers (e.g. `aa-api::alerts::capture::spawn_secret_alert_capture`)
/// turn this into a persistent `StoredAlert` for the public alerts API.
#[derive(Debug, Clone)]
pub struct SecretAlert {
/// The agent whose outbound payload triggered the scanner.
pub agent_id: AgentId,
/// Team attribution propagated from the request context, when present.
pub team_id: Option<String>,
/// All distinct credential kinds detected in the payload, in the
/// order returned by the scanner pass. Always non-empty when emitted.
pub kinds: Vec<CredentialKind>,
/// Total number of credential findings produced by the scanner pass.
/// May exceed `kinds.len()` when the same kind matches more than once.
pub finding_count: usize,
}
impl SecretAlert {
/// The primary detected pattern kind, used as the alert's
/// `detected_pattern_type` in the public API. Falls back to the
/// generic [`CredentialKind::Custom`] only if the kinds list is
/// empty — which should never happen in practice since the gateway
/// only emits this alert when findings are non-empty.
pub fn primary_kind(&self) -> CredentialKind {
self.kinds.first().cloned().unwrap_or(CredentialKind::Custom)
}
/// The `[REDACTED:<Kind>]` label corresponding to `primary_kind`.
/// Never contains any byte of the original secret.
pub fn redacted_label(&self) -> String {
format!("[REDACTED:{}]", self.primary_kind().as_str())
}
}
#[cfg(test)]
mod tests {
use super::*;
fn agent() -> AgentId {
AgentId::from_bytes([0x42; 16])
}
#[test]
fn primary_kind_returns_first() {
let alert = SecretAlert {
agent_id: agent(),
team_id: None,
kinds: vec![CredentialKind::AwsAccessKey, CredentialKind::OpenAiKey],
finding_count: 2,
};
assert_eq!(alert.primary_kind(), CredentialKind::AwsAccessKey);
}
#[test]
fn redacted_label_uses_primary_kind() {
let alert = SecretAlert {
agent_id: agent(),
team_id: Some("team-x".to_string()),
kinds: vec![CredentialKind::GitHubPat],
finding_count: 1,
};
assert_eq!(alert.redacted_label(), "[REDACTED:GitHubPat]");
}
#[test]
fn primary_kind_falls_back_to_custom_when_empty() {
let alert = SecretAlert {
agent_id: agent(),
team_id: None,
kinds: vec![],
finding_count: 0,
};
assert_eq!(alert.primary_kind(), CredentialKind::Custom);
}
}