mirror of
https://github.com/danbulant/oxc
synced 2026-05-22 05:38:54 +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 std::{num::NonZeroU32, ops::Deref};
|
||||||
|
|
||||||
|
use regex::Regex;
|
||||||
|
use serde::Deserialize;
|
||||||
|
use serde_json::Value;
|
||||||
|
|
||||||
use oxc_ast::AstKind;
|
use oxc_ast::AstKind;
|
||||||
use oxc_diagnostics::OxcDiagnostic;
|
use oxc_diagnostics::OxcDiagnostic;
|
||||||
use oxc_macros::declare_oxc_lint;
|
use oxc_macros::declare_oxc_lint;
|
||||||
use oxc_span::GetSpan;
|
use oxc_span::{CompactStr, GetSpan};
|
||||||
|
|
||||||
use entropy::Entropy;
|
|
||||||
use secret::{Secret, SecretScanner, SecretScannerMeta, SecretViolation};
|
|
||||||
use secrets::{SecretsEnum, ALL_RULES};
|
|
||||||
|
|
||||||
use crate::{context::LintContext, rule::Rule, AstNode};
|
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 {
|
fn api_keys(violation: &SecretViolation) -> OxcDiagnostic {
|
||||||
OxcDiagnostic::warn(violation.message().to_owned())
|
OxcDiagnostic::warn(violation.message().to_owned())
|
||||||
|
|
@ -105,6 +110,31 @@ pub struct ApiKeysInner {
|
||||||
rules: Vec<SecretsEnum>,
|
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 {
|
impl Default for ApiKeysInner {
|
||||||
fn default() -> Self {
|
fn default() -> Self {
|
||||||
Self::new(ALL_RULES.clone())
|
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
|
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
|
/// Metadata trait separated out of [`SecretScanner`]. The easiest way to implement this is with
|
||||||
/// the [`oxc_macros::declare_oxc_secret!`] macro.
|
/// the [`oxc_macros::declare_oxc_secret!`] macro.
|
||||||
pub trait SecretScannerMeta {
|
pub trait SecretScannerMeta {
|
||||||
/// Human-readable unique identifier describing what service this rule finds api keys for.
|
/// Human-readable unique identifier describing what service this rule finds api keys for.
|
||||||
/// Must be kebab-case.
|
/// 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.
|
/// Min str length a key candidate must have to be considered a violation. Must be >= 1.
|
||||||
#[inline]
|
#[inline]
|
||||||
fn min_len(&self) -> NonZeroU32 {
|
fn min_len(&self) -> NonZeroU32 {
|
||||||
// SAFETY: 8 is a valid value for NonZeroU32
|
DEFAULT_MIN_LEN
|
||||||
unsafe { NonZeroU32::new_unchecked(8) }
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Secret candidates above this length will not be considered.
|
/// Secret candidates above this length will not be considered.
|
||||||
|
|
@ -60,7 +63,7 @@ pub trait SecretScannerMeta {
|
||||||
/// Defaults to 0.5
|
/// Defaults to 0.5
|
||||||
#[inline]
|
#[inline]
|
||||||
fn min_entropy(&self) -> f32 {
|
fn min_entropy(&self) -> f32 {
|
||||||
0.5
|
DEFAULT_MIN_ENTROPY
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -113,11 +116,7 @@ impl GetSpan for Secret<'_> {
|
||||||
|
|
||||||
impl<'a> SecretViolation<'a> {
|
impl<'a> SecretViolation<'a> {
|
||||||
pub fn new(secret: Secret<'a>, rule: &SecretsEnum) -> Self {
|
pub fn new(secret: Secret<'a>, rule: &SecretsEnum) -> Self {
|
||||||
Self {
|
Self { secret, rule_name: rule.rule_name(), message: rule.message() }
|
||||||
secret,
|
|
||||||
rule_name: Cow::Borrowed(rule.rule_name()),
|
|
||||||
message: Cow::Borrowed(rule.message()),
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn message(&self) -> &str {
|
pub fn message(&self) -> &str {
|
||||||
|
|
|
||||||
|
|
@ -1,54 +1,65 @@
|
||||||
mod aws_access_token;
|
mod aws_access_token;
|
||||||
|
mod custom;
|
||||||
|
|
||||||
use std::num::NonZeroU32;
|
use std::{borrow::Cow, num::NonZeroU32};
|
||||||
|
|
||||||
use super::{Secret, SecretScanner, SecretScannerMeta, SecretViolation};
|
use super::{Secret, SecretScanner, SecretScannerMeta, SecretViolation};
|
||||||
|
|
||||||
|
pub use custom::CustomSecret;
|
||||||
|
|
||||||
#[derive(Debug, Clone)]
|
#[derive(Debug, Clone)]
|
||||||
pub enum SecretsEnum {
|
pub enum SecretsEnum {
|
||||||
AwsAccessKeyId(aws_access_token::AwsAccessToken),
|
AwsAccessKeyId(aws_access_token::AwsAccessToken),
|
||||||
|
Custom(custom::CustomSecret),
|
||||||
}
|
}
|
||||||
|
|
||||||
impl SecretsEnum {
|
impl SecretsEnum {
|
||||||
pub fn rule_name(&self) -> &'static str {
|
pub fn rule_name(&self) -> Cow<'static, str> {
|
||||||
match self {
|
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 {
|
match self {
|
||||||
SecretsEnum::AwsAccessKeyId(rule) => rule.message(),
|
Self::AwsAccessKeyId(rule) => rule.message(),
|
||||||
|
Self::Custom(rule) => rule.message(),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn min_len(&self) -> NonZeroU32 {
|
pub fn min_len(&self) -> NonZeroU32 {
|
||||||
match self {
|
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> {
|
pub fn max_len(&self) -> Option<NonZeroU32> {
|
||||||
match self {
|
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 {
|
pub fn min_entropy(&self) -> f32 {
|
||||||
match self {
|
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 {
|
pub fn verify(&self, violation: &mut SecretViolation<'_>) -> bool {
|
||||||
match self {
|
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 {
|
pub fn detect(&self, candidate: &Secret<'_>) -> bool {
|
||||||
match self {
|
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! {
|
let output = quote! {
|
||||||
impl super::SecretScannerMeta for #struct_name {
|
impl super::SecretScannerMeta for #struct_name {
|
||||||
#[inline]
|
#[inline]
|
||||||
fn rule_name(&self) -> &'static str {
|
fn rule_name(&self) -> std::borrow::Cow<'static, str> {
|
||||||
#rule_name
|
std::borrow::Cow::Borrowed(#rule_name)
|
||||||
}
|
}
|
||||||
|
|
||||||
#[inline]
|
#[inline]
|
||||||
fn message(&self) -> &'static str {
|
fn message(&self) -> std::borrow::Cow<'static, str> {
|
||||||
#message
|
std::borrow::Cow::Borrowed(#message)
|
||||||
}
|
}
|
||||||
|
|
||||||
#min_len_fn
|
#min_len_fn
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue