diff --git a/.typos.toml b/.typos.toml index 419f7a433..c83946cc6 100644 --- a/.typos.toml +++ b/.typos.toml @@ -34,6 +34,7 @@ trivia = "trivia" xdescribe = "xdescribe" seeked = "seeked" labeledby = "labeledby" +hashi = "hashi" [default.extend-identifiers] IIFEs = "IIFEs" diff --git a/apps/oxlint/src/command/lint.rs b/apps/oxlint/src/command/lint.rs index 9e39d1f1a..b4e7bfcf4 100644 --- a/apps/oxlint/src/command/lint.rs +++ b/apps/oxlint/src/command/lint.rs @@ -265,6 +265,10 @@ pub struct EnablePlugins { /// Enable the node plugin and detect node usage problems #[bpaf(switch, hide_usage)] pub node_plugin: bool, + + /// Enable the security plugin and detect security problems + #[bpaf(switch, hide_usage)] + pub security_plugin: bool, } #[cfg(test)] diff --git a/apps/oxlint/src/lint/mod.rs b/apps/oxlint/src/lint/mod.rs index 403a39b89..778f5b080 100644 --- a/apps/oxlint/src/lint/mod.rs +++ b/apps/oxlint/src/lint/mod.rs @@ -116,7 +116,8 @@ impl Runner for LintRunner { .with_nextjs_plugin(enable_plugins.nextjs_plugin) .with_react_perf_plugin(enable_plugins.react_perf_plugin) .with_promise_plugin(enable_plugins.promise_plugin) - .with_node_plugin(enable_plugins.node_plugin); + .with_node_plugin(enable_plugins.node_plugin) + .with_security_plugin(enable_plugins.security_plugin); let linter = match Linter::from_options(lint_options) { Ok(lint_service) => lint_service, diff --git a/crates/oxc_linter/src/context/mod.rs b/crates/oxc_linter/src/context/mod.rs index 94be18593..f46386012 100644 --- a/crates/oxc_linter/src/context/mod.rs +++ b/crates/oxc_linter/src/context/mod.rs @@ -344,4 +344,5 @@ const PLUGIN_PREFIXES: phf::Map<&'static str, &'static str> = phf::phf_map! { "unicorn" => "eslint-plugin-unicorn", "vitest" => "eslint-plugin-vitest", "node" => "eslint-plugin-node", + "security" => "oxc-security", }; diff --git a/crates/oxc_linter/src/options/mod.rs b/crates/oxc_linter/src/options/mod.rs index 2aca78e44..ceb566d5c 100644 --- a/crates/oxc_linter/src/options/mod.rs +++ b/crates/oxc_linter/src/options/mod.rs @@ -178,6 +178,12 @@ impl OxlintOptions { self.plugins.node = yes; self } + + #[must_use] + pub fn with_security_plugin(mut self, yes: bool) -> Self { + self.plugins.security = yes; + self + } } impl OxlintOptions { @@ -286,6 +292,7 @@ impl OxlintOptions { "eslint" | "tree_shaking" => true, "promise" => self.plugins.promise, "node" => self.plugins.node, + "security" => self.plugins.security, name => panic!("Unhandled plugin: {name}"), }) .cloned() diff --git a/crates/oxc_linter/src/options/plugins.rs b/crates/oxc_linter/src/options/plugins.rs index dff487aef..d991a9f50 100644 --- a/crates/oxc_linter/src/options/plugins.rs +++ b/crates/oxc_linter/src/options/plugins.rs @@ -34,6 +34,8 @@ bitflags! { const PROMISE = 1 << 11; /// `eslint-plugin-node` const NODE = 1 << 12; + /// Custom security rules made by the Oxc team + const SECURITY = 1 << 13; } } impl Default for LintPlugins { @@ -59,6 +61,7 @@ impl From for LintPlugins { plugins.set(LintPlugins::REACT_PERF, options.react_perf); plugins.set(LintPlugins::PROMISE, options.promise); plugins.set(LintPlugins::NODE, options.node); + plugins.set(LintPlugins::SECURITY, options.security); plugins } } @@ -108,6 +111,7 @@ impl From<&str> for LintPlugins { "react-perf" | "react_perf" => LintPlugins::REACT_PERF, "promise" => LintPlugins::PROMISE, "node" => LintPlugins::NODE, + "security" | "oxc-security" => LintPlugins::SECURITY, // "eslint" is not really a plugin, so it's 'empty'. This has the added benefit of // making it the default value. _ => LintPlugins::empty(), @@ -131,6 +135,7 @@ impl From for &'static str { LintPlugins::REACT_PERF => "react-perf", LintPlugins::PROMISE => "promise", LintPlugins::NODE => "node", + LintPlugins::SECURITY => "security", _ => "", } } @@ -189,6 +194,7 @@ pub struct LintPluginOptions { pub react_perf: bool, pub promise: bool, pub node: bool, + pub security: bool, } impl Default for LintPluginOptions { @@ -207,6 +213,7 @@ impl Default for LintPluginOptions { react_perf: false, promise: false, node: false, + security: false, } } } @@ -229,6 +236,7 @@ impl LintPluginOptions { react_perf: false, promise: false, node: false, + security: false, } } @@ -249,6 +257,7 @@ impl LintPluginOptions { react_perf: true, promise: true, node: true, + security: true, } } } @@ -272,6 +281,7 @@ impl> FromIterator<(S, bool)> for LintPluginOptions { LintPlugins::REACT_PERF => options.react_perf = enabled, LintPlugins::PROMISE => options.promise = enabled, LintPlugins::NODE => options.node = enabled, + LintPlugins::SECURITY => options.security = enabled, _ => {} // ignored } } @@ -304,6 +314,7 @@ mod test { && self.react_perf == other.react_perf && self.promise == other.promise && self.node == other.node + && self.security == other.security } } @@ -343,6 +354,7 @@ mod test { react_perf: false, promise: false, node: false, + security: false, }; assert_eq!(plugins, expected); } diff --git a/crates/oxc_linter/src/rules.rs b/crates/oxc_linter/src/rules.rs index ca3e6e74a..9e8996a03 100644 --- a/crates/oxc_linter/src/rules.rs +++ b/crates/oxc_linter/src/rules.rs @@ -411,6 +411,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; @@ -789,6 +793,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, diff --git a/crates/oxc_linter/src/rules/security/api_keys/entropy.rs b/crates/oxc_linter/src/rules/security/api_keys/entropy.rs new file mode 100644 index 000000000..edb91afdf --- /dev/null +++ b/crates/oxc_linter/src/rules/security/api_keys/entropy.rs @@ -0,0 +1,65 @@ +/// 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>(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 { + /// 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)) + fn entropy(&self) -> f32; +} + +impl 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}" + ); + } + } +} diff --git a/crates/oxc_linter/src/rules/security/api_keys/mod.rs b/crates/oxc_linter/src/rules/security/api_keys/mod.rs new file mode 100644 index 000000000..e4f1501cc --- /dev/null +++ b/crates/oxc_linter/src/rules/security/api_keys/mod.rs @@ -0,0 +1,176 @@ +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 + /// + /// Disallows hard-coded API keys and other credentials. + /// + /// ### Why is this bad? + /// + /// Hard-coding API keys and committing them to source control is a serious + /// security risk. + /// + /// 1. If your code is leaked, attackers can use your API keys to access your + /// services and data. + /// 2. Accidental bundling of API keys can lead them to be exposed publicly + /// in your website, compriming your services. + /// 3. Any developer or contractor you hire will have access to your + /// services, even after they lose access to your codebase. + /// 4. Even after being deleted, they will be visible in your git repo's + /// commit history. + /// 5. Key rotation requires a code change and redeployment, and can + /// therefore not be handled by security teams or by automated systems. + /// 6. Many, many more reasons. + /// + /// ```ts + /// const API_KEY = 'abcdef123456'; + /// const data = await fetch('/api/some/endpoint', { + /// headers: { + /// 'Authorization': `Bearer ${API_KEY}`, + /// } + /// }); + /// ``` + /// + /// ### What To Do Instead + /// :::warning + /// The Oxc team are not security experts. We do not endorse any particular + /// key management service or strategy. Do your research and choose the best + /// solution/architecture for your use case. + /// ::: + /// + /// One possible alternative is to store secrets in a secure secrets manager + /// (such as [AWS + /// KMS](https://docs.aws.amazon.com/AWSJavaScriptSDK/v3/latest/client/kms/), + /// [HashiCorp Vault](https://github.com/nodevault/node-vault/tree/master), + /// [Pangea](https://pangea.cloud/docs/sdk/js/vault#retrieve), etc.) and + /// request them when your application starts (e.g. a Docker container, an + /// EC2). + /// + /// ### Examples + /// + /// Examples of **incorrect** code for this rule: + /// ```js + /// const AWS_ACCESS_KEY_ID = 'AKIA1234X678C123B567'; + /// const OPENAI_API_KEY = 'sk_test_1234567890'; + /// ``` + /// + /// Examples of **correct** code for this rule: + /// + /// ```js + /// const AWS_ACCESS_KEY_ID = process.env.AWS_ACCESS_KEY_ID; + /// const OPENAI_API_KEY = await getSecret('open-ai-api-key'); + /// ``` + ApiKeys, + correctness +); + +#[derive(Debug, Default, Clone)] +pub struct ApiKeys(Box); + +#[derive(Debug, Clone)] +pub struct ApiKeysInner { + /// Minimum length over all enabled secret rules. + /// This is a performance optimization to avoid checking each rule for every string. + min_len: NonZeroU32, + /// Minimum entropy over all enabled secret rules. + /// This is a performance optimization to avoid checking each rule for every string. + min_entropy: f32, + /// Credentials the user wants to check for. + rules: Vec, +} + +impl Default for ApiKeysInner { + fn default() -> Self { + Self::new(ALL_RULES.clone()) + } +} + +impl ApiKeysInner { + // TODO: allow configuring what rules are enabled/disabled + // TODO: allow custom patterns + pub fn new(rules: Vec) -> 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; + } + } + } +} diff --git a/crates/oxc_linter/src/rules/security/api_keys/secret.rs b/crates/oxc_linter/src/rules/security/api_keys/secret.rs new file mode 100644 index 000000000..2ec973f03 --- /dev/null +++ b/crates/oxc_linter/src/rules/security/api_keys/secret.rs @@ -0,0 +1,149 @@ +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>, + entropy: f32, +} + +/// A secret that was positively identified by a secret rule. +/// +/// This gets used to construct the final diagnostic message. +#[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 +} + +/// 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 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) } + } + + /// Secret candidates above this length will not be considered. + /// + /// By default, no maximum length is enforced. + #[inline] + fn max_len(&self) -> Option { + 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 + } +} + +/// Detects hard-coded API keys and other credentials of a single kind or for a single SaaS +/// service. +pub trait SecretScanner: SecretScannerMeta { + /// Returns `true` if `candidate` is a leaked credential. + fn detect(&self, candidate: &Secret<'_>) -> bool; + + /// `verify` lets secret rules modify diagnostic messages and/or perform additional + /// verification checks on secrets before they are reported. You may mutate state such as the + /// diagnostic message, but you _must not_ modify the secret itself as it is shared between + /// all rules. + /// + /// Returns `true` to report the violation, or `false` to ignore it. + #[inline] + fn verify(&self, violation: &mut SecretViolation<'_>) -> bool { + true + } +} + +impl<'a> Secret<'a> { + pub fn new(secret: &'a str, span: Span, identifier: Option>) -> 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 set_message>>(&mut self, message: S) { + self.message = message.into(); + } + + pub fn rule_name(&self) -> &str { + &self.rule_name + } +} + +impl GetSpan for SecretViolation<'_> { + #[inline] + fn span(&self) -> Span { + self.secret.span() + } +} +impl Deref for SecretViolation<'_> { + type Target = str; + + #[inline] + fn deref(&self) -> &Self::Target { + &self.secret + } +} diff --git a/crates/oxc_linter/src/rules/security/api_keys/secrets.rs b/crates/oxc_linter/src/rules/security/api_keys/secrets.rs new file mode 100644 index 000000000..2d44ba279 --- /dev/null +++ b/crates/oxc_linter/src/rules/security/api_keys/secrets.rs @@ -0,0 +1,60 @@ +mod aws_access_token; + +use std::num::NonZeroU32; + +use super::{Secret, SecretScanner, SecretScannerMeta, SecretViolation}; + +#[derive(Debug, Clone)] +pub enum SecretsEnum { + AwsAccessKeyId(aws_access_token::AwsAccessToken), +} + +impl SecretsEnum { + pub fn rule_name(&self) -> &'static str { + match self { + SecretsEnum::AwsAccessKeyId(rule) => rule.rule_name(), + } + } + + pub fn message(&self) -> &'static str { + match self { + SecretsEnum::AwsAccessKeyId(rule) => rule.message(), + } + } + + pub fn min_len(&self) -> NonZeroU32 { + match self { + SecretsEnum::AwsAccessKeyId(rule) => rule.min_len(), + } + } + + pub fn max_len(&self) -> Option { + match self { + SecretsEnum::AwsAccessKeyId(rule) => rule.max_len(), + } + } + + pub fn min_entropy(&self) -> f32 { + match self { + SecretsEnum::AwsAccessKeyId(rule) => rule.min_entropy(), + } + } + + pub fn verify(&self, violation: &mut SecretViolation<'_>) -> bool { + match self { + SecretsEnum::AwsAccessKeyId(rule) => rule.verify(violation), + } + } + + pub fn detect(&self, candidate: &Secret<'_>) -> bool { + match self { + SecretsEnum::AwsAccessKeyId(rule) => rule.detect(candidate), + } + } +} + +lazy_static::lazy_static! { + pub static ref ALL_RULES: Vec = vec![ + SecretsEnum::AwsAccessKeyId(aws_access_token::AwsAccessToken), + ]; +} diff --git a/crates/oxc_linter/src/rules/security/api_keys/secrets/aws_access_token.rs b/crates/oxc_linter/src/rules/security/api_keys/secrets/aws_access_token.rs new file mode 100644 index 000000000..20b789079 --- /dev/null +++ b/crates/oxc_linter/src/rules/security/api_keys/secrets/aws_access_token.rs @@ -0,0 +1,100 @@ +use std::num::NonZeroU32; + +use oxc_macros::declare_oxc_secret; +use phf::{map::Map, phf_map}; + +use super::{Secret, SecretScanner, SecretViolation}; + +/// See: +#[derive(Debug, Default, Clone)] +pub struct AwsAccessToken; + +declare_oxc_secret! { + AwsAccessToken, + "Detected an AWS Access Key ID, which may lead to unauthorized access to AWS resources.", + entropy = 2.0, + min_len = 20, + max_len = 20, +} + +impl SecretScanner for AwsAccessToken { + // '''(?:A3T[A-Z0-9]|AKIA|ASIA|ABIA|ACCA|)[A-Z0-9]{16}''' + fn detect(&self, candidate: &Secret<'_>) -> bool { + if !candidate.chars().all(|c| c.is_ascii_uppercase() || c.is_ascii_digit()) { + return false; + } + + let prefix = &candidate[..4]; + AWS_TOKEN_PREFIXES.contains_key(prefix) || &prefix[0..3] == "A3T" + } + + fn verify(&self, violation: &mut SecretViolation<'_>) -> bool { + let prefix = &violation[..4]; + // Detect false positives on DNA sequences + if prefix == "ACCA" && violation.chars().all(|c| c.is_ascii_uppercase()) { + return false; + } + + let name = AWS_TOKEN_PREFIXES.get(prefix).copied().unwrap_or("AWS access token"); + let a_or_an = match name.chars().next().unwrap() { + 'A' | 'E' | 'I' | 'O' | 'U' => "an", + _ => "a", + }; + + violation.set_message(format!( + "Detected {a_or_an} {name}, which may lead to unauthorized access to AWS resources." + )); + + true + } +} + +/// List taken from: +/// +static AWS_TOKEN_PREFIXES: Map<&'static str, &'static str> = phf_map! { + "ABIA" => "AWS STS service bearer token", + "ACCA" => "AWS Context-specific credential", + "AGPA" => "AWS User Group ID", + "AIDA" => "AWS IAM User ID", + "AIPA" => "Amazon EC2 instance profile", + "AKIA" => "AWS Access Key ID", + "ANPA" => "managed AWS Policy ID", + "ANVA" => "managed AWS Policy Version ID", + "APKA" => "AWS Public key", + "AROA" => "AWS Role", + "ASCA" => "AWS Certificate", + "ASIA" => "temporary (AWS STS) Access Key", +}; + +#[test] +fn test() { + use crate::{rules::ApiKeys, tester::Tester, RuleMeta}; + + let pass = vec![ + "let x = ''", + "let x = 'AKIA'", + "let not_a_key = 'abcdabcdabcdabcdabcd' ", // no prefix, has lowercase + "let not_a_key = 'AKIA ABCD1099FAM9KEY' ", // whitespace + "let not_a_key = 'AKIA-ABCD1099FAM9KEY' ", // special characters + "let not_a_key = 'AKIA_ABCD1099FAM9KEY' ", // special characters + "let not_a_key = 'AKIA%ABCD1099FAM9KEY' ", // special characters + "let not_a_key = 'AKIA$ABCD1099FAM9KEY' ", // special characters + "let not_a_key = 'AKIAAABcD1099FAM9KEY' ", // has lowercase + "let not_a_key = 'AKIAAABCD1099FAM9KEY9'", // too long + "let dna = 'ACCATGGCTACCGCTGTGCT' ", // DNA sequence + ]; + + let fail = vec![ + r#"let key = "AKIAAABCD1099FAM9KEY""#, + "let key = `AKIAAABCD1099FAM9KEY`", // no-expression template literal + "let key = 'ABIAAABCD1099FAM9KEY'", + "let key = 'ACCAAABCD1099FAM9KEY'", + "let key = 'AKIAAABCD1099FAM9KEY'", + "let key = 'AKIAAABCD1099FAM9KEY'", + "let key = 'AKIAAABCD1099FAM9KEY'", + ]; + + Tester::new(ApiKeys::NAME, pass, fail) + .with_snapshot_suffix("aws_access_token") + .test_and_snapshot(); +} diff --git a/crates/oxc_linter/src/snapshots/api_keys@aws_access_token.snap b/crates/oxc_linter/src/snapshots/api_keys@aws_access_token.snap new file mode 100644 index 000000000..099f8cfbc --- /dev/null +++ b/crates/oxc_linter/src/snapshots/api_keys@aws_access_token.snap @@ -0,0 +1,51 @@ +--- +source: crates/oxc_linter/src/tester.rs +--- + ⚠ oxc-security(api-keys/aws-access-token): Detected an AWS Access Key ID, which may lead to unauthorized access to AWS resources. + ╭─[api_keys.tsx:1:11] + 1 │ let key = "AKIAAABCD1099FAM9KEY" + · ────────────────────── + ╰──── + help: Use a secrets manager to store your API keys securely, then read them at runtime. + + ⚠ oxc-security(api-keys/aws-access-token): Detected an AWS Access Key ID, which may lead to unauthorized access to AWS resources. + ╭─[api_keys.tsx:1:11] + 1 │ let key = `AKIAAABCD1099FAM9KEY` + · ────────────────────── + ╰──── + help: Use a secrets manager to store your API keys securely, then read them at runtime. + + ⚠ oxc-security(api-keys/aws-access-token): Detected an AWS STS service bearer token, which may lead to unauthorized access to AWS resources. + ╭─[api_keys.tsx:1:11] + 1 │ let key = 'ABIAAABCD1099FAM9KEY' + · ────────────────────── + ╰──── + help: Use a secrets manager to store your API keys securely, then read them at runtime. + + ⚠ oxc-security(api-keys/aws-access-token): Detected an AWS Context-specific credential, which may lead to unauthorized access to AWS resources. + ╭─[api_keys.tsx:1:11] + 1 │ let key = 'ACCAAABCD1099FAM9KEY' + · ────────────────────── + ╰──── + help: Use a secrets manager to store your API keys securely, then read them at runtime. + + ⚠ oxc-security(api-keys/aws-access-token): Detected an AWS Access Key ID, which may lead to unauthorized access to AWS resources. + ╭─[api_keys.tsx:1:11] + 1 │ let key = 'AKIAAABCD1099FAM9KEY' + · ────────────────────── + ╰──── + help: Use a secrets manager to store your API keys securely, then read them at runtime. + + ⚠ oxc-security(api-keys/aws-access-token): Detected an AWS Access Key ID, which may lead to unauthorized access to AWS resources. + ╭─[api_keys.tsx:1:11] + 1 │ let key = 'AKIAAABCD1099FAM9KEY' + · ────────────────────── + ╰──── + help: Use a secrets manager to store your API keys securely, then read them at runtime. + + ⚠ oxc-security(api-keys/aws-access-token): Detected an AWS Access Key ID, which may lead to unauthorized access to AWS resources. + ╭─[api_keys.tsx:1:11] + 1 │ let key = 'AKIAAABCD1099FAM9KEY' + · ────────────────────── + ╰──── + help: Use a secrets manager to store your API keys securely, then read them at runtime. diff --git a/crates/oxc_macros/src/declare_oxc_lint.rs b/crates/oxc_macros/src/declare_oxc_lint.rs index 8f80bcbe3..b76c8b4a9 100644 --- a/crates/oxc_macros/src/declare_oxc_lint.rs +++ b/crates/oxc_macros/src/declare_oxc_lint.rs @@ -52,7 +52,7 @@ impl Parse for LintRuleMeta { } } -fn rule_name_converter() -> Converter { +pub(crate) fn rule_name_converter() -> Converter { Converter::new().remove_boundary(Boundary::LowerDigit).to_case(Case::Kebab) } diff --git a/crates/oxc_macros/src/declare_oxc_secret.rs b/crates/oxc_macros/src/declare_oxc_secret.rs new file mode 100644 index 000000000..ae9b7ff67 --- /dev/null +++ b/crates/oxc_macros/src/declare_oxc_secret.rs @@ -0,0 +1,164 @@ +use proc_macro::TokenStream; +use quote::quote; +use syn::{ + parse::{Parse, ParseStream}, + Ident, LitFloat, LitInt, LitStr, Token, +}; + +use super::declare_oxc_lint::rule_name_converter; + +pub struct SecretRuleMeta { + struct_name: Ident, + message: LitStr, + entropy: Option, + min_len: Option, + max_len: Option, +} + +impl Parse for SecretRuleMeta { + fn parse(mut input: syn::parse::ParseStream) -> syn::Result { + let struct_name = input.parse()?; + input.parse::()?; + let description = input.parse()?; + + eat_comma(&mut input)?; + + let mut rule = SecretRuleMeta { + struct_name, + message: description, + entropy: None, + min_len: None, + max_len: None, + }; + + while input.peek(Ident) { + let ident = input.parse::()?; + match ident.to_string().as_str() { + "entropy" => { + input.parse::()?; + let entropy = input.parse::()?; + if entropy.base10_parse::()? < 0.0 { + return Err(syn::Error::new_spanned( + entropy, + "Entropy must be greater than or equal to 0.", + )); + } + rule.entropy = Some(entropy); + } + "min_len" => { + input.parse::()?; + let min_len = input.parse::()?; + if min_len.base10_parse::()? < 1 { + return Err(syn::Error::new_spanned( + min_len, + "Minimum length cannot be zero.", + )); + } + rule.min_len = Some(min_len); + } + "max_len" => { + input.parse::()?; + let max_len = input.parse::()?; + if max_len.base10_parse::()? < 1 { + return Err(syn::Error::new_spanned( + max_len, + "Maximum length cannot be zero.", + )); + } + rule.max_len = Some(max_len); + } + _ => return Err(syn::Error::new_spanned( + ident, + "Unexpected attribute. Only `entropy`, `min_len`, and `max_len` are allowed", + )), + } + eat_comma(&mut input)?; + } + + // Ignore the rest + input.parse::()?; + + if let (Some(min), Some(max)) = (rule.min_len.as_ref(), &rule.max_len.as_ref()) { + let min = min.base10_parse::()?; + let max = max.base10_parse::()?; + if min > max { + return Err(syn::Error::new_spanned( + max, + "Maximum length must be greater than or equal to minimum length.", + )); + } + } + + Ok(rule) + } +} + +pub fn declare_oxc_secret(meta: SecretRuleMeta) -> TokenStream { + let SecretRuleMeta { + // + struct_name, + message, + entropy, + min_len, + max_len, + } = meta; + + let rule_name = rule_name_converter().convert(struct_name.to_string()); + + let min_len_fn = min_len.map(|min_len| { + quote! { + #[inline] + fn min_len(&self) -> NonZeroU32 { + // SAFETY: #min_len is a valid value for NonZeroU32 + unsafe { NonZeroU32::new_unchecked(#min_len) } + } + } + }); + + let max_len_fn = max_len.map(|max_len| { + quote! { + #[inline] + fn max_len(&self) -> Option { + Some(unsafe { NonZeroU32::new_unchecked(#max_len) }) + } + } + }); + + let entropy_fn = entropy.map(|entropy| { + quote! { + #[inline] + fn min_entropy(&self) -> f32 { + #entropy + } + } + }); + + let output = quote! { + impl super::SecretScannerMeta for #struct_name { + #[inline] + fn rule_name(&self) -> &'static str { + #rule_name + } + + #[inline] + fn message(&self) -> &'static str { + #message + } + + #min_len_fn + + #max_len_fn + + #entropy_fn + } + }; + + TokenStream::from(output) +} + +fn eat_comma(input: &mut ParseStream) -> syn::Result<()> { + if input.peek(Token!(,)) { + input.parse::()?; + } + Ok(()) +} diff --git a/crates/oxc_macros/src/lib.rs b/crates/oxc_macros/src/lib.rs index ed6d05bbf..f7ba6e5d9 100644 --- a/crates/oxc_macros/src/lib.rs +++ b/crates/oxc_macros/src/lib.rs @@ -3,6 +3,7 @@ use syn::parse_macro_input; mod declare_all_lint_rules; mod declare_oxc_lint; +mod declare_oxc_secret; /// Macro used to declare an oxc lint rule /// @@ -101,3 +102,65 @@ pub fn declare_all_lint_rules(input: TokenStream) -> TokenStream { let metadata = parse_macro_input!(input as declare_all_lint_rules::AllLintRulesMeta); declare_all_lint_rules::declare_all_lint_rules(metadata) } + +/// Declare a secret scanner for `oxc-security/api-keys`. +/// +/// Scanner definitions are composed of: +/// 1. The scanner struct name, +/// 2. A message displayed in diagnostics, +/// 3. A set of `key = value` config pairs. +/// +/// # Pre-Verify Configuration +/// +/// These configs filter secret candidates before `verify` is ever called. This +/// is for performance reasons, as many `verify` implementations use expensive +/// checks (such as regular expression matching). All configs are optional. +/// +/// The following config key/value pairs are available: +/// +/// ## `entropy` ([`f32`]) +/// Minimum [Shannon +/// entropy](https://en.wikipedia.org/wiki/Entropy_(information_theory)) a +/// candidate must have to be considered a violation. Must be a positive, +/// non-zero `f32`. Defaults to `0.5`. +/// +/// ## `min_len` ([`NonZeroU32`]) +/// Minimum length a key candidate must have to be considered a violation. This +/// is a `u32` greater than 0. Defaults to `8`. +/// +/// ## `max_len` ([`Option`]) +/// Maximum length a key candidate must have to be considered a violation. This +/// is a `u32` greater than 0. By default, no maximum is enforced ([`None`]). +/// +/// # Example +/// ``` +/// use oxc_macros::declare_oxc_secret; +/// use super::SecretScanner; +/// +/// #[derive(Debug, Default, Clone)] +/// pub struct AwsAccessKeyId; +/// +/// declare_oxc_secret! { +/// AwsAccessKeyId, +/// "Detected an AWS Access Key ID, which can be used to access AWS resources.", +/// entropy = 4.0, +/// min_len = 20, +/// max_len = 20, +/// } +/// +/// impl SecretScanner for AwsAccessKeyId { +/// fn detect(&self, candidate: &Secret<'_>) -> bool { +/// // Look for AKIA, ASIA, or AIDA +/// ["AKIA", "ASIA", "AIDA"].iter().any(|prefix| candidate.starts_with(prefix)) && +/// !candidate.cha +/// } +/// } +/// +/// ``` +/// +/// [`NonZeroU32`]: std::num::NonZeroU32 +#[proc_macro] +pub fn declare_oxc_secret(input: TokenStream) -> TokenStream { + let metadata = parse_macro_input!(input as declare_oxc_secret::SecretRuleMeta); + declare_oxc_secret::declare_oxc_secret(metadata) +} diff --git a/justfile b/justfile index 65d456ecf..56a279f5a 100755 --- a/justfile +++ b/justfile @@ -176,6 +176,9 @@ new-promise-rule name: new-vitest-rule name: cargo run -p rulegen {{name}} vitest +new-security-rule name: + cargo run -p rulegen {{name}} security + clone-submodule dir url sha: git clone --depth=1 {{url}} {{dir}} || true cd {{dir}} && git fetch origin {{sha}} && git reset --hard {{sha}} diff --git a/tasks/rulegen/src/main.rs b/tasks/rulegen/src/main.rs index 1a5b5641b..e4d9c364c 100644 --- a/tasks/rulegen/src/main.rs +++ b/tasks/rulegen/src/main.rs @@ -589,6 +589,7 @@ pub enum RuleKind { TreeShaking, Promise, Vitest, + Security, } impl RuleKind { @@ -607,6 +608,7 @@ impl RuleKind { "tree-shaking" => Self::TreeShaking, "promise" => Self::Promise, "vitest" => Self::Vitest, + "security" => Self::Security, _ => Self::ESLint, } } @@ -629,6 +631,7 @@ impl Display for RuleKind { Self::TreeShaking => write!(f, "eslint-plugin-tree-shaking"), Self::Promise => write!(f, "eslint-plugin-promise"), Self::Vitest => write!(f, "eslint-plugin-vitest"), + Self::Security => write!(f, "security"), } } } @@ -657,7 +660,7 @@ fn main() { RuleKind::TreeShaking => format!("{TREE_SHAKING_PATH}/{kebab_rule_name}.test.ts"), RuleKind::Promise => format!("{PROMISE_TEST_PATH}/{kebab_rule_name}.js"), RuleKind::Vitest => format!("{VITEST_TEST_PATH}/{kebab_rule_name}.test.ts"), - RuleKind::Oxc => String::new(), + RuleKind::Oxc | RuleKind::Security => String::new(), }; let language = match rule_kind { RuleKind::Typescript | RuleKind::Oxc => "ts", diff --git a/tasks/rulegen/src/template.rs b/tasks/rulegen/src/template.rs index d364be768..c9576234e 100644 --- a/tasks/rulegen/src/template.rs +++ b/tasks/rulegen/src/template.rs @@ -44,6 +44,7 @@ impl<'a> Template<'a> { RuleKind::TreeShaking => Path::new("crates/oxc_linter/src/rules/tree_shaking"), RuleKind::Promise => Path::new("crates/oxc_linter/src/rules/promise"), RuleKind::Vitest => Path::new("crates/oxc_linter/src/rules/vitest"), + RuleKind::Security => Path::new("crates/oxc_linter/src/rules/security"), }; std::fs::create_dir_all(path)?; @@ -52,7 +53,7 @@ impl<'a> Template<'a> { File::create(out_path.clone())?.write_all(rendered.as_bytes())?; format_rule_output(&out_path)?; - println!("Saved testd file to {out_path:?}"); + println!("Saved test file to {out_path:?}"); Ok(()) } diff --git a/tasks/website/src/linter/snapshots/cli.snap b/tasks/website/src/linter/snapshots/cli.snap index e67de1078..a039d20ff 100644 --- a/tasks/website/src/linter/snapshots/cli.snap +++ b/tasks/website/src/linter/snapshots/cli.snap @@ -67,6 +67,8 @@ Arguments: Enable the promise plugin and detect promise usage problems - **` --node-plugin`** — Enable the node plugin and detect node usage problems +- **` --security-plugin`** — + Enable the security plugin and detect security problems diff --git a/tasks/website/src/linter/snapshots/cli_terminal.snap b/tasks/website/src/linter/snapshots/cli_terminal.snap index ff249ddb9..3700e67c6 100644 --- a/tasks/website/src/linter/snapshots/cli_terminal.snap +++ b/tasks/website/src/linter/snapshots/cli_terminal.snap @@ -43,6 +43,7 @@ Enable Plugins problems --promise-plugin Enable the promise plugin and detect promise usage problems --node-plugin Enable the node plugin and detect node usage problems + --security-plugin Enable the security plugin and detect security problems Fix Problems --fix Fix as many issues as possible. Only unfixed issues are reported in