mirror of
https://github.com/danbulant/oxc
synced 2026-05-19 20:28:58 +00:00
feat(linter): support user-configurable secrets for oxc-security/api-keys (#5938)
This commit is contained in:
parent
4b450cc985
commit
1691cab507
5 changed files with 135 additions and 29 deletions
|
|
@ -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<SecretsEnum>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Default, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct ApiKeysConfig {
|
||||
#[serde(default)]
|
||||
custom_patterns: Vec<CustomPattern>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
struct CustomPattern {
|
||||
// required fields
|
||||
#[serde(rename = "ruleName")]
|
||||
rule_name: CompactStr,
|
||||
pattern: String,
|
||||
|
||||
// optional fields
|
||||
#[serde(default)]
|
||||
message: Option<CompactStr>,
|
||||
#[serde(default)]
|
||||
entropy: Option<f32>,
|
||||
#[serde(default, rename = "minLength")]
|
||||
min_len: Option<NonZeroU32>,
|
||||
#[serde(default, rename = "maxLength")]
|
||||
max_len: Option<NonZeroU32>,
|
||||
}
|
||||
|
||||
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::<ApiKeysConfig>(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)))
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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<NonZeroU32> {
|
||||
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),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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<NonZeroU32>,
|
||||
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<NonZeroU32> {
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Reference in a new issue