use std::path::Path;
use oxc_ast::ast::*;
use oxc_ast_visit::Visit;
use crate::rules::{Issue, IssueCategory, IssueSource, Rule, RuleContext, Severity};
use crate::utils::offset_to_line_col;
pub struct NoHardcodedSecretInJsx;
impl Rule for NoHardcodedSecretInJsx {
fn name(&self) -> &str {
"no_hardcoded_secret_in_jsx"
}
fn run(&self, ctx: &RuleContext<'_>) -> Vec<Issue> {
let mut visitor = Visitor {
source_text: ctx.source_text,
file_path: ctx.file_path,
issues: vec![],
};
visitor.visit_program(ctx.program);
visitor.issues
}
}
struct Visitor<'a> {
source_text: &'a str,
file_path: &'a Path,
issues: Vec<Issue>,
}
const SECRET_PROP_NAMES: &[&str] = &[
"apikey",
"api_key",
"apiKey",
"key",
"secret",
"token",
"password",
"credential",
"auth",
"accesskey",
"access_key",
"accessKey",
"privatekey",
"private_key",
"privateKey",
"clientsecret",
"client_secret",
"clientSecret",
];
const SECRET_NAME_PARTS: &[&str] = &[
"key",
"secret",
"token",
"password",
"credential",
"apikey",
"api_key",
"access_key",
"private_key",
"auth",
];
const PLACEHOLDERS: &[&str] = &[
"your-",
"example",
"placeholder",
"xxx",
"todo",
"changeme",
"<",
"***",
"...",
"test",
"fake",
"dummy",
];
fn looks_like_secret(value: &str) -> bool {
if value.len() < 12 {
return false;
}
let low = value.to_lowercase();
if PLACEHOLDERS.iter().any(|p| low.contains(p)) {
return false;
}
shannon_entropy(value) > 3.5
}
fn shannon_entropy(s: &str) -> f64 {
let len = s.len() as f64;
if len == 0.0 {
return 0.0;
}
let mut counts = [0u32; 256];
for b in s.bytes() {
counts[b as usize] += 1;
}
counts
.iter()
.filter(|&&c| c > 0)
.map(|&c| {
let p = c as f64 / len;
-p * p.log2()
})
.sum()
}
impl<'a, 'b> Visit<'b> for Visitor<'a> {
fn visit_jsx_attribute(&mut self, attr: &JSXAttribute<'b>) {
let prop_name = match &attr.name {
JSXAttributeName::Identifier(id) => id.name.as_str().to_lowercase(),
JSXAttributeName::NamespacedName(nn) => nn.name.name.as_str().to_lowercase(),
};
let is_secret_prop = SECRET_PROP_NAMES
.iter()
.any(|s| prop_name == s.to_lowercase());
if !is_secret_prop {
return;
}
let Some(JSXAttributeValue::StringLiteral(s)) = &attr.value else {
return;
};
if looks_like_secret(s.value.as_str()) {
let (line, col) = offset_to_line_col(self.source_text, s.span.start);
self.issues.push(Issue {
rule: "no_hardcoded_secret_in_jsx".into(),
message: format!(
"JSX prop `{}` contains a hardcoded secret — \
use an environment variable instead",
prop_name
),
file: self.file_path.to_path_buf(),
line,
column: col,
severity: Severity::High,
source: IssueSource::ReactPerfAnalyzer,
category: IssueCategory::Security,
});
}
}
fn visit_variable_declarator(&mut self, decl: &VariableDeclarator<'b>) {
let BindingPatternKind::BindingIdentifier(id) = &decl.id.kind else {
return;
};
let var_name = id.name.as_str().to_lowercase();
let is_secret_name = SECRET_NAME_PARTS.iter().any(|part| var_name.contains(part));
if !is_secret_name {
return;
}
let Some(init) = &decl.init else {
return;
};
let Expression::StringLiteral(s) = init else {
return;
};
if looks_like_secret(s.value.as_str()) {
let (line, col) = offset_to_line_col(self.source_text, s.span.start);
self.issues.push(Issue {
rule: "no_hardcoded_secret_in_jsx".into(),
message: format!(
"Variable `{}` appears to contain a hardcoded secret — \
use process.env or a secrets manager",
id.name
),
file: self.file_path.to_path_buf(),
line,
column: col,
severity: Severity::High,
source: IssueSource::ReactPerfAnalyzer,
category: IssueCategory::Security,
});
}
}
}