mirror of
https://github.com/danbulant/oxc
synced 2026-05-19 12:19:15 +00:00
feat(linter): add oxc-security/api-keys (#5906)
## What This PR does Adds a new `oxc-security/api-key` rule that scans for hard-coded API keys. It is broken up into "secret rules", where each one is responsible for finding a different kind of key. It is architecturally identical to how lint rules themselves. This PR also includes the first of these rules, for AWS access key IDs. Logic and rules are based on [keyhunter](https://github.com/Donisaac/keyhunter). I've licensed that repo under GNU GPLv3, but it's my code and I can do what I want with it 😈 (read: I'm fine with it being MIT for oxc). This PR is a complete feature in its own right, but does not represent the end of this work. See https://github.com/oxc-project/backlog/issues/116 to track overall progress.
This commit is contained in:
parent
767602b56d
commit
d24985ed51
21 changed files with 873 additions and 4 deletions
|
|
@ -34,6 +34,7 @@ trivia = "trivia"
|
|||
xdescribe = "xdescribe"
|
||||
seeked = "seeked"
|
||||
labeledby = "labeledby"
|
||||
hashi = "hashi"
|
||||
|
||||
[default.extend-identifiers]
|
||||
IIFEs = "IIFEs"
|
||||
|
|
|
|||
|
|
@ -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)]
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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<LintPluginOptions> 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<LintPlugins> 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<S: AsRef<str>> 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);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
65
crates/oxc_linter/src/rules/security/api_keys/entropy.rs
Normal file
65
crates/oxc_linter/src/rules/security/api_keys/entropy.rs
Normal file
|
|
@ -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<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 {
|
||||
/// 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<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}"
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
176
crates/oxc_linter/src/rules/security/api_keys/mod.rs
Normal file
176
crates/oxc_linter/src/rules/security/api_keys/mod.rs
Normal file
|
|
@ -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<ApiKeysInner>);
|
||||
|
||||
#[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<SecretsEnum>,
|
||||
}
|
||||
|
||||
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<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;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
149
crates/oxc_linter/src/rules/security/api_keys/secret.rs
Normal file
149
crates/oxc_linter/src/rules/security/api_keys/secret.rs
Normal file
|
|
@ -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<Atom<'a>>,
|
||||
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<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
|
||||
}
|
||||
}
|
||||
|
||||
/// 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<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 set_message<S: Into<Cow<'static, str>>>(&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
|
||||
}
|
||||
}
|
||||
60
crates/oxc_linter/src/rules/security/api_keys/secrets.rs
Normal file
60
crates/oxc_linter/src/rules/security/api_keys/secrets.rs
Normal file
|
|
@ -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<NonZeroU32> {
|
||||
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<SecretsEnum> = vec![
|
||||
SecretsEnum::AwsAccessKeyId(aws_access_token::AwsAccessToken),
|
||||
];
|
||||
}
|
||||
|
|
@ -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: <https://swisskyrepo.github.io/InternalAllTheThings/cloud/aws/aws-access-token/#access-key-id-secret>
|
||||
#[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|<other-keys-below>)[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:
|
||||
/// <https://swisskyrepo.github.io/InternalAllTheThings/cloud/aws/aws-access-token/#access-key-id-secret>
|
||||
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();
|
||||
}
|
||||
|
|
@ -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.
|
||||
|
|
@ -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)
|
||||
}
|
||||
|
||||
|
|
|
|||
164
crates/oxc_macros/src/declare_oxc_secret.rs
Normal file
164
crates/oxc_macros/src/declare_oxc_secret.rs
Normal file
|
|
@ -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<LitFloat>,
|
||||
min_len: Option<LitInt>,
|
||||
max_len: Option<LitInt>,
|
||||
}
|
||||
|
||||
impl Parse for SecretRuleMeta {
|
||||
fn parse(mut input: syn::parse::ParseStream) -> syn::Result<Self> {
|
||||
let struct_name = input.parse()?;
|
||||
input.parse::<Token!(,)>()?;
|
||||
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::<Ident>()?;
|
||||
match ident.to_string().as_str() {
|
||||
"entropy" => {
|
||||
input.parse::<Token!(=)>()?;
|
||||
let entropy = input.parse::<LitFloat>()?;
|
||||
if entropy.base10_parse::<f32>()? < 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::<Token!(=)>()?;
|
||||
let min_len = input.parse::<LitInt>()?;
|
||||
if min_len.base10_parse::<u32>()? < 1 {
|
||||
return Err(syn::Error::new_spanned(
|
||||
min_len,
|
||||
"Minimum length cannot be zero.",
|
||||
));
|
||||
}
|
||||
rule.min_len = Some(min_len);
|
||||
}
|
||||
"max_len" => {
|
||||
input.parse::<Token!(=)>()?;
|
||||
let max_len = input.parse::<LitInt>()?;
|
||||
if max_len.base10_parse::<u32>()? < 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::<proc_macro2::TokenStream>()?;
|
||||
|
||||
if let (Some(min), Some(max)) = (rule.min_len.as_ref(), &rule.max_len.as_ref()) {
|
||||
let min = min.base10_parse::<u32>()?;
|
||||
let max = max.base10_parse::<u32>()?;
|
||||
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<NonZeroU32> {
|
||||
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::<Token!(,)>()?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
|
@ -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<NonZeroU32>`])
|
||||
/// 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)
|
||||
}
|
||||
|
|
|
|||
3
justfile
3
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}}
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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(())
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Reference in a new issue