diff --git a/crates/oxc_linter/src/rules/security/api_keys/mod.rs b/crates/oxc_linter/src/rules/security/api_keys/mod.rs index dd35e7ae697f0..3cc7178726d53 100644 --- a/crates/oxc_linter/src/rules/security/api_keys/mod.rs +++ b/crates/oxc_linter/src/rules/security/api_keys/mod.rs @@ -5,16 +5,21 @@ mod secrets; use std::{num::NonZeroU32, ops::Deref}; +use regex::Regex; +use serde::Deserialize; +use serde_json::Value; + use oxc_ast::AstKind; use oxc_diagnostics::OxcDiagnostic; use oxc_macros::declare_oxc_lint; -use oxc_span::GetSpan; - -use entropy::Entropy; -use secret::{Secret, SecretScanner, SecretScannerMeta, SecretViolation}; -use secrets::{SecretsEnum, ALL_RULES}; +use oxc_span::{CompactStr, GetSpan}; use crate::{context::LintContext, rule::Rule, AstNode}; +use entropy::Entropy; +use secret::{ + Secret, SecretScanner, SecretScannerMeta, SecretViolation, DEFAULT_MIN_ENTROPY, DEFAULT_MIN_LEN, +}; +use secrets::{CustomSecret, SecretsEnum, ALL_RULES}; fn api_keys(violation: &SecretViolation) -> OxcDiagnostic { OxcDiagnostic::warn(violation.message().to_owned()) @@ -105,6 +110,31 @@ pub struct ApiKeysInner { rules: Vec, } +#[derive(Debug, Default, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct ApiKeysConfig { + #[serde(default)] + custom_patterns: Vec, +} + +#[derive(Debug, Deserialize)] +struct CustomPattern { + // required fields + #[serde(rename = "ruleName")] + rule_name: CompactStr, + pattern: String, + + // optional fields + #[serde(default)] + message: Option, + #[serde(default)] + entropy: Option, + #[serde(default, rename = "minLength")] + min_len: Option, + #[serde(default, rename = "maxLength")] + max_len: Option, +} + impl Default for ApiKeysInner { fn default() -> Self { Self::new(ALL_RULES.clone()) @@ -174,4 +204,29 @@ impl Rule for ApiKeys { } } } + + fn from_configuration(value: Value) -> Self { + let Some(obj) = value.get(0) else { + return Self::default(); + }; + let config = serde_json::from_value::(obj.clone()) + .expect("Invalid configuration for 'oxc-security/api-keys'"); + + // TODO: Check if this is worth optimizing, then do so if needed. + let mut rules = ALL_RULES.clone(); + rules.extend(config.custom_patterns.into_iter().map(|pattern| { + let regex = Regex::new(&pattern.pattern) + .expect("Invalid custom API key regex in 'oxc-security/api-keys'"); + SecretsEnum::Custom(CustomSecret { + rule_name: pattern.rule_name, + message: pattern.message.unwrap_or("Detected a hard-coded secret.".into()), + entropy: pattern.entropy.unwrap_or(DEFAULT_MIN_ENTROPY), + min_len: pattern.min_len.unwrap_or(DEFAULT_MIN_LEN), + max_len: pattern.max_len, + pattern: regex, + }) + })); + + Self(Box::new(ApiKeysInner::new(rules))) + } } diff --git a/crates/oxc_linter/src/rules/security/api_keys/secret.rs b/crates/oxc_linter/src/rules/security/api_keys/secret.rs index 2ec973f0310f1..d2953c1260c59 100644 --- a/crates/oxc_linter/src/rules/security/api_keys/secret.rs +++ b/crates/oxc_linter/src/rules/security/api_keys/secret.rs @@ -31,20 +31,23 @@ pub struct SecretViolation<'a> { message: Cow<'a, str>, // really should be &'static } +// SAFETY: 8 is a valid value for NonZeroU32 +pub(super) const DEFAULT_MIN_LEN: NonZeroU32 = unsafe { NonZeroU32::new_unchecked(8) }; +pub(super) const DEFAULT_MIN_ENTROPY: f32 = 0.5; + /// Metadata trait separated out of [`SecretScanner`]. The easiest way to implement this is with /// the [`oxc_macros::declare_oxc_secret!`] macro. pub trait SecretScannerMeta { /// Human-readable unique identifier describing what service this rule finds api keys for. /// Must be kebab-case. - fn rule_name(&self) -> &'static str; + fn rule_name(&self) -> Cow<'static, str>; - fn message(&self) -> &'static str; + fn message(&self) -> Cow<'static, str>; /// Min str length a key candidate must have to be considered a violation. Must be >= 1. #[inline] fn min_len(&self) -> NonZeroU32 { - // SAFETY: 8 is a valid value for NonZeroU32 - unsafe { NonZeroU32::new_unchecked(8) } + DEFAULT_MIN_LEN } /// Secret candidates above this length will not be considered. @@ -60,7 +63,7 @@ pub trait SecretScannerMeta { /// Defaults to 0.5 #[inline] fn min_entropy(&self) -> f32 { - 0.5 + DEFAULT_MIN_ENTROPY } } @@ -113,11 +116,7 @@ impl GetSpan for Secret<'_> { impl<'a> SecretViolation<'a> { pub fn new(secret: Secret<'a>, rule: &SecretsEnum) -> Self { - Self { - secret, - rule_name: Cow::Borrowed(rule.rule_name()), - message: Cow::Borrowed(rule.message()), - } + Self { secret, rule_name: rule.rule_name(), message: rule.message() } } pub fn message(&self) -> &str { diff --git a/crates/oxc_linter/src/rules/security/api_keys/secrets.rs b/crates/oxc_linter/src/rules/security/api_keys/secrets.rs index 2d44ba2794842..4b9551851334d 100644 --- a/crates/oxc_linter/src/rules/security/api_keys/secrets.rs +++ b/crates/oxc_linter/src/rules/security/api_keys/secrets.rs @@ -1,54 +1,65 @@ mod aws_access_token; +mod custom; -use std::num::NonZeroU32; +use std::{borrow::Cow, num::NonZeroU32}; use super::{Secret, SecretScanner, SecretScannerMeta, SecretViolation}; +pub use custom::CustomSecret; + #[derive(Debug, Clone)] pub enum SecretsEnum { AwsAccessKeyId(aws_access_token::AwsAccessToken), + Custom(custom::CustomSecret), } impl SecretsEnum { - pub fn rule_name(&self) -> &'static str { + pub fn rule_name(&self) -> Cow<'static, str> { match self { - SecretsEnum::AwsAccessKeyId(rule) => rule.rule_name(), + Self::AwsAccessKeyId(rule) => rule.rule_name(), + Self::Custom(rule) => rule.rule_name(), } } - pub fn message(&self) -> &'static str { + pub fn message(&self) -> Cow<'static, str> { match self { - SecretsEnum::AwsAccessKeyId(rule) => rule.message(), + Self::AwsAccessKeyId(rule) => rule.message(), + Self::Custom(rule) => rule.message(), } } pub fn min_len(&self) -> NonZeroU32 { match self { - SecretsEnum::AwsAccessKeyId(rule) => rule.min_len(), + Self::AwsAccessKeyId(rule) => rule.min_len(), + Self::Custom(rule) => rule.min_len(), } } pub fn max_len(&self) -> Option { match self { - SecretsEnum::AwsAccessKeyId(rule) => rule.max_len(), + Self::AwsAccessKeyId(rule) => rule.max_len(), + Self::Custom(rule) => rule.max_len(), } } pub fn min_entropy(&self) -> f32 { match self { - SecretsEnum::AwsAccessKeyId(rule) => rule.min_entropy(), + Self::AwsAccessKeyId(rule) => rule.min_entropy(), + Self::Custom(rule) => rule.min_entropy(), } } pub fn verify(&self, violation: &mut SecretViolation<'_>) -> bool { match self { - SecretsEnum::AwsAccessKeyId(rule) => rule.verify(violation), + Self::AwsAccessKeyId(rule) => rule.verify(violation), + Self::Custom(rule) => rule.verify(violation), } } pub fn detect(&self, candidate: &Secret<'_>) -> bool { match self { - SecretsEnum::AwsAccessKeyId(rule) => rule.detect(candidate), + Self::AwsAccessKeyId(rule) => rule.detect(candidate), + Self::Custom(rule) => rule.detect(candidate), } } } diff --git a/crates/oxc_linter/src/rules/security/api_keys/secrets/custom.rs b/crates/oxc_linter/src/rules/security/api_keys/secrets/custom.rs new file mode 100644 index 0000000000000..939b571ecd953 --- /dev/null +++ b/crates/oxc_linter/src/rules/security/api_keys/secrets/custom.rs @@ -0,0 +1,41 @@ +use std::{borrow::Cow, num::NonZeroU32}; + +use regex::Regex; + +use oxc_span::CompactStr; + +use super::{Secret, SecretScanner, SecretScannerMeta}; + +#[derive(Debug, Clone)] +pub struct CustomSecret { + pub(crate) rule_name: CompactStr, + pub(crate) message: CompactStr, + pub(crate) entropy: f32, + pub(crate) min_len: NonZeroU32, + pub(crate) max_len: Option, + pub(crate) pattern: Regex, +} + +impl SecretScannerMeta for CustomSecret { + fn rule_name(&self) -> Cow<'static, str> { + self.rule_name.clone().into() + } + fn message(&self) -> Cow<'static, str> { + self.message.clone().into() + } + fn min_len(&self) -> NonZeroU32 { + self.min_len + } + fn max_len(&self) -> Option { + self.max_len + } + fn min_entropy(&self) -> f32 { + self.entropy + } +} + +impl SecretScanner for CustomSecret { + fn detect(&self, candidate: &Secret<'_>) -> bool { + self.pattern.is_match(candidate) + } +} diff --git a/crates/oxc_macros/src/declare_oxc_secret.rs b/crates/oxc_macros/src/declare_oxc_secret.rs index c852adaec4444..7b911a24512f3 100644 --- a/crates/oxc_macros/src/declare_oxc_secret.rs +++ b/crates/oxc_macros/src/declare_oxc_secret.rs @@ -134,13 +134,13 @@ pub fn declare_oxc_secret(meta: SecretRuleMeta) -> TokenStream { let output = quote! { impl super::SecretScannerMeta for #struct_name { #[inline] - fn rule_name(&self) -> &'static str { - #rule_name + fn rule_name(&self) -> std::borrow::Cow<'static, str> { + std::borrow::Cow::Borrowed(#rule_name) } #[inline] - fn message(&self) -> &'static str { - #message + fn message(&self) -> std::borrow::Cow<'static, str> { + std::borrow::Cow::Borrowed(#message) } #min_len_fn