feat(security): unified OTLP logging schema for cross-tool detection (#9713)
This commit is contained in:
committed by
GitHub
parent
f23347fc30
commit
8b5a7b8f8e
Generated
+1
@@ -4572,6 +4572,7 @@ dependencies = [
|
||||
"fs-err 3.3.0",
|
||||
"fs2",
|
||||
"futures",
|
||||
"gethostname",
|
||||
"goose-acp-macros",
|
||||
"goose-mcp",
|
||||
"goose-providers",
|
||||
|
||||
@@ -216,6 +216,7 @@ icu_locale = { version = "=2.1.1", default-features = false }
|
||||
llama-cpp-sys-2 = { workspace = true, optional = true }
|
||||
image = { version = "0.24.9", default-features = false, features = ["png", "jpeg", "gif", "webp"] }
|
||||
subtle = { version = "2.5", default-features = false, features = ["std"] }
|
||||
gethostname = "1.1.0"
|
||||
|
||||
[target.'cfg(target_os = "windows")'.dependencies]
|
||||
winapi = { workspace = true }
|
||||
|
||||
@@ -1719,7 +1719,15 @@ impl Agent {
|
||||
.count();
|
||||
|
||||
let working_dir = session.working_dir.clone();
|
||||
let reply_stream_span = tracing::info_span!(target: "goose::agents::agent", "reply_stream", trace_output = tracing::field::Empty, session.id = %session_config.id);
|
||||
let reply_stream_span = tracing::info_span!(
|
||||
target: "goose::agents::agent",
|
||||
"reply_stream",
|
||||
trace_output = tracing::field::Empty,
|
||||
session.id = %session_config.id,
|
||||
session.user = %crate::session_context::session_user(),
|
||||
session.host = %crate::session_context::session_host(),
|
||||
session.agent_type = "goose",
|
||||
);
|
||||
let inner = Box::pin(async_stream::try_stream! {
|
||||
let mut turns_taken = 0u32;
|
||||
let max_turns = session_config.max_turns.unwrap_or_else(|| {
|
||||
|
||||
@@ -112,12 +112,18 @@ impl Agent {
|
||||
.map_err(|_| anyhow::anyhow!("Confirmation channel closed for request {}", request.id))?;
|
||||
|
||||
if let Some(finding_id) = get_security_finding_id_from_results(&request.id, inspection_results) {
|
||||
let action = match confirmation.permission {
|
||||
Permission::AllowOnce | Permission::AlwaysAllow => "ALLOW",
|
||||
_ => "BLOCK",
|
||||
};
|
||||
tracing::info!(
|
||||
monotonic_counter.goose.prompt_injection_user_decisions = 1,
|
||||
decision = ?confirmation.permission,
|
||||
finding_id = %finding_id,
|
||||
tool_request_id = %request.id,
|
||||
"Prompt injection detection: user decision on command injection finding"
|
||||
security.event_type = "user_decision",
|
||||
security.action = action,
|
||||
security.finding_id = %finding_id,
|
||||
tool.request_id = %request.id,
|
||||
user.decision = ?confirmation.permission,
|
||||
"security finding: user decision"
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -145,11 +145,15 @@ pub fn promote_config_to_env(config: &crate::config::Config) {
|
||||
}
|
||||
|
||||
fn create_resource() -> Resource {
|
||||
use crate::session_context::{session_host, session_user};
|
||||
|
||||
let mut builder = Resource::builder_empty()
|
||||
.with_attributes([
|
||||
KeyValue::new("service.name", "goose"),
|
||||
KeyValue::new("service.version", env!("CARGO_PKG_VERSION")),
|
||||
KeyValue::new("service.namespace", "goose"),
|
||||
KeyValue::new("host.name", session_host()),
|
||||
KeyValue::new("user.name", session_user()),
|
||||
])
|
||||
.with_detector(Box::new(EnvResourceDetector::new()))
|
||||
.with_detector(Box::new(TelemetryResourceDetector));
|
||||
@@ -304,7 +308,12 @@ fn create_otlp_logs_layer() -> OtlpResult<OtlpLogsLayer> {
|
||||
};
|
||||
|
||||
let bridge = OpenTelemetryTracingBridge::builder(&logger_provider)
|
||||
.with_tracing_span_attributes(TracingSpanAttributes::allowlist(["session.id"]))
|
||||
.with_tracing_span_attributes(TracingSpanAttributes::allowlist([
|
||||
"session.id",
|
||||
"session.user",
|
||||
"session.host",
|
||||
"session.agent_type",
|
||||
]))
|
||||
.build();
|
||||
*LOGGER_PROVIDER.lock().unwrap_or_else(|e| e.into_inner()) = Some(logger_provider);
|
||||
|
||||
@@ -598,45 +607,79 @@ mod tests {
|
||||
shutdown_otlp();
|
||||
}
|
||||
|
||||
#[test_case(
|
||||
&[],
|
||||
Resource::builder_empty()
|
||||
.with_attributes([KeyValue::new("service.name", "goose"), KeyValue::new("service.version", env!("CARGO_PKG_VERSION")), KeyValue::new("service.namespace", "goose")])
|
||||
.with_detector(Box::new(TelemetryResourceDetector))
|
||||
.build();
|
||||
"no env vars uses goose defaults"
|
||||
)]
|
||||
#[test_case(
|
||||
&[("OTEL_SERVICE_NAME", "custom")],
|
||||
Resource::builder_empty()
|
||||
.with_attributes([KeyValue::new("service.name", "goose"), KeyValue::new("service.version", env!("CARGO_PKG_VERSION")), KeyValue::new("service.namespace", "goose")])
|
||||
.with_detector(Box::new(TelemetryResourceDetector))
|
||||
.with_service_name("custom")
|
||||
.build();
|
||||
"OTEL_SERVICE_NAME overrides service.name"
|
||||
)]
|
||||
#[test_case(
|
||||
&[("OTEL_RESOURCE_ATTRIBUTES", "deployment.environment=prod")],
|
||||
Resource::builder_empty()
|
||||
.with_attributes([KeyValue::new("service.name", "goose"), KeyValue::new("service.version", env!("CARGO_PKG_VERSION")), KeyValue::new("service.namespace", "goose")])
|
||||
.with_detector(Box::new(TelemetryResourceDetector))
|
||||
.with_attribute(KeyValue::new("deployment.environment", "prod"))
|
||||
.build();
|
||||
"OTEL_RESOURCE_ATTRIBUTES adds custom attributes"
|
||||
)]
|
||||
#[test_case(
|
||||
&[("OTEL_SERVICE_NAME", "custom"), ("OTEL_RESOURCE_ATTRIBUTES", "deployment.environment=prod")],
|
||||
Resource::builder_empty()
|
||||
.with_attributes([KeyValue::new("service.name", "goose"), KeyValue::new("service.version", env!("CARGO_PKG_VERSION")), KeyValue::new("service.namespace", "goose")])
|
||||
.with_detector(Box::new(TelemetryResourceDetector))
|
||||
.with_service_name("custom")
|
||||
.with_attribute(KeyValue::new("deployment.environment", "prod"))
|
||||
.build();
|
||||
"OTEL_SERVICE_NAME and OTEL_RESOURCE_ATTRIBUTES combine"
|
||||
)]
|
||||
fn test_create_resource(env: &[(&'static str, &'static str)], expected: Resource) {
|
||||
let _guard = clear_otel_env(env);
|
||||
assert_eq!(create_resource(), expected);
|
||||
#[test]
|
||||
fn test_create_resource_defaults() {
|
||||
let _guard = clear_otel_env(&[]);
|
||||
let resource = create_resource();
|
||||
let attrs: Vec<_> = resource.iter().collect();
|
||||
let get = |key: &str| {
|
||||
attrs
|
||||
.iter()
|
||||
.find(|(k, _)| k.as_str() == key)
|
||||
.map(|(_, v)| v.to_string())
|
||||
};
|
||||
|
||||
assert_eq!(get("service.name").as_deref(), Some("goose"));
|
||||
assert_eq!(
|
||||
get("service.version").as_deref(),
|
||||
Some(env!("CARGO_PKG_VERSION"))
|
||||
);
|
||||
assert_eq!(get("service.namespace").as_deref(), Some("goose"));
|
||||
assert!(get("host.name").is_some(), "host.name should be set");
|
||||
assert!(get("user.name").is_some(), "user.name should be set");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_create_resource_otel_service_name_overrides() {
|
||||
let _guard = clear_otel_env(&[("OTEL_SERVICE_NAME", "custom")]);
|
||||
let resource = create_resource();
|
||||
let attrs: Vec<_> = resource.iter().collect();
|
||||
let get = |key: &str| {
|
||||
attrs
|
||||
.iter()
|
||||
.find(|(k, _)| k.as_str() == key)
|
||||
.map(|(_, v)| v.to_string())
|
||||
};
|
||||
|
||||
assert_eq!(get("service.name").as_deref(), Some("custom"));
|
||||
assert_eq!(get("service.namespace").as_deref(), Some("goose"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_create_resource_otel_resource_attributes() {
|
||||
let _guard = clear_otel_env(&[("OTEL_RESOURCE_ATTRIBUTES", "deployment.environment=prod")]);
|
||||
let resource = create_resource();
|
||||
let attrs: Vec<_> = resource.iter().collect();
|
||||
let get = |key: &str| {
|
||||
attrs
|
||||
.iter()
|
||||
.find(|(k, _)| k.as_str() == key)
|
||||
.map(|(_, v)| v.to_string())
|
||||
};
|
||||
|
||||
assert_eq!(get("service.name").as_deref(), Some("goose"));
|
||||
assert_eq!(get("deployment.environment").as_deref(), Some("prod"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_create_resource_combined() {
|
||||
let _guard = clear_otel_env(&[
|
||||
("OTEL_SERVICE_NAME", "custom"),
|
||||
("OTEL_RESOURCE_ATTRIBUTES", "deployment.environment=prod"),
|
||||
]);
|
||||
let resource = create_resource();
|
||||
let attrs: Vec<_> = resource.iter().collect();
|
||||
let get = |key: &str| {
|
||||
attrs
|
||||
.iter()
|
||||
.find(|(k, _)| k.as_str() == key)
|
||||
.map(|(_, v)| v.to_string())
|
||||
};
|
||||
|
||||
assert_eq!(get("service.name").as_deref(), Some("custom"));
|
||||
assert_eq!(get("deployment.environment").as_deref(), Some("prod"));
|
||||
assert!(get("host.name").is_some());
|
||||
assert!(get("user.name").is_some());
|
||||
}
|
||||
|
||||
#[test_case(&[("RUST_LOG", "")], Level::INFO; "default is info")]
|
||||
|
||||
@@ -380,6 +380,10 @@ impl ToolInspector for AdversaryInspector {
|
||||
}
|
||||
|
||||
let tool_description = Self::format_tool_call(request);
|
||||
let tool_call_name = match &request.tool_call {
|
||||
Ok(tc) => tc.name.to_string(),
|
||||
Err(_) => "unknown".to_string(),
|
||||
};
|
||||
|
||||
tracing::debug!(
|
||||
tool_request_id = %request.id,
|
||||
@@ -397,9 +401,13 @@ impl ToolInspector for AdversaryInspector {
|
||||
{
|
||||
Ok((true, reason)) => {
|
||||
tracing::debug!(
|
||||
tool_request_id = %request.id,
|
||||
reason = %reason,
|
||||
"Adversary: ALLOW"
|
||||
security.event_type = "adversary_detection",
|
||||
security.action = "ALLOW",
|
||||
security.confidence = 1.0_f32,
|
||||
security.explanation = %reason,
|
||||
tool.name = %tool_call_name,
|
||||
tool.request_id = %request.id,
|
||||
"adversary review: ALLOW"
|
||||
);
|
||||
results.push(InspectionResult {
|
||||
tool_request_id: request.id.clone(),
|
||||
@@ -412,9 +420,13 @@ impl ToolInspector for AdversaryInspector {
|
||||
}
|
||||
Ok((false, reason)) => {
|
||||
tracing::warn!(
|
||||
tool_request_id = %request.id,
|
||||
reason = %reason,
|
||||
"Adversary: BLOCK"
|
||||
security.event_type = "adversary_detection",
|
||||
security.action = "BLOCK",
|
||||
security.confidence = 1.0_f32,
|
||||
security.explanation = %reason,
|
||||
tool.name = %tool_call_name,
|
||||
tool.request_id = %request.id,
|
||||
"adversary review: BLOCK"
|
||||
);
|
||||
results.push(InspectionResult {
|
||||
tool_request_id: request.id.clone(),
|
||||
@@ -427,9 +439,13 @@ impl ToolInspector for AdversaryInspector {
|
||||
}
|
||||
Err(e) => {
|
||||
tracing::warn!(
|
||||
tool_request_id = %request.id,
|
||||
error = %e,
|
||||
"Adversary inspector failed, allowing tool call (fail-open)"
|
||||
security.event_type = "adversary_detection",
|
||||
security.action = "ALLOW",
|
||||
security.confidence = 0.0_f32,
|
||||
security.explanation = %format!("error (fail-open): {}", e),
|
||||
tool.name = %tool_call_name,
|
||||
tool.request_id = %request.id,
|
||||
"adversary review: error (fail-open)"
|
||||
);
|
||||
results.push(InspectionResult {
|
||||
tool_request_id: request.id.clone(),
|
||||
|
||||
@@ -354,12 +354,15 @@ impl ToolInspector for EgressInspector {
|
||||
|
||||
for dest in &destinations {
|
||||
tracing::info!(
|
||||
egress_kind = dest.kind.as_str(),
|
||||
domain = dest.domain.as_str(),
|
||||
destination = dest.destination.as_str(),
|
||||
direction = direction.as_str(),
|
||||
tool_name = name,
|
||||
"egress destination detected"
|
||||
security.event_type = "egress",
|
||||
security.action = "LOG",
|
||||
security.threat_type = "data_exfiltration",
|
||||
network.destination = dest.destination.as_str(),
|
||||
network.domain = dest.domain.as_str(),
|
||||
network.egress_kind = dest.kind.as_str(),
|
||||
network.direction = direction.as_str(),
|
||||
tool.name = name,
|
||||
"network egress detected"
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -162,22 +162,26 @@ impl SecurityManager {
|
||||
let tool_call_json =
|
||||
serde_json::to_string(&tool_call).unwrap_or_else(|_| "{}".to_string());
|
||||
|
||||
let action = if above_threshold { "BLOCK" } else { "LOG" };
|
||||
|
||||
tracing::warn!(
|
||||
monotonic_counter.goose.prompt_injection_finding = 1,
|
||||
threat_type = "command_injection",
|
||||
above_threshold = above_threshold,
|
||||
tool_name = %tool_call.name,
|
||||
tool_request_id = %tool_request.id,
|
||||
tool_call_json = %tool_call_json,
|
||||
confidence = analysis_result.confidence,
|
||||
explanation = %sanitized_explanation,
|
||||
finding_id = %finding_id,
|
||||
threshold = config_threshold,
|
||||
security.event_type = "prompt_injection_scan",
|
||||
security.action = action,
|
||||
security.confidence = analysis_result.confidence,
|
||||
security.threshold = config_threshold,
|
||||
security.above_threshold = above_threshold,
|
||||
security.threat_type = "command_injection",
|
||||
security.finding_id = %finding_id,
|
||||
security.explanation = %sanitized_explanation,
|
||||
tool.name = %tool_call.name,
|
||||
tool.request_id = %tool_request.id,
|
||||
tool.call_json = %tool_call_json,
|
||||
"{}",
|
||||
if above_threshold {
|
||||
"Prompt injection detection: Current tool call flagged as malicious after security analysis (above threshold)"
|
||||
"prompt injection scan: finding above threshold"
|
||||
} else {
|
||||
"Prompt injection detection: Security finding below threshold (logged but not blocking execution)"
|
||||
"prompt injection scan: finding below threshold"
|
||||
}
|
||||
);
|
||||
if above_threshold {
|
||||
@@ -196,12 +200,16 @@ impl SecurityManager {
|
||||
|
||||
tracing::info!(
|
||||
monotonic_counter.goose.prompt_injection_tool_call_passed = 1,
|
||||
tool_name = %tool_call.name,
|
||||
tool_request_id = %tool_request.id,
|
||||
tool_call_json = %tool_call_json,
|
||||
confidence = analysis_result.confidence,
|
||||
explanation = %sanitized_explanation,
|
||||
"Current tool call passed security analysis"
|
||||
security.event_type = "prompt_injection_scan",
|
||||
security.action = "ALLOW",
|
||||
security.confidence = analysis_result.confidence,
|
||||
security.threshold = config_threshold,
|
||||
security.above_threshold = false,
|
||||
security.threat_type = "command_injection",
|
||||
tool.name = %tool_call.name,
|
||||
tool.request_id = %tool_request.id,
|
||||
tool.call_json = %tool_call_json,
|
||||
"prompt injection scan: tool call passed"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -170,15 +170,16 @@ impl PromptInjectionScanner {
|
||||
self.combine_confidences(tool_result.confidence, context_result.ml_confidence);
|
||||
|
||||
tracing::info!(
|
||||
tool_confidence = %tool_result.confidence,
|
||||
context_confidence = ?context_result.ml_confidence,
|
||||
final_confidence = %final_confidence,
|
||||
used_command_ml = tool_result.ml_confidence.is_some(),
|
||||
used_prompt_ml = context_result.ml_confidence.is_some(),
|
||||
used_pattern_detection = tool_result.used_pattern_detection,
|
||||
threshold = %threshold,
|
||||
malicious = final_confidence >= threshold,
|
||||
"Security analysis complete"
|
||||
security.event_type = "prompt_injection_scan",
|
||||
security.confidence = final_confidence,
|
||||
security.threshold = threshold,
|
||||
security.above_threshold = final_confidence >= threshold,
|
||||
scanner.tool_confidence = tool_result.confidence,
|
||||
scanner.context_confidence = ?context_result.ml_confidence,
|
||||
scanner.used_command_ml = tool_result.ml_confidence.is_some(),
|
||||
scanner.used_prompt_ml = context_result.ml_confidence.is_some(),
|
||||
scanner.used_pattern_detection = tool_result.used_pattern_detection,
|
||||
"prompt injection scan: analysis complete"
|
||||
);
|
||||
|
||||
let final_result = DetailedScanResult {
|
||||
|
||||
@@ -22,6 +22,20 @@ pub fn current_session_id() -> Option<String> {
|
||||
SESSION_ID.try_with(|id| id.clone()).ok().flatten()
|
||||
}
|
||||
|
||||
/// Local OS user running goose, shared by the OTLP `user.name` resource
|
||||
/// attribute and the `session.user` span attribute so the two never drift.
|
||||
pub fn session_user() -> String {
|
||||
std::env::var("USER")
|
||||
.or_else(|_| std::env::var("LOGNAME"))
|
||||
.unwrap_or_else(|_| "unknown".to_string())
|
||||
}
|
||||
|
||||
/// Hostname of the machine running goose, shared by the OTLP `host.name`
|
||||
/// resource attribute and the `session.host` span attribute.
|
||||
pub fn session_host() -> String {
|
||||
gethostname::gethostname().to_string_lossy().to_string()
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
@@ -193,14 +193,21 @@ pub fn apply_inspection_results_to_permissions(
|
||||
for result in inspection_results {
|
||||
let request_id = &result.tool_request_id;
|
||||
|
||||
let action_str = match &result.action {
|
||||
InspectionAction::Deny => "BLOCK",
|
||||
InspectionAction::RequireApproval(_) => "ALERT",
|
||||
InspectionAction::Allow => "ALLOW",
|
||||
};
|
||||
|
||||
tracing::info!(
|
||||
inspector_name = result.inspector_name,
|
||||
tool_request_id = %request_id,
|
||||
action = ?result.action,
|
||||
confidence = result.confidence,
|
||||
reason = %result.reason,
|
||||
finding_id = ?result.finding_id,
|
||||
"Applying inspection result"
|
||||
security.event_type = "inspection_result",
|
||||
security.action = action_str,
|
||||
security.confidence = result.confidence,
|
||||
security.finding_id = ?result.finding_id,
|
||||
tool.request_id = %request_id,
|
||||
inspector.name = result.inspector_name,
|
||||
inspector.reason = %result.reason,
|
||||
"inspection result applied"
|
||||
);
|
||||
|
||||
match result.action {
|
||||
|
||||
Reference in New Issue
Block a user