Skip to content
Open
Show file tree
Hide file tree
Changes from 1 commit
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
9 changes: 0 additions & 9 deletions .github/workflows/publish-npm.yml
Original file line number Diff line number Diff line change
Expand Up @@ -130,19 +130,10 @@ jobs:
echo "- $name@$version"
fi
done
echo ""
if [ "${{ github.event_name }}" = "workflow_dispatch" ]; then
echo "### ⚠️ Dry run — packages were built but will NOT be published"
echo "Publishing only happens via release.yml (workflow_call)."
fi
} >> "$GITHUB_STEP_SUMMARY"

# Publish to npm (only via workflow_call from release.yml)
# workflow_dispatch is build-only because npm trusted publishing OIDC claims
# are bound to the caller workflow filename (release.yml), not this file.
publish:
name: Publish to npm
if: github.event_name != 'workflow_dispatch'
runs-on: ubuntu-latest
needs: [build]
Comment on lines 135 to 138
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Keep publish job gated from workflow_dispatch runs

Removing the if: github.event_name != 'workflow_dispatch' guard causes manual workflow_dispatch executions to run the production publish job (pnpm publish --provenance) instead of staying build-only. That changes dry-run behavior into a real publish path, so an operator-triggered verification run can now either publish packages unexpectedly or fail during trusted-publishing validation, which is a release-blocking regression for this workflow.

Useful? React with 👍 / 👎.

environment: npm-production-publishing
Expand Down
267 changes: 261 additions & 6 deletions crates/goose/src/session/diagnostics.rs
Original file line number Diff line number Diff line change
Expand Up @@ -5,13 +5,14 @@
use crate::providers::utils::LOGS_TO_KEEP;
use crate::session::SessionManager;
use serde::{Deserialize, Serialize};
use std::collections::HashMap;
use std::fs;
use std::io::Cursor;
use std::io::Write;

Check warning on line 11 in crates/goose/src/session/diagnostics.rs

View workflow job for this annotation

GitHub Actions / Check Rust Code Format

Diff in /home/runner/work/goose/goose/crates/goose/src/session/diagnostics.rs
use std::path::PathBuf;
use utoipa::ToSchema;
use zip::write::SimpleFileOptions;
use zip::ZipWriter;
use zip::write::SimpleFileOptions;

#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)]
pub struct SystemInfo {
Expand Down Expand Up @@ -134,6 +135,142 @@
entries.last().map(|e| e.path())
}

/// Shannon entropy in bits per character.
fn shannon_entropy(s: &str) -> f64 {
let len = s.len() as f64;
if len == 0.0 {
return 0.0;
}
let mut counts: HashMap<u8, usize> = HashMap::new();
for &b in s.as_bytes() {
*counts.entry(b).or_default() += 1;
}
counts
.values()
.map(|&c| {
let p = c as f64 / len;
-p * p.log2()
})
.sum()
}

const ENTROPY_THRESHOLD: f64 = 3.5;
const MIN_SECRET_LEN: usize = 20;

/// Returns true if `token` looks like a secret based on length, entropy, and
/// character composition. Secrets (API keys, tokens) are long, high-entropy
/// strings composed almost entirely of alphanumeric chars, hyphens, and
/// underscores. Non-secrets like URLs, paths, model names, and descriptions
/// contain structural characters (slashes, spaces, colons, etc.).
///
/// Dotted tokens get special handling: JWT-shaped strings (three long base64
/// segments separated by dots) are treated as secrets, while hostnames and
/// version numbers are not.
fn looks_like_secret(token: &str) -> bool {
if token.len() < MIN_SECRET_LEN {
return false;
}
if shannon_entropy(token) <= ENTROPY_THRESHOLD {
return false;
}
// Anything with URL, path, or natural-language characters is not a secret.
if token.bytes().any(|b| {
matches!(
b,
b' ' | b'/'
| b':'
| b'@'
Comment on lines +180 to +182
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Detect dotted high-entropy tokens as secrets

The looks_like_secret heuristic unconditionally rejects any token containing . (b'.' in the denylist), which means JWT/OAuth bearer tokens like header.payload.signature are never redacted even when they are long and high-entropy. Because diagnostics export includes logs and session data where Authorization: Bearer ... values can appear, this leaves a common secret format exposed in the zip and undermines the masking goal.

Useful? React with 👍 / 👎.

| b','
| b';'
| b'!'
| b'?'
| b'('
| b')'
| b'['
| b']'
| b'{'
| b'}'
| b'='
| b'&'
| b'+'
Comment on lines +193 to +195
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Allow base64 secret characters in detector

looks_like_secret rejects any token containing = or +, so long high-entropy base64 secrets (for example Basic auth payloads ending in = or provider keys that include +) are forced to false and remain unredacted in exported diagnostics. Because this masking pass is the last protection before writing logs/session data to the zip, this character denylist creates a direct secret-leak path for common token formats.

Useful? React with 👍 / 👎.

| b'#'
)
}) {
return false;
}
// Dots need special handling: JWTs (header.payload.signature) are secrets,
// but hostnames (api.openai.com) and versions (6.2.9200) are not.
if token.contains('.') {
let parts: Vec<&str> = token.split('.').collect();
return parts.len() == 3 && parts.iter().all(|p| p.len() >= 4);
Comment on lines +203 to +205
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Redact dotted secrets beyond strict JWT shape

The new dotted-token branch returns true only when parts.len() == 3, so any high-entropy token with dots that is not exactly three segments is forced to false and will be exported unredacted. This still leaks common non-JWT bearer formats (for example opaque OAuth tokens containing a single dot), so diagnostics masking remains incomplete for real authorization headers. Fresh evidence in this patch is the newly added parts.len() == 3 gate.

Useful? React with 👍 / 👎.

}
true
}

/// Redact high-entropy tokens that look like secrets from arbitrary text.
///
/// For `key: value` lines (YAML-style), checks the whole value and individual
/// words within it. For JSON, checks quoted string values. Key names are always
/// preserved for debugging.
#[allow(clippy::string_slice)] // All splits are on ASCII delimiters ('"', ": ") — safe.
fn redact_secrets(text: &str) -> String {
text.lines()
.map(|line| {
// YAML-style "KEY: value" — check the value and its individual words
if let Some(colon_pos) = line.find(": ") {
let key_part = &line[..colon_pos + 2];
let value_part = line[colon_pos + 2..].trim();
if looks_like_secret(value_part) {
return format!("{}[REDACTED]", key_part);
}
// Check individual words (handles "Bearer <token>" etc.)
let words: Vec<&str> = value_part.split_whitespace().collect();
if words.iter().any(|w| looks_like_secret(w)) {
let redacted_words: Vec<String> = words
.iter()
.map(|w| {
if looks_like_secret(w) {
"[REDACTED]".to_string()
} else {
w.to_string()
}
})
.collect();
return format!("{}{}", key_part, redacted_words.join(" "));
}
}

// JSON-style — redact quoted high-entropy values
let mut result = String::with_capacity(line.len());
let mut rest = line;
while !rest.is_empty() {
if let Some(q_start) = rest.find('"') {
result.push_str(&rest[..q_start + 1]);
let after_quote = &rest[q_start + 1..];
if let Some(q_end) = after_quote.find('"') {
let inner = &after_quote[..q_end];
if looks_like_secret(inner) {
result.push_str("[REDACTED]");
} else {
result.push_str(inner);
}
result.push('"');
rest = &after_quote[q_end + 1..];
} else {
result.push_str(after_quote);
break;
}
} else {
result.push_str(rest);
break;
}
}
result
})
.collect::<Vec<_>>()
.join("\n")
}

pub async fn generate_diagnostics(
session_manager: &SessionManager,
session_id: &str,
Expand Down Expand Up @@ -162,24 +299,29 @@
let path = entry.path();
let name = path.file_name().unwrap().to_str().unwrap();
zip.start_file(format!("logs/{}", name), options)?;
zip.write_all(&fs::read(&path)?)?;
let bytes = fs::read(&path)?;
let content = String::from_utf8_lossy(&bytes);
zip.write_all(redact_secrets(&content).as_bytes())?;
}

if let Some(server_log) = latest_server_log_path() {
if let Ok(content) = fs::read(&server_log) {
if let Ok(bytes) = fs::read(&server_log) {
let name = server_log.file_name().unwrap().to_str().unwrap();
zip.start_file(format!("logs/server/{}", name), options)?;
zip.write_all(&content)?;
let content = String::from_utf8_lossy(&bytes);
zip.write_all(redact_secrets(&content).as_bytes())?;
}
}

let session_data = session_manager.export_session(session_id).await?;
zip.start_file("session.json", options)?;
zip.write_all(session_data.as_bytes())?;
zip.write_all(redact_secrets(&session_data).as_bytes())?;

if config_path.exists() {
let bytes = fs::read(&config_path)?;
let content = String::from_utf8_lossy(&bytes);
zip.start_file("config.yaml", options)?;
zip.write_all(&fs::read(&config_path)?)?;
zip.write_all(redact_secrets(&content).as_bytes())?;
}

zip.start_file("system.txt", options)?;
Expand Down Expand Up @@ -215,3 +357,116 @@

Ok(buffer)
}

#[cfg(test)]
mod tests {
use super::*;

#[test]
fn redacts_api_key_in_yaml() {
let input = "OPENROUTER_API_KEY: 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";
let output = redact_secrets(input);
assert_eq!(output, "OPENROUTER_API_KEY: [REDACTED]");
}

#[test]
fn redacts_api_key_in_json() {
let input = r#"{"key": "OPENROUTER_API_KEY", "value": "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"}"#;
let output = redact_secrets(input);
assert!(output.contains("[REDACTED]"), "secret should be redacted");
assert!(
!output.contains("6c82424c"),
"raw key material must not appear"
);
assert!(
output.contains("OPENROUTER_API_KEY"),
"key name should survive"
);
}

#[test]
fn preserves_normal_config_values() {
let input = "GOOSE_PROVIDER: openrouter\nGOOSE_MODEL: openai/gpt-4o\nOPENROUTER_HOST: https://openrouter.ai";
assert_eq!(redact_secrets(input), input);
}

#[test]
fn preserves_short_values() {
let input = "GOOSE_MODE: auto\nenabled: true";
assert_eq!(redact_secrets(input), input);
}

#[test]
fn preserves_model_names() {
let input = "model: anthropic/claude-sonnet-4\nfast: google/gemini-2.5-flash";
assert_eq!(redact_secrets(input), input);
}

#[test]
fn preserves_descriptions() {
let input = "description: Write and edit files, and execute shell commands";
assert_eq!(redact_secrets(input), input);
}

#[test]
fn preserves_urls() {
let input = "host: https://api.openai.com\nreferer: https://goose-docs.ai";
assert_eq!(redact_secrets(input), input);
}

#[test]
fn redacts_bearer_token_in_yaml_value() {
let input = "Authorization: Bearer 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";
let output = redact_secrets(input);
// The YAML-style "key: value" check fires on "Authorization: Bearer sk-..."
// The bearer value itself is the high-entropy part
assert!(!output.contains("6c82424c"), "raw key must not appear");
}

#[test]
fn redacts_multiple_secrets_preserves_normal() {
let input = "\
OPENAI_API_KEY: sk-proj-abc123def456ghi789jkl012mno345pqr678stu901vwx234yz\n\
GOOSE_PROVIDER: openrouter\n\
ANTHROPIC_API_KEY: sk-ant-api03-xyzabc123def456ghi789jkl012mno345pqr678stu901vwx";
let output = redact_secrets(input);
assert!(output.contains("OPENAI_API_KEY: [REDACTED]"));
assert!(output.contains("GOOSE_PROVIDER: openrouter"));
assert!(output.contains("ANTHROPIC_API_KEY: [REDACTED]"));
}

#[test]
fn detects_jwt_tokens() {
assert!(looks_like_secret(
"eyJhbGciOiJSUzI1NiJ9.eyJzdWIiOiIxMjM0NTY3ODkwIn0.abc123signature456xyz"
));
}

#[test]
fn preserves_hostnames_and_versions() {
assert!(!looks_like_secret("api.openai.com"));
assert!(!looks_like_secret("openrouter.ai"));
assert!(!looks_like_secret("6.2.9200"));
}

#[test]
fn secret_detection_accuracy() {
// Real API keys: detected
assert!(looks_like_secret(
"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"
));
assert!(looks_like_secret(
"sk-proj-abc123def456ghi789jkl012mno345pqr678stu901vwx234yz"
));

// Normal values: not detected
assert!(!looks_like_secret("openrouter"));
assert!(!looks_like_secret("https://openrouter.ai"));
assert!(!looks_like_secret("anthropic/claude-sonnet-4"));
assert!(!looks_like_secret("google/gemini-2.5-flash"));
assert!(!looks_like_secret(
"Write and edit files, and execute shell commands"
));
assert!(!looks_like_secret("OPENROUTER_API_KEY"));
}
}
Loading