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
50 changes: 9 additions & 41 deletions .secrets.baseline

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion plugins/rust/python-package/secrets_detection/Cargo.toml
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
[package]
name = "secrets_detection"
version = "0.3.9"
version = "0.3.10"
edition.workspace = true
authors.workspace = true
license.workspace = true
Expand Down
2 changes: 1 addition & 1 deletion plugins/rust/python-package/secrets_detection/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -132,7 +132,7 @@ config:

- Redaction preserves payload shape where possible instead of flattening everything to plain dicts.
- `aws_secret_access_key` recognizes `=` and `:` assignments with optional single or double quotes around the value.
- `base64_24` uses capture-group redaction so leading non-base64 boundary characters are preserved.
- `aws_secret_access_key`, `generic_api_key_assignment`, and `base64_24` use capture-group redaction so only the detected secret span is replaced; assignment labels, separators, quotes, and leading non-base64 boundary characters are preserved.
- Broad detectors remain opt-in to reduce noisy matches on ordinary identifiers.
- Binary resource bodies are not scanned; `resource_post_fetch` only scans text content exposed as `payload.content.text`.
- The plugin does not decode archives, compressed data, or arbitrary encoded blobs before scanning.
Expand Down
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
description: "Detect likely credentials and secrets in prompt input, tool output, and resource content"
author: "ContextForge Contributors"
version: "0.3.9"
version: "0.3.10"
kind: "cpex_secrets_detection.secrets_detection.SecretsDetectionPlugin"
available_hooks:
- "prompt_pre_fetch"
Expand Down
11 changes: 8 additions & 3 deletions plugins/rust/python-package/secrets_detection/src/patterns.rs
Original file line number Diff line number Diff line change
Expand Up @@ -35,7 +35,7 @@ pub static PATTERNS: LazyLock<HashMap<&'static str, Regex>> = LazyLock::new(|| {
patterns.insert(
"generic_api_key_assignment",
Regex::new(
r#"(?ix)\b(?:(?:x[-_])?api[-_]?key|apikey|api[_-]?token|access[_-]?token|bearer[_-]?token|auth[_-]?token)\b\s*[:=]\s*['"]?[A-Za-z0-9_\-]{20,}['"]?"#,
r#"(?ix)\b(?:(?:x[-_])?api[-_]?key|apikey|api[_-]?token|access[_-]?token|bearer[_-]?token|auth[_-]?token)\b\s*[:=]\s*['"]?([A-Za-z0-9_\-]{20,})['"]?"#,
)
.expect("valid generic_api_key_assignment regex"),
);
Expand Down Expand Up @@ -72,8 +72,13 @@ pub static PATTERNS: LazyLock<HashMap<&'static str, Regex>> = LazyLock::new(|| {
patterns
});

pub static CAPTURE_PATTERNS: LazyLock<HashSet<&'static str>> =
LazyLock::new(|| HashSet::from(["base64_24"]));
pub static CAPTURE_PATTERNS: LazyLock<HashSet<&'static str>> = LazyLock::new(|| {
HashSet::from([
"aws_secret_access_key",
"generic_api_key_assignment",
"base64_24",
])
});

#[cfg(test)]
mod tests {
Expand Down
55 changes: 36 additions & 19 deletions plugins/rust/python-package/secrets_detection/src/scanner.rs
Original file line number Diff line number Diff line change
Expand Up @@ -128,10 +128,10 @@ mod tests {

assert_eq!(findings.len(), 1, "{name}: {findings:?}");
assert_eq!(findings[0].pii_type, "aws_secret_access_key", "{name}");
assert!(!redacted.contains(SECRET_FIXTURE), "{name}: {redacted}");
assert!(
redacted.contains(&config.redaction_text),
"{name}: {redacted}"
assert_eq!(
redacted,
text.replace(SECRET_FIXTURE, &config.redaction_text),
"{name}"
);
}
}
Expand Down Expand Up @@ -212,7 +212,7 @@ mod tests {
}

#[test]
fn redacts_each_supported_secret_as_one_replacement_with_all_patterns_enabled() {
fn redacts_only_secret_value_for_each_supported_detector_with_all_patterns_enabled() {
let config = SecretsDetectionConfig {
enabled: crate::patterns::PATTERNS
.keys()
Expand All @@ -223,56 +223,73 @@ mod tests {
..Default::default()
};

for (name, secret) in [
("aws_access_key_id", "AKIAFAKE12345EXAMPLE".to_string()),
for (name, text, expected) in [
(
"aws_access_key_id",
"aws_access_key_id=AKIAFAKE12345EXAMPLE".to_string(),
"aws_access_key_id=[TESTING-REDACTED]".to_string(),
),
(
"aws_secret_access_key",
"AWS_SECRET_ACCESS_KEY=FAKESecretAccessKeyForTestingEXAMPLE0000".to_string(),
"aws_secret_access_key=\"FAKESecretAccessKeyForTestingEXAMPLE0000\"".to_string(),
"aws_secret_access_key=\"[TESTING-REDACTED]\"".to_string(),
),
(
"google_api_key",
"AIzaAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA".to_string(),
"google_api_key=AIzaAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA".to_string(),
"google_api_key=[TESTING-REDACTED]".to_string(),
),
(
"github_token",
"ghp_abcdefghijklmnopqrstuvwxyz0123456789".to_string(),
"github_token=ghp_abcdefghijklmnopqrstuvwxyz0123456789".to_string(), // pragma: allowlist secret
"github_token=[TESTING-REDACTED]".to_string(),
),
(
"stripe_secret_key",
"sk_test_abcdefghijklmnopqrstuvwxyz".to_string(),
"stripe_secret_key=sk_test_abcdefghijklmnopqrstuvwxyz".to_string(),
"stripe_secret_key=[TESTING-REDACTED]".to_string(),
),
(
"generic_api_key_assignment",
"api_key=test12345678901234567890".to_string(),
"api_key=\"test12345678901234567890\"".to_string(),
"api_key=\"[TESTING-REDACTED]\"".to_string(),
),
(
"slack_token",
[
"xoxb",
"slack_token=xoxb",
"123456789012",
"123456789012",
"abcdefghijklmnopqrstuvwx",
]
.join("-"),
"slack_token=[TESTING-REDACTED]".to_string(),
),
(
"private_key_block",
"-----BEGIN RSA PRIVATE KEY-----".to_string(),
"private_key_block=-----BEGIN RSA PRIVATE KEY-----".to_string(),
"private_key_block=[TESTING-REDACTED]".to_string(),
),
(
"jwt_like",
"eyJaaaaaaaaaaa.eyJbbbbbbbbbbb.cccccccccccccc".to_string(),
"jwt_like=eyJaaaaaaaaaaa.eyJbbbbbbbbbbb.cccccccccccccc".to_string(),
"jwt_like=[TESTING-REDACTED]".to_string(),
),
(
"hex_secret_32",
"0123456789abcdef0123456789abcdef".to_string(),
"hex_secret_32=0123456789abcdef0123456789abcdef".to_string(),
"hex_secret_32=[TESTING-REDACTED]".to_string(),
),
(
"base64_24",
"base64_24=QUJDREVGR0hJSktMTU5PUFFSU1RVVldY".to_string(), // pragma: allowlist secret
"base64_24=[TESTING-REDACTED]".to_string(),
),
("base64_24", "QUJDREVGR0hJSktMTU5PUFFSU1RVVldY".to_string()),
] {
let (findings, redacted) = detect_and_redact(&secret, &config);
let (findings, redacted) = detect_and_redact(&text, &config);
assert_eq!(findings.len(), 1, "{name}: {findings:?}");
assert_eq!(findings[0].pii_type, name, "{name}: {findings:?}");
assert_eq!(redacted, config.redaction_text, "{name}");
assert_eq!(redacted, expected, "{name}");
}
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -235,7 +235,7 @@ class CleanObject:
);
assert_eq!(
redacted_tuple.get_item(1)?.extract::<String>()?,
"[REDACTED]"
"AWS_SECRET_ACCESS_KEY=[REDACTED]"
);

Ok(())
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -517,8 +517,8 @@ class LeakModel:
let internal = redacted.getattr("internal")?.extract::<String>()?;
let external = redacted.getattr("external")?.extract::<String>()?;

assert_eq!(internal, config.redaction_text);
assert_eq!(external, config.redaction_text);
assert_eq!(internal, "AWS_SECRET_ACCESS_KEY=[REDACTED]");
assert_eq!(external, "AWS_SECRET_ACCESS_KEY=[REDACTED]");
assert_ne!(
internal,
"AWS_SECRET_ACCESS_KEY=FAKESecretAccessKeyForTestingEXAMPLE0000"
Expand Down Expand Up @@ -847,7 +847,11 @@ class Model:
.iter()
.filter_map(|value| value.extract::<String>().ok())
.collect();
assert!(values.iter().any(|value| value == "[REDACTED]"));
assert!(
values
.iter()
.any(|value| value == "AWS_SECRET_ACCESS_KEY=[REDACTED]")
);

Ok(())
})
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -26,13 +26,15 @@ pub fn detect_and_redact(text: &str, config: &SecretsDetectionConfig) -> (Vec<Fi
}

if CAPTURE_PATTERNS.contains(name) {
// Capturing patterns exclude boundary chars from findings. Rust
// regex has no lookbehind, so group 1 is the actual secret span.
// Capturing patterns exclude labels, quotes, or boundary chars
// from findings. Group 1 is always the actual secret span.
for captures in pattern.captures_iter(text) {
let Some(matched) = captures.get(1) else {
continue;
};
if is_base64_boundary_char(text[matched.end()..].chars().next()) {
if *name == "base64_24"
&& is_base64_boundary_char(text[matched.end()..].chars().next())
{
continue;
}
candidates.push(MatchCandidate {
Expand Down
5 changes: 3 additions & 2 deletions plugins/tests/secrets_detection/test_plugin_hook_results.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,8 @@

from secrets_detection.helpers import * # noqa: F403,F405

AWS_SECRET_VALUE = "FAKESecretAccessKeyForTestingEXAMPLE0000" # pragma: allowlist secret

AWS_SECRET_ASSIGNMENTS = [
pytest.param(
'aws_secret_access_key = "FAKESecretAccessKeyForTestingEXAMPLE0000"', # pragma: allowlist secret
Expand Down Expand Up @@ -85,8 +87,7 @@ async def test_tool_post_invoke_redacts_aws_secret_assignment(
assert result.violation is None
assert result.modified_payload is not None
redacted = result.modified_payload.result["content"][0]["text"]
assert "[REDACTED]" in redacted
assert redacted != assignment
assert redacted == assignment.replace(AWS_SECRET_VALUE, "[REDACTED]")
assert payload.result["content"][0]["text"] == assignment

@pytest.mark.parametrize("assignment", AWS_SECRET_ASSIGNMENTS)
Expand Down
4 changes: 2 additions & 2 deletions plugins/tests/secrets_detection/test_public_rust_api.py
Original file line number Diff line number Diff line change
Expand Up @@ -189,7 +189,7 @@ def __init__(self):
assert isinstance(redacted, SecretBox)
assert redacted.value == "AWS_ACCESS_KEY_ID=[REDACTED]"
assert any(
value == "[REDACTED]"
value == "AWS_SECRET_ACCESS_KEY=[REDACTED]"
for key, value in redacted.__dict__.items()
if not isinstance(key, str)
)
Expand Down Expand Up @@ -368,7 +368,7 @@ def model_dump(self):
{"type": "aws_access_key_id"},
{"type": "aws_secret_access_key"},
]
assert redacted == "[REDACTED]"
assert redacted == "AWS_SECRET_ACCESS_KEY=[REDACTED]"

def test_scan_container_redacts_non_replayable_custom_object(self):
class NonReplayableBox:
Expand Down