-
-
Notifications
You must be signed in to change notification settings - Fork 509
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
feat(linter): add
oxc-security/api-keys
- Loading branch information
Showing
6 changed files
with
419 additions
and
0 deletions.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,60 @@ | ||
/// Calculates the Shannon entropy of a byte string. | ||
/// | ||
/// Implementation borrowed from [Rosetta Code](https://rosettacode.org/wiki/Entropy#Rust). | ||
/// | ||
/// see: [Entropy (Wikipedial)](https://en.wikipedia.org/wiki/Entropy_(information_theory)) | ||
#[allow(clippy::cast_precision_loss)] | ||
pub(crate) fn entropy<S: AsRef<[u8]>>(string: S) -> f32 { | ||
let mut histogram = [0u32; 256]; | ||
let bytes = string.as_ref(); | ||
// we don't care if this is truncated | ||
let len = bytes.len() as f32; | ||
|
||
for &b in bytes { | ||
histogram[b as usize] += 1; | ||
} | ||
|
||
histogram | ||
.iter() | ||
.copied() | ||
.filter(|&h| h != 0) | ||
.map(|h| h as f32 / len) // we don't care if this is truncated | ||
.map(|ratio| -ratio * ratio.log2()) | ||
.sum() | ||
} | ||
|
||
pub(crate) trait Entropy { | ||
fn entropy(&self) -> f32; | ||
} | ||
|
||
impl<S> Entropy for S | ||
where | ||
S: AsRef<[u8]>, | ||
{ | ||
fn entropy(&self) -> f32 { | ||
entropy(self) | ||
} | ||
} | ||
|
||
#[cfg(test)] | ||
mod test { | ||
use super::*; | ||
|
||
#[test] | ||
fn test_entropy() { | ||
let test_cases = vec![ | ||
("hello world", "hello world".entropy()), | ||
("hello world", b"hello world".entropy()), | ||
("hello world", String::from("hello world").entropy()), | ||
("hello world", 2.845_351_2), | ||
]; | ||
|
||
for (input, expected) in test_cases { | ||
let actual = entropy(input); | ||
assert!( | ||
(actual - expected).abs() < f32::EPSILON, | ||
"expected entropy({input}) to be {expected}, got {actual}" | ||
); | ||
} | ||
} | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,143 @@ | ||
mod entropy; | ||
#[allow(unused_imports, unused_variables)] | ||
mod secret; | ||
mod secrets; | ||
|
||
use std::{num::NonZeroU32, ops::Deref}; | ||
|
||
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 crate::{context::LintContext, rule::Rule, AstNode}; | ||
|
||
fn api_keys(violation: &SecretViolation) -> OxcDiagnostic { | ||
OxcDiagnostic::warn(violation.message().to_owned()) | ||
.with_error_code_num(format!("api-keys/{}", violation.rule_name())) | ||
.with_label(violation.span()) | ||
.with_help( | ||
"Use a secrets manager to store your API keys securely, then read them at runtime.", | ||
) | ||
} | ||
|
||
declare_oxc_lint!( | ||
/// ### What it does | ||
/// | ||
/// | ||
/// ### Why is this bad? | ||
/// | ||
/// | ||
/// ### Examples | ||
/// | ||
/// Examples of **incorrect** code for this rule: | ||
/// ```js | ||
/// FIXME: Tests will fail if examples are missing or syntactically incorrect. | ||
/// ``` | ||
/// | ||
/// Examples of **correct** code for this rule: | ||
/// ```js | ||
/// FIXME: Tests will fail if examples are missing or syntactically incorrect. | ||
/// ``` | ||
ApiKeys, | ||
nursery, // TODO: change category to `correctness`, `suspicious`, `pedantic`, `perf`, `restriction`, or `style` | ||
// See <https://oxc.rs/docs/contribute/linter.html#rule-category> for details | ||
|
||
pending // TODO: describe fix capabilities. Remove if no fix can be done, | ||
// keep at 'pending' if you think one could be added but don't know how. | ||
// Options are 'fix', 'fix_dangerous', 'suggestion', and 'conditional_fix_suggestion' | ||
); | ||
|
||
#[derive(Debug, Default, Clone)] | ||
pub struct ApiKeys(Box<ApiKeysInner>); | ||
|
||
#[derive(Debug, Clone)] | ||
pub struct ApiKeysInner { | ||
min_len: NonZeroU32, | ||
min_entropy: f32, | ||
rules: Vec<SecretsEnum>, | ||
} | ||
|
||
impl Default for ApiKeysInner { | ||
fn default() -> Self { | ||
Self::new(ALL_RULES.clone()) | ||
} | ||
} | ||
|
||
impl ApiKeysInner { | ||
pub fn new(rules: Vec<SecretsEnum>) -> Self { | ||
let min_len = rules.iter().map(secrets::SecretsEnum::min_len).min().unwrap(); | ||
// can't use min() b/c f32 is not Ord | ||
let min_entropy = rules.iter().map(secrets::SecretsEnum::min_entropy).fold(0.0, f32::min); | ||
|
||
Self { min_len, min_entropy, rules } | ||
} | ||
} | ||
|
||
impl Deref for ApiKeys { | ||
type Target = ApiKeysInner; | ||
fn deref(&self) -> &Self::Target { | ||
&self.0 | ||
} | ||
} | ||
|
||
impl ApiKeysInner {} | ||
|
||
impl Rule for ApiKeys { | ||
fn run<'a>(&self, node: &AstNode<'a>, ctx: &LintContext<'a>) { | ||
let string: &'a str = match node.kind() { | ||
AstKind::StringLiteral(string) => string.value.as_str(), | ||
AstKind::TemplateLiteral(string) => { | ||
let Some(string) = string.quasi() else { | ||
return; | ||
}; | ||
string.as_str() | ||
} | ||
_ => return, | ||
}; | ||
|
||
// skip strings that are below the length/entropy threshold of _all_ rules. Perf | ||
// optimization, avoid O(n) len/entropy checks (for n rules) | ||
if string.len() < self.min_len.get() as usize { | ||
return; | ||
} | ||
let candidate = Secret::new(string, node.span(), None); | ||
if candidate.entropy() < self.min_entropy { | ||
return; | ||
} | ||
|
||
for rule in &self.rules { | ||
// order here is important: they're in order of cheapest to most expensive | ||
if candidate.len() < rule.min_len().get() as usize | ||
|| candidate.entropy() < rule.min_entropy() | ||
|| rule.max_len().is_some_and(|max_len| candidate.len() > max_len.get() as usize) | ||
|| !rule.detect(&candidate) | ||
{ | ||
continue; | ||
} | ||
|
||
// This clone allocs no memory and so is relatively cheap. rustc should optimize it | ||
// away anyways. | ||
let mut violation = SecretViolation::new(candidate.clone(), rule); | ||
if rule.verify(&mut violation) { | ||
ctx.diagnostic(api_keys(&violation)); | ||
return; | ||
} | ||
} | ||
} | ||
} | ||
|
||
#[test] | ||
fn test() { | ||
use crate::tester::Tester; | ||
|
||
let pass: Vec<&str> = vec![]; | ||
|
||
let fail = vec![]; | ||
|
||
Tester::new(ApiKeys::NAME, pass, fail).test_and_snapshot(); | ||
} |
121 changes: 121 additions & 0 deletions
121
crates/oxc_linter/src/rules/security/api_keys/secret.rs
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,121 @@ | ||
use std::{borrow::Cow, num::NonZeroU32, ops::Deref}; | ||
|
||
use oxc_span::{Atom, GetSpan, Span}; | ||
|
||
use super::{Entropy, SecretsEnum}; | ||
|
||
/// A credential discovered in source code. | ||
/// | ||
/// Could be an API key, an auth token, or any other sensitive information. | ||
#[allow(clippy::struct_field_names)] | ||
#[derive(Debug, Clone)] | ||
pub struct Secret<'a> { | ||
secret: &'a str, | ||
/// Secret span | ||
span: Span, | ||
/// TODO: find and pass identifiers once we have rules that need it | ||
#[allow(dead_code)] | ||
identifier: Option<Atom<'a>>, | ||
entropy: f32, | ||
} | ||
|
||
#[derive(Debug, Clone)] | ||
pub struct SecretViolation<'a> { | ||
// NOTE: Rules get a &mut reference to a SecretViolation to verify the | ||
// violation. It is important that the underlying secret is not modified. | ||
secret: Secret<'a>, | ||
rule_name: Cow<'a, str>, // really should be &'static | ||
message: Cow<'a, str>, // really should be &'static | ||
} | ||
|
||
/// Detects hard-coded API keys and other credentials. | ||
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 message(&self) -> &'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) } | ||
} | ||
|
||
#[inline] | ||
fn max_len(&self) -> Option<NonZeroU32> { | ||
None | ||
} | ||
|
||
/// Min entropy a key must have to be considered a violation. Must be >= 0. | ||
/// | ||
/// Defaults to 0.5 | ||
#[inline] | ||
fn min_entropy(&self) -> f32 { | ||
0.5 | ||
} | ||
} | ||
|
||
pub trait SecretScanner: SecretScannerMeta { | ||
fn detect(&self, candidate: &Secret<'_>) -> bool; | ||
|
||
#[inline] | ||
fn verify(&self, violation: &mut SecretViolation<'_>) -> bool { | ||
true | ||
} | ||
} | ||
|
||
impl<'a> Secret<'a> { | ||
pub fn new(secret: &'a str, span: Span, identifier: Option<Atom<'a>>) -> Self { | ||
let entropy = secret.entropy(); | ||
Self { secret, span, identifier, entropy } | ||
} | ||
} | ||
impl Deref for Secret<'_> { | ||
type Target = str; | ||
|
||
#[inline] | ||
fn deref(&self) -> &Self::Target { | ||
self.secret | ||
} | ||
} | ||
|
||
impl Entropy for Secret<'_> { | ||
#[inline] | ||
fn entropy(&self) -> f32 { | ||
self.entropy | ||
} | ||
} | ||
|
||
impl GetSpan for Secret<'_> { | ||
#[inline] | ||
fn span(&self) -> Span { | ||
self.span | ||
} | ||
} | ||
|
||
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()), | ||
} | ||
} | ||
|
||
pub fn message(&self) -> &str { | ||
&self.message | ||
} | ||
|
||
pub fn rule_name(&self) -> &str { | ||
&self.rule_name | ||
} | ||
} | ||
|
||
impl GetSpan for SecretViolation<'_> { | ||
#[inline] | ||
fn span(&self) -> Span { | ||
self.secret.span() | ||
} | ||
} |
Oops, something went wrong.