Skip to content

Commit a66e450

Browse files
feat(linter): implement noSecrets (#3823)
Co-authored-by: togami <[email protected]>
1 parent 26e722c commit a66e450

File tree

13 files changed

+742
-92
lines changed

13 files changed

+742
-92
lines changed

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@ our [guidelines for writing a good changelog entry](https://github.com/biomejs/b
1515

1616
#### New features
1717

18+
- Implement [nursery/noSecrets](https://biomejs.dev/linter/rules/no-secrets/). Contributed by @SaadBazaz
1819
- Implement [nursery/useConsistentMemberAccessibility](https://github.com/biomejs/biome/issues/3271). Contributed by @seitarof
1920
- Implement [nursery/noDuplicateCustomProperties](https://github.com/biomejs/biome/issues/2784). Contributed by @chansuke
2021

crates/biome_cli/src/execute/migrate/eslint_any_rule_to_biome.rs

Lines changed: 12 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

crates/biome_configuration/src/analyzer/linter/rules.rs

Lines changed: 109 additions & 90 deletions
Large diffs are not rendered by default.

crates/biome_diagnostics_categories/src/categories.rs

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -130,6 +130,7 @@ define_categories! {
130130
"lint/nursery/noExportedImports": "https://biomejs.dev/linter/rules/no-exported-imports",
131131
"lint/nursery/noImportantInKeyframe": "https://biomejs.dev/linter/rules/no-important-in-keyframe",
132132
"lint/nursery/noInvalidDirectionInLinearGradient": "https://biomejs.dev/linter/rules/no-invalid-direction-in-linear-gradient",
133+
"lint/nursery/noInvalidGridAreas": "https://biomejs.dev/linter/rules/use-consistent-grid-areas",
133134
"lint/nursery/noInvalidPositionAtImportRule": "https://biomejs.dev/linter/rules/no-invalid-position-at-import-rule",
134135
"lint/nursery/noIrregularWhitespace": "https://biomejs.dev/linter/rules/no-irregular-whitespace",
135136
"lint/nursery/noLabelWithoutControl": "https://biomejs.dev/linter/rules/no-label-without-control",
@@ -138,6 +139,7 @@ define_categories! {
138139
"lint/nursery/noReactSpecificProps": "https://biomejs.dev/linter/rules/no-react-specific-props",
139140
"lint/nursery/noRestrictedImports": "https://biomejs.dev/linter/rules/no-restricted-imports",
140141
"lint/nursery/noRestrictedTypes": "https://biomejs.dev/linter/rules/no-restricted-types",
142+
"lint/nursery/noSecrets": "https://biomejs.dev/linter/rules/no-secrets",
141143
"lint/nursery/noShorthandPropertyOverrides": "https://biomejs.dev/linter/rules/no-shorthand-property-overrides",
142144
"lint/nursery/noStaticElementInteractions": "https://biomejs.dev/linter/rules/no-static-element-interactions",
143145
"lint/nursery/noSubstr": "https://biomejs.dev/linter/rules/no-substr",
@@ -160,7 +162,6 @@ define_categories! {
160162
"lint/nursery/useBiomeSuppressionComment": "https://biomejs.dev/linter/rules/use-biome-suppression-comment",
161163
"lint/nursery/useConsistentBuiltinInstantiation": "https://biomejs.dev/linter/rules/use-consistent-new-builtin",
162164
"lint/nursery/useConsistentCurlyBraces": "https://biomejs.dev/linter/rules/use-consistent-curly-braces",
163-
"lint/nursery/noInvalidGridAreas": "https://biomejs.dev/linter/rules/use-consistent-grid-areas",
164165
"lint/nursery/useConsistentMemberAccessibility": "https://biomejs.dev/linter/rules/use-consistent-member-accessibility",
165166
"lint/nursery/useDateNow": "https://biomejs.dev/linter/rules/use-date-now",
166167
"lint/nursery/useDefaultSwitchClause": "https://biomejs.dev/linter/rules/use-default-switch-clause",

crates/biome_js_analyze/src/lint/nursery.rs

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@ pub mod no_misplaced_assertion;
1515
pub mod no_react_specific_props;
1616
pub mod no_restricted_imports;
1717
pub mod no_restricted_types;
18+
pub mod no_secrets;
1819
pub mod no_static_element_interactions;
1920
pub mod no_substr;
2021
pub mod no_undeclared_dependencies;
@@ -62,6 +63,7 @@ declare_lint_group! {
6263
self :: no_react_specific_props :: NoReactSpecificProps ,
6364
self :: no_restricted_imports :: NoRestrictedImports ,
6465
self :: no_restricted_types :: NoRestrictedTypes ,
66+
self :: no_secrets :: NoSecrets ,
6567
self :: no_static_element_interactions :: NoStaticElementInteractions ,
6668
self :: no_substr :: NoSubstr ,
6769
self :: no_undeclared_dependencies :: NoUndeclaredDependencies ,
Lines changed: 287 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,287 @@
1+
use biome_analyze::{
2+
context::RuleContext, declare_lint_rule, Ast, Rule, RuleDiagnostic, RuleSource, RuleSourceKind,
3+
};
4+
use biome_console::markup;
5+
6+
use biome_js_syntax::JsStringLiteralExpression;
7+
8+
use biome_rowan::AstNode;
9+
use regex::Regex;
10+
11+
use std::sync::LazyLock;
12+
13+
// TODO: Try to get this to work in JavaScript comments as well
14+
declare_lint_rule! {
15+
/// Disallow usage of sensitive data such as API keys and tokens.
16+
///
17+
/// This rule checks for high-entropy strings and matches common patterns
18+
/// for secrets, such as AWS keys, Slack tokens, and private keys.
19+
///
20+
/// While this rule is helpful, it's not infallible. Always review your code carefully and consider implementing additional security measures like automated secret scanning in your CI/CD and git pipeline, such as GitGuardian or GitHub protections.
21+
///
22+
/// ## Examples
23+
///
24+
/// ### Invalid
25+
///
26+
/// ```js,expect_diagnostic
27+
/// const secret = "AKIA1234567890EXAMPLE";
28+
/// ```
29+
///
30+
/// ### Valid
31+
///
32+
/// ```js
33+
/// const nonSecret = "hello world";
34+
/// ```
35+
pub NoSecrets {
36+
version: "next",
37+
name: "noSecrets",
38+
language: "js",
39+
recommended: false,
40+
sources: &[RuleSource::Eslint("no-secrets/no-secrets")],
41+
source_kind: RuleSourceKind::Inspired,
42+
}
43+
}
44+
45+
impl Rule for NoSecrets {
46+
type Query = Ast<JsStringLiteralExpression>;
47+
type State = &'static str;
48+
type Signals = Option<Self::State>;
49+
type Options = ();
50+
51+
fn run(ctx: &RuleContext<Self>) -> Self::Signals {
52+
let node = ctx.query();
53+
let token = node.value_token().ok()?;
54+
let text = token.text();
55+
56+
if text.len() < MIN_PATTERN_LEN {
57+
return None;
58+
}
59+
60+
for sensitive_pattern in SENSITIVE_PATTERNS.iter() {
61+
if text.len() < sensitive_pattern.min_len {
62+
continue;
63+
}
64+
65+
let matched = match &sensitive_pattern.pattern {
66+
Pattern::Regex(re) => re.is_match(text),
67+
Pattern::Contains(substring) => text.contains(substring),
68+
};
69+
70+
if matched {
71+
return Some(sensitive_pattern.comment);
72+
}
73+
}
74+
75+
if is_high_entropy(text) {
76+
Some("The string has a high entropy value")
77+
} else {
78+
None
79+
}
80+
}
81+
82+
fn diagnostic(ctx: &RuleContext<Self>, state: &Self::State) -> Option<RuleDiagnostic> {
83+
let node = ctx.query();
84+
Some(
85+
RuleDiagnostic::new(
86+
rule_category!(),
87+
node.range(),
88+
markup! { "Potential secret found." },
89+
)
90+
.note(markup! { "Type of secret detected: " {state} })
91+
.note(markup! {
92+
"Storing secrets in source code is a security risk. Consider the following steps:"
93+
"\n1. Remove the secret from your code. If you've already committed it, consider removing the commit entirely from your git tree."
94+
"\n2. If needed, use environment variables or a secure secret management system to store sensitive data."
95+
"\n3. If this is a false positive, consider adding an inline disable comment."
96+
})
97+
)
98+
}
99+
}
100+
101+
const HIGH_ENTROPY_THRESHOLD: f64 = 4.5;
102+
103+
// Workaround: Since I couldn't figure out how to declare them inline,
104+
// declare the LazyLock patterns separately
105+
static SLACK_TOKEN_REGEX: LazyLock<Regex> =
106+
LazyLock::new(|| Regex::new(r"xox[baprs]-([0-9a-zA-Z]{10,48})?").unwrap());
107+
108+
static SLACK_WEBHOOK_REGEX: LazyLock<Regex> = LazyLock::new(|| {
109+
Regex::new(
110+
r"https://hooks\.slack\.com/services/T[a-zA-Z0-9_]{8}/B[a-zA-Z0-9_]{8}/[a-zA-Z0-9_]{24}",
111+
)
112+
.unwrap()
113+
});
114+
115+
static GITHUB_TOKEN_REGEX: LazyLock<Regex> =
116+
LazyLock::new(|| Regex::new(r#"[gG][iI][tT][hH][uU][bB].*[0-9a-zA-Z]{35,40}"#).unwrap());
117+
118+
static TWITTER_OAUTH_REGEX: LazyLock<Regex> =
119+
LazyLock::new(|| Regex::new(r#"[tT][wW][iI][tT][tT][eE][rR].*[0-9a-zA-Z]{35,44}"#).unwrap());
120+
121+
static FACEBOOK_OAUTH_REGEX: LazyLock<Regex> =
122+
LazyLock::new(|| Regex::new(r#"[fF][aA][cC][eE][bB][oO][oO][kK].*(?:.{0,42})"#).unwrap());
123+
124+
static HEROKU_API_KEY_REGEX: LazyLock<Regex> = LazyLock::new(|| {
125+
Regex::new(
126+
r"[hH][eE][rR][oO][kK][uU].*[0-9A-F]{8}-[0-9A-F]{4}-[0-9A-F]{4}-[0-9A-F]{4}-[0-9A-F]{12}",
127+
)
128+
.unwrap()
129+
});
130+
131+
static PASSWORD_IN_URL_REGEX: LazyLock<Regex> = LazyLock::new(|| {
132+
Regex::new(r#"[a-zA-Z]{3,10}://[^/\s:@]{3,20}:[^/\s:@]{3,20}@.{1,100}['"\s]"#).unwrap()
133+
});
134+
135+
static GOOGLE_SERVICE_ACCOUNT_REGEX: LazyLock<Regex> = LazyLock::new(|| {
136+
Regex::new(r#"(?:^|[,\s])"type"\s*:\s*(?:['"]service_account['"']|service_account)(?:[,\s]|$)"#)
137+
.unwrap()
138+
});
139+
140+
static TWILIO_API_KEY_REGEX: LazyLock<Regex> =
141+
LazyLock::new(|| Regex::new(r#"SK[a-z0-9]{32}"#).unwrap());
142+
143+
static GOOGLE_OAUTH_REGEX: LazyLock<Regex> =
144+
LazyLock::new(|| Regex::new(r#"ya29\\.[0-9A-Za-z\\-_]+"#).unwrap());
145+
146+
static AWS_API_KEY_REGEX: LazyLock<Regex> =
147+
LazyLock::new(|| Regex::new(r"AKIA[0-9A-Z]{16}").unwrap());
148+
149+
enum Pattern {
150+
Regex(&'static LazyLock<Regex>),
151+
Contains(&'static str),
152+
}
153+
154+
struct SensitivePattern {
155+
pattern: Pattern,
156+
comment: &'static str,
157+
min_len: usize,
158+
}
159+
160+
static SENSITIVE_PATTERNS: &[SensitivePattern] = &[
161+
SensitivePattern {
162+
pattern: Pattern::Regex(&SLACK_TOKEN_REGEX),
163+
comment: "Slack Token",
164+
min_len: 32,
165+
},
166+
SensitivePattern {
167+
pattern: Pattern::Regex(&SLACK_WEBHOOK_REGEX),
168+
comment: "Slack Webhook",
169+
min_len: 24,
170+
},
171+
SensitivePattern {
172+
pattern: Pattern::Regex(&GITHUB_TOKEN_REGEX),
173+
comment: "GitHub",
174+
min_len: 35,
175+
},
176+
SensitivePattern {
177+
pattern: Pattern::Regex(&TWITTER_OAUTH_REGEX),
178+
comment: "Twitter OAuth",
179+
min_len: 35,
180+
},
181+
SensitivePattern {
182+
pattern: Pattern::Regex(&FACEBOOK_OAUTH_REGEX),
183+
comment: "Facebook OAuth",
184+
min_len: 32,
185+
},
186+
SensitivePattern {
187+
pattern: Pattern::Regex(&GOOGLE_OAUTH_REGEX),
188+
comment: "Google OAuth",
189+
min_len: 24,
190+
},
191+
SensitivePattern {
192+
pattern: Pattern::Regex(&AWS_API_KEY_REGEX),
193+
comment: "AWS API Key",
194+
min_len: 16,
195+
},
196+
SensitivePattern {
197+
pattern: Pattern::Regex(&HEROKU_API_KEY_REGEX),
198+
comment: "Heroku API Key",
199+
min_len: 12,
200+
},
201+
SensitivePattern {
202+
pattern: Pattern::Regex(&PASSWORD_IN_URL_REGEX),
203+
comment: "Password in URL",
204+
min_len: 14,
205+
},
206+
SensitivePattern {
207+
pattern: Pattern::Regex(&GOOGLE_SERVICE_ACCOUNT_REGEX),
208+
comment: "Google (GCP) Service-account",
209+
min_len: 14,
210+
},
211+
SensitivePattern {
212+
pattern: Pattern::Regex(&TWILIO_API_KEY_REGEX),
213+
comment: "Twilio API Key",
214+
min_len: 32,
215+
},
216+
SensitivePattern {
217+
pattern: Pattern::Contains("-----BEGIN RSA PRIVATE KEY-----"),
218+
comment: "RSA Private Key",
219+
min_len: 64,
220+
},
221+
SensitivePattern {
222+
pattern: Pattern::Contains("-----BEGIN OPENSSH PRIVATE KEY-----"),
223+
comment: "SSH (OPENSSH) Private Key",
224+
min_len: 64,
225+
},
226+
SensitivePattern {
227+
pattern: Pattern::Contains("-----BEGIN DSA PRIVATE KEY-----"),
228+
comment: "SSH (DSA) Private Key",
229+
min_len: 64,
230+
},
231+
SensitivePattern {
232+
pattern: Pattern::Contains("-----BEGIN EC PRIVATE KEY-----"),
233+
comment: "SSH (EC) Private Key",
234+
min_len: 64,
235+
},
236+
SensitivePattern {
237+
pattern: Pattern::Contains("-----BEGIN PGP PRIVATE KEY BLOCK-----"),
238+
comment: "PGP Private Key Block",
239+
min_len: 64,
240+
},
241+
];
242+
243+
const MIN_PATTERN_LEN: usize = 12;
244+
245+
fn is_high_entropy(text: &str) -> bool {
246+
let entropy = calculate_shannon_entropy(text);
247+
entropy > HIGH_ENTROPY_THRESHOLD // TODO: Make this optional, or controllable
248+
}
249+
250+
/// Inspired by https://github.com/nickdeis/eslint-plugin-no-secrets/blob/master/utils.js#L93
251+
/// Adapted from https://docs.rs/entropy/latest/src/entropy/lib.rs.html#14-33
252+
/// Calculates Shannon entropy to measure the randomness of data. High entropy values indicate potentially
253+
/// secret or sensitive information, as such data is typically more random and less predictable than regular text.
254+
/// Useful for detecting API keys, passwords, and other secrets within code or configuration files.
255+
fn calculate_shannon_entropy(data: &str) -> f64 {
256+
let mut freq = [0usize; 256];
257+
let len = data.len();
258+
for &byte in data.as_bytes() {
259+
freq[byte as usize] += 1;
260+
}
261+
262+
let mut entropy = 0.0;
263+
for count in freq.iter() {
264+
if *count > 0 {
265+
let p = *count as f64 / len as f64;
266+
entropy -= p * p.log2();
267+
}
268+
}
269+
270+
entropy
271+
}
272+
273+
#[cfg(test)]
274+
mod tests {
275+
use super::*;
276+
#[test]
277+
fn test_min_pattern_len() {
278+
let actual_min_pattern_len = SENSITIVE_PATTERNS
279+
.iter()
280+
.map(|pattern| pattern.min_len)
281+
.min()
282+
.unwrap_or(0);
283+
284+
let initialized_min_pattern_len = MIN_PATTERN_LEN;
285+
assert_eq!(initialized_min_pattern_len, actual_min_pattern_len, "The initialized MIN_PATTERN_LEN value is not correct. Please ensure it's the smallest possible number from the SENSITIVE_PATTERNS.");
286+
}
287+
}

crates/biome_js_analyze/src/options.rs

Lines changed: 1 addition & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.
Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
const awsApiKey = "AKIA1234567890EXAMPLE"
2+
const slackToken = "xoxb-not-a-real-token-this-will-not-work";
3+
const rsaPrivateKey = "-----BEGIN RSA PRIVATE KEY-----\nMIIEpAIBAAKCAQEA1234567890..."
4+
const facebookToken = "facebook_app_id_12345abcde67890fghij12345";
5+
const twitterApiKey = "twitter_api_key_1234567890abcdefghijklmnopqrstuvwxyz";
6+
const githubToken = "github_pat_1234567890abcdefghijklmnopqrstuvwxyz";
7+
const clientSecret = "abcdefghijklmnopqrstuvwxyz"
8+
const herokuApiKey = "heroku_api_key_1234abcd-1234-1234-1234-1234abcd5678";
9+
const genericSecret = "secret_1234567890abcdefghijklmnopqrstuvwxyz";
10+
const genericApiKey = "api_key_1234567890abcdefghijklmnopqrstuvwxyz";
11+
const slackKey = "https://hooks.slack.com/services/T12345678/B12345678/abcdefghijklmnopqrstuvwx"
12+
const twilioApiKey = "SK1234567890abcdefghijklmnopqrstuv";
13+
const dbUrl = "postgres://user:[email protected]:5432/dbname";

0 commit comments

Comments
 (0)