Skip to content

Commit

Permalink
feat(linter): support user-configurable secrets for `oxc-security/api…
Browse files Browse the repository at this point in the history
…-keys` (#5938)
  • Loading branch information
DonIsaac committed Oct 28, 2024
1 parent 4b450cc commit 1691cab
Show file tree
Hide file tree
Showing 5 changed files with 135 additions and 29 deletions.
65 changes: 60 additions & 5 deletions crates/oxc_linter/src/rules/security/api_keys/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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())
Expand Down Expand Up @@ -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())
Expand Down Expand Up @@ -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)))
}
}
19 changes: 9 additions & 10 deletions crates/oxc_linter/src/rules/security/api_keys/secret.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand All @@ -60,7 +63,7 @@ pub trait SecretScannerMeta {
/// Defaults to 0.5
#[inline]
fn min_entropy(&self) -> f32 {
0.5
DEFAULT_MIN_ENTROPY
}
}

Expand Down Expand Up @@ -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 {
Expand Down
31 changes: 21 additions & 10 deletions crates/oxc_linter/src/rules/security/api_keys/secrets.rs
Original file line number Diff line number Diff line change
@@ -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),
}
}
}
Expand Down
41 changes: 41 additions & 0 deletions crates/oxc_linter/src/rules/security/api_keys/secrets/custom.rs
Original file line number Diff line number Diff line change
@@ -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)
}
}
8 changes: 4 additions & 4 deletions crates/oxc_macros/src/declare_oxc_secret.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down

0 comments on commit 1691cab

Please sign in to comment.