mod assignment;
mod canonical;
mod detector;
mod path;
mod prose;
pub(crate) mod public;
pub(crate) mod source;
#[cfg(feature = "entropy")]
pub(crate) use assignment::looks_like_entropy_random_base64_blob_decoy;
pub(crate) use assignment::{
generic_base64_candidate_is_ambiguous, looks_like_entropy_canonical_hex_digest,
looks_like_entropy_canonical_non_secret_shape, looks_like_entropy_uuid_shape,
looks_like_generic_random_base64_blob_decoy, HIGH_ENTROPY_BASE64_CUTOFF,
};
pub(crate) use canonical::{
has_n_or_more_consecutive_identical, has_repeated_block_mask,
has_three_or_more_consecutive_identical, is_dash_segmented_alnum_decoy,
is_structured_dotted_token, is_uuid_v4_shape, looks_like_aws_iam_arn,
looks_like_bare_hex_digest, looks_like_base64_integrity_body,
looks_like_bracketed_template_placeholder, looks_like_dashed_serial_key,
looks_like_prefixed_hash_digest, looks_like_prefixed_masked_sequence,
looks_like_random_byte_base64_blob, looks_like_standard_base64_blob,
looks_like_trimmed_aws_iam_arn, looks_like_truncated_uuid_v4_suffix, HASH_ALGO_COLON_LABELS,
HASH_ALGO_INTEGRITY_LABELS, RFC7519_EXAMPLE_JWT_PREFIX,
};
pub(crate) use detector::is_canonical_service_hex_key;
pub(crate) use path::{
looks_like_filename_reference, looks_like_scheme_prefixed_uri, looks_like_url_or_path_segment,
};
pub(crate) use prose::looks_like_english_prose;
pub(crate) use public::{
looks_like_html_event_handler_fragment, looks_like_percent_encoded_markup,
looks_like_public_evidence_identifier, looks_like_public_reference_selector,
looks_like_public_version_identifier_with_randomness,
};
#[cfg(any(feature = "entropy", test))]
pub(crate) use source::looks_like_kebab_config_identifier;
#[cfg(feature = "entropy")]
pub(crate) use source::looks_like_source_type_identifier_with_randomness;
pub(crate) use source::{
looks_like_dotted_source_identifier, looks_like_program_identifier,
looks_like_source_code_expression_with_randomness,
looks_like_source_symbol_identifier_with_randomness,
};
pub(crate) fn looks_like_pure_identifier(credential: &str) -> bool {
let bytes = credential.as_bytes();
if bytes.is_empty() {
return false;
}
let mut underscore_count = 0usize;
let mut hyphen_count = 0usize;
let mut has_digit = false;
let mut has_upper = false;
let mut has_lower = false;
let mut alpha_count = 0usize;
for &b in bytes {
if b == b'_' {
underscore_count += 1;
} else if b == b'-' {
hyphen_count += 1;
} else if b.is_ascii_digit() {
has_digit = true;
} else if b.is_ascii_uppercase() {
has_upper = true;
alpha_count += 1;
} else if b.is_ascii_lowercase() {
has_lower = true;
alpha_count += 1;
} else {
return false;
}
}
if has_digit {
return false;
}
if underscore_count >= 2 {
return true;
}
if (underscore_count + hyphen_count) <= 1
&& (8..=40).contains(&alpha_count)
&& (has_upper || has_lower)
{
return true;
}
false
}
pub(crate) fn looks_like_camel_case_no_digit(credential: &str) -> bool {
if credential.bytes().any(|b| b.is_ascii_digit()) {
return false;
}
credential
.as_bytes()
.windows(2)
.filter(|w| w[0].is_ascii_lowercase() && w[1].is_ascii_uppercase())
.take(2)
.count()
>= 2
}
pub(crate) fn looks_like_word_separated_identifier(value: &str) -> bool {
if value.len() < 8 || value.len() > 50 {
return false;
}
if !value
.bytes()
.all(|b| b.is_ascii_alphanumeric() || b == b'_' || b == b'-')
{
return false;
}
let sep_count = value.bytes().filter(|&b| b == b'_' || b == b'-').count();
if sep_count == 0 {
return false;
}
for w in value.split(['_', '-']) {
if w.is_empty() || w.len() > 10 || !w.bytes().any(|b| b.is_ascii_alphabetic()) {
return false;
}
}
true
}
#[derive(serde::Deserialize)]
struct ProseConnectors {
connectors: Vec<String>,
}
fn parse_prose_connectors(raw: &str) -> Result<Vec<String>, String> {
toml::from_str::<ProseConnectors>(raw)
.map(|parsed| parsed.connectors)
.map_err(|error| error.to_string())
}
static PROSE_CONNECTORS: std::sync::LazyLock<Vec<String>> = std::sync::LazyLock::new(|| {
match parse_prose_connectors(include_str!(concat!(
env!("CARGO_MANIFEST_DIR"),
"/rules/prose-connectors.toml"
))) {
Ok(connectors) => connectors,
Err(error) => panic!(
"rules/prose-connectors.toml is invalid: {error}. \
Fix the bundled Tier-B prose connectors list."
),
}
});
#[derive(serde::Deserialize)]
struct RegexSigilSuffixes {
suffixes: Vec<String>,
}
fn parse_regex_sigil_suffixes(raw: &str) -> Result<Vec<String>, String> {
toml::from_str::<RegexSigilSuffixes>(raw)
.map(|parsed| parsed.suffixes)
.map_err(|error| error.to_string())
}
static REGEX_SIGIL_SUFFIXES: std::sync::LazyLock<Vec<String>> = std::sync::LazyLock::new(|| {
match parse_regex_sigil_suffixes(include_str!(concat!(
env!("CARGO_MANIFEST_DIR"),
"/rules/regex-sigil-suffixes.toml"
))) {
Ok(suffixes) => suffixes,
Err(error) => panic!(
"rules/regex-sigil-suffixes.toml is invalid: {error}. \
Fix the bundled regex sigil suffixes list."
),
}
});
pub(crate) fn looks_like_train_case_prose_identifier(value: &str) -> bool {
let bytes = value.as_bytes();
if bytes.len() < 24 || bytes.len() > 160 || !bytes.contains(&b'-') {
return false;
}
if !bytes.iter().all(|&b| b.is_ascii_alphabetic() || b == b'-') {
return false;
}
let mut part_count = 0usize;
let mut lower_parts = 0usize;
let mut has_connector = false;
for part in value.split('-') {
if part.is_empty() || part.len() > 18 {
return false;
}
part_count += 1;
if part.bytes().any(|b| b.is_ascii_lowercase()) {
lower_parts += 1;
}
if PROSE_CONNECTORS
.iter()
.any(|connector| part.eq_ignore_ascii_case(connector.as_str()))
{
has_connector = true;
}
}
if part_count < 4 || lower_parts < 3 || !has_connector {
return false;
}
true
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(crate) enum PublicShapeScope {
Full,
WeakAnchor,
}
#[cfg(test)]
pub(crate) fn public_noncredential_shape(
value: &str,
scope: PublicShapeScope,
) -> Option<&'static str> {
let randomness = crate::suppression::token_randomness::TokenRandomness::for_candidate(value);
public_noncredential_shape_with_randomness(value, scope, &randomness)
}
pub(crate) fn public_noncredential_shape_with_randomness(
value: &str,
scope: PublicShapeScope,
randomness: &crate::suppression::token_randomness::TokenRandomness<'_>,
) -> Option<&'static str> {
if looks_like_train_case_prose_identifier(value) {
return Some("train_case_prose_identifier");
}
if public::looks_like_public_version_identifier_with_randomness(value, randomness) {
return Some("public_version_identifier");
}
if scope == PublicShapeScope::WeakAnchor {
if public::looks_like_shell_template_value_with_randomness(value, randomness) {
return Some("shell_template_value");
}
if looks_like_percent_encoded_markup(value) {
return Some("percent_encoded_markup");
}
if looks_like_html_event_handler_fragment(value) {
return Some("html_event_handler_fragment");
}
return None;
}
if looks_like_public_reference_selector(value) {
return Some("public_reference_selector");
}
if public::looks_like_public_metadata_identifier_with_randomness(value, randomness) {
return Some("public_metadata_identifier");
}
if looks_like_public_evidence_identifier(value) {
return Some("public_evidence_identifier");
}
if public::looks_like_public_artifact_reference_with_randomness(value, randomness) {
return Some("public_artifact_reference");
}
if public::looks_like_shell_template_value_with_randomness(value, randomness) {
return Some("shell_template_value");
}
if looks_like_percent_encoded_markup(value) {
return Some("percent_encoded_markup");
}
if looks_like_html_event_handler_fragment(value) {
return Some("html_event_handler_fragment");
}
None
}
pub(crate) fn looks_like_high_entropy_punctuation_payload(value: &str, entropy: f64) -> bool {
if entropy < HIGH_ENTROPY_BASE64_CUTOFF || value.len() < 40 {
return false;
}
if value.contains('+') || value.contains('/') {
return true;
}
looks_like_bang_led_opaque_secret(value)
}
fn looks_like_bang_led_opaque_secret(value: &str) -> bool {
let bytes = value.as_bytes();
if !bytes.starts_with(b"!") || bytes.starts_with(b"!!") {
return false;
}
let mut has_alpha = false;
let mut has_digit = false;
let mut alnum = 0usize;
let mut punctuation = 0usize;
for &byte in bytes {
if !byte.is_ascii_graphic() {
return false;
}
if byte.is_ascii_alphabetic() {
has_alpha = true;
alnum += 1;
} else if byte.is_ascii_digit() {
has_digit = true;
alnum += 1;
} else {
punctuation += 1;
}
}
has_alpha && has_digit && punctuation >= 4 && alnum * 2 >= bytes.len()
}
pub(crate) fn looks_like_regex_literal_tail(value: &str) -> bool {
REGEX_SIGIL_SUFFIXES
.iter()
.any(|sig| value.ends_with(sig.as_str()))
}
pub(crate) fn looks_like_email_address(value: &str) -> bool {
let bytes = value.as_bytes();
if bytes.len() < 5 || bytes.len() > 64 {
return false;
}
let at = match bytes.iter().position(|&b| b == b'@') {
Some(idx) => idx,
None => return false,
};
if bytes.iter().skip(at + 1).any(|&b| b == b'@') {
return false;
}
let local = &bytes[..at];
let domain = &bytes[at + 1..];
if local.is_empty() || domain.is_empty() {
return false;
}
if !local
.iter()
.all(|&b| b.is_ascii_alphanumeric() || b == b'_' || b == b'-' || b == b'.' || b == b'+')
{
return false;
}
if !domain.contains(&b'.') {
return false;
}
domain
.iter()
.all(|&b| b.is_ascii_alphanumeric() || b == b'-' || b == b'.')
}
pub(crate) fn contains_uuid_v4_substring(value: &str) -> bool {
let bytes = value.as_bytes();
if bytes.len() < 36 {
return false;
}
for dash in memchr::memchr_iter(b'-', bytes) {
if dash < 8 || dash + 28 > bytes.len() {
continue;
}
let slice = &bytes[dash - 8..dash + 28];
if slice[13] == b'-'
&& slice[18] == b'-'
&& slice[23] == b'-'
&& slice.iter().enumerate().all(|(j, &c)| match j {
8 | 13 | 18 | 23 => c == b'-',
_ => c.is_ascii_hexdigit(),
})
{
return true;
}
}
false
}
pub(crate) fn looks_like_syntactic_punctuation_marker(value: &str) -> bool {
if value.is_empty() {
return false;
}
let bytes = value.as_bytes();
let starts_with_double_dash = bytes.starts_with(b"--") && bytes.len() >= 3 && bytes[2] != b'-';
if starts_with_double_dash {
return true;
}
if matches!(bytes[0], b'&' | b'@' | b'$' | b'*') {
let rest = &bytes[1..];
let pure_ident_tail =
!rest.is_empty() && rest.iter().all(|&b| b.is_ascii_alphanumeric() || b == b'_');
if pure_ident_tail {
return true;
}
}
let last = bytes[bytes.len() - 1];
if last == b':' {
let prefix = &bytes[..bytes.len() - 1];
if !prefix.is_empty()
&& prefix.iter().any(|b| b.is_ascii_alphabetic())
&& prefix.iter().all(|&b| b.is_ascii_alphabetic() || b == b'_')
{
return true;
}
}
false
}
pub(crate) fn looks_like_credential_colliding_punctuation(value: &str) -> bool {
if value.is_empty() {
return false;
}
let bytes = value.as_bytes();
bytes[0] == b'!' || bytes[0] == b'/' || looks_like_ts_non_null_identifier(bytes)
}
pub(super) const CREDENTIAL_KEYWORD_NEEDLES: &[&[u8]] = &[
b"token",
b"secret",
b"key",
b"password",
b"passwd",
b"auth",
b"credential",
];
fn looks_like_ts_non_null_identifier(bytes: &[u8]) -> bool {
if !bytes.ends_with(b"!") || bytes.len() < 9 {
return false;
}
let body = &bytes[..bytes.len() - 1];
if !body.iter().all(|&b| b.is_ascii_alphanumeric() || b == b'_') {
return false;
}
if body.iter().any(|b| b.is_ascii_digit()) {
return false;
}
let has_camel_transition = body
.windows(2)
.any(|w| w[0].is_ascii_lowercase() && w[1].is_ascii_uppercase());
if !has_camel_transition {
return false;
}
CREDENTIAL_KEYWORD_NEEDLES
.iter()
.any(|needle| crate::ascii_ci::ci_find(body, needle))
}
pub(crate) fn looks_like_punctuation_decorated_identifier(value: &str) -> bool {
looks_like_syntactic_punctuation_marker(value)
|| looks_like_credential_colliding_punctuation(value)
}
#[cfg(test)]
#[path = "../../../tests/unit/suppression_shape_mod.rs"]
mod tests;