Skip to content

Commit

Permalink
feat(linter): add oxc-security/api-keys
Browse files Browse the repository at this point in the history
  • Loading branch information
DonIsaac committed Sep 20, 2024
1 parent 7c080b2 commit 997064a
Show file tree
Hide file tree
Showing 6 changed files with 419 additions and 0 deletions.
5 changes: 5 additions & 0 deletions crates/oxc_linter/src/rules.rs
Original file line number Diff line number Diff line change
Expand Up @@ -410,6 +410,10 @@ mod oxc {
pub mod uninvoked_array_callback;
}

mod security {
pub mod api_keys;
}

mod nextjs {
pub mod google_font_display;
pub mod google_font_preconnect;
Expand Down Expand Up @@ -787,6 +791,7 @@ oxc_macros::declare_all_lint_rules! {
react_perf::jsx_no_new_array_as_prop,
react_perf::jsx_no_new_function_as_prop,
react_perf::jsx_no_new_object_as_prop,
security::api_keys,
tree_shaking::no_side_effects_in_initialization,
typescript::adjacent_overload_signatures,
typescript::array_type,
Expand Down
60 changes: 60 additions & 0 deletions crates/oxc_linter/src/rules/security/api_keys/entropy.rs
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}"
);
}
}
}
143 changes: 143 additions & 0 deletions crates/oxc_linter/src/rules/security/api_keys/mod.rs
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 crates/oxc_linter/src/rules/security/api_keys/secret.rs
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()
}
}
Loading

0 comments on commit 997064a

Please sign in to comment.