1use std::path::Path;
14
15use oxc_allocator::Allocator;
16use oxc_ast::ast::{
17 Argument, ArrowFunctionExpression, BindingPattern, CallExpression, Class, Expression, Function,
18 ImportDeclaration, ImportDeclarationSpecifier, JSXAttribute, MethodDefinition, NewExpression,
19 ObjectProperty, PropertyKey, SpreadElement, StringLiteral, TemplateLiteral, VariableDeclarator,
20};
21use oxc_ast_visit::{Visit, walk};
22use oxc_parser::Parser;
23use oxc_span::{GetSpan, SourceType, Span};
24
25#[derive(Debug, Clone, PartialEq, Eq)]
26pub enum NodeKind {
27 Call,
29 Handler,
31 Literal,
33 Assign,
35 Spread,
37}
38
39#[derive(Debug, Clone, PartialEq, Eq)]
40pub struct Node {
41 pub line: usize,
42 pub kind: NodeKind,
43 pub callee: String,
44 pub text: String,
45}
46
47#[derive(Debug, Clone, PartialEq, Eq)]
50pub struct FunctionSpan {
51 pub name: String,
52 pub start: usize,
53 pub end: usize,
54}
55
56#[derive(Debug, Clone, PartialEq, Eq)]
59pub struct Import {
60 pub local: String,
61 pub exported: String,
62 pub specifier: String,
63}
64
65#[derive(Debug, Clone, Default, PartialEq, Eq)]
67pub struct Structure {
68 pub functions: Vec<FunctionSpan>,
69 pub imports: Vec<Import>,
70}
71
72struct Collector<'s> {
73 source: &'s str,
74 nodes: Vec<Node>,
75 structure: Structure,
76}
77
78impl<'s> Collector<'s> {
79 fn line(&self, span: Span) -> usize {
80 self.source[..span.start as usize].matches('\n').count() + 1
81 }
82 fn text(&self, span: Span) -> String {
83 let raw = &self.source[span.start as usize..span.end as usize];
84 raw.split_whitespace()
85 .collect::<Vec<_>>()
86 .join(" ")
87 .chars()
88 .take(160)
89 .collect()
90 }
91 fn push(&mut self, span: Span, kind: NodeKind, callee: &str) {
92 self.nodes.push(Node {
93 line: self.line(span),
94 kind,
95 callee: callee.split_whitespace().collect(),
96 text: self.text(span),
97 });
98 }
99 fn span_function(&mut self, name: &str, span: Span) {
100 let start = self.line(span);
101 let end = self.source[..span.end as usize].matches('\n').count() + 1;
102 self.structure.functions.push(FunctionSpan {
103 name: name.to_owned(),
104 start,
105 end,
106 });
107 }
108 fn handler_bodies(&mut self, callee: &str, arguments: &[Argument<'_>]) {
109 let Some(Argument::StringLiteral(path)) = arguments.first() else {
110 return;
111 };
112 if !path.value.starts_with('/') && !path.value.starts_with('*') {
113 return;
114 }
115 for argument in &arguments[1..] {
116 match argument {
117 Argument::ArrowFunctionExpression(f) => {
118 self.push(f.span, NodeKind::Handler, callee);
119 }
120 Argument::FunctionExpression(f) => {
121 self.push(f.span, NodeKind::Handler, callee);
122 }
123 _ => {}
124 }
125 }
126 }
127}
128
129pub fn secretish(value: &str) -> bool {
132 if value.len() < 8 {
133 return false;
134 }
135 let prefixes = [
136 "sk_", "sk-", "pk_", "rk_", "AKIA", "ghp_", "xoxa-", "xoxb-", "xoxp-", "eyJ",
137 ];
138 if prefixes.iter().any(|p| value.starts_with(p)) {
139 return true;
140 }
141 let hex = value.len() >= 20 && value.chars().all(|c| c.is_ascii_hexdigit());
142 let base64 = value.len() >= 24
143 && value
144 .trim_end_matches('=')
145 .chars()
146 .all(|c| c.is_ascii_alphanumeric() || "+/_-".contains(c))
147 && value.chars().any(|c| c.is_ascii_digit())
148 && value.chars().any(|c| c.is_ascii_uppercase())
149 && value.chars().any(|c| c.is_ascii_lowercase());
150 let word = value.to_ascii_lowercase();
151 hex || base64 || word.contains("password") || word.contains("secret") || word.contains("passwd")
152}
153
154impl<'a> Visit<'a> for Collector<'_> {
155 fn visit_call_expression(&mut self, call: &CallExpression<'a>) {
156 let callee = self.text(call.callee.span());
157 self.push(call.span, NodeKind::Call, &callee);
158 self.handler_bodies(&callee, &call.arguments);
159 walk::walk_call_expression(self, call);
160 }
161 fn visit_new_expression(&mut self, new: &NewExpression<'a>) {
162 let callee = format!("new {}", self.text(new.callee.span()));
163 self.push(new.span, NodeKind::Call, &callee);
164 walk::walk_new_expression(self, new);
165 }
166 fn visit_string_literal(&mut self, literal: &StringLiteral<'a>) {
167 if secretish(&literal.value) {
168 self.push(literal.span, NodeKind::Literal, "");
169 }
170 }
171 fn visit_template_literal(&mut self, literal: &TemplateLiteral<'a>) {
172 if literal.expressions.is_empty() && literal.quasis.iter().any(|q| secretish(&q.value.raw))
173 {
174 self.push(literal.span, NodeKind::Literal, "");
175 }
176 walk::walk_template_literal(self, literal);
177 }
178 fn visit_object_property(&mut self, property: &ObjectProperty<'a>) {
179 let key = match &property.key {
180 PropertyKey::StaticIdentifier(id) => id.name.to_string(),
181 other => self.text(other.span()),
182 };
183 self.push(property.span, NodeKind::Assign, &key);
184 if matches!(
187 property.value,
188 Expression::ArrowFunctionExpression(_) | Expression::FunctionExpression(_)
189 ) {
190 self.span_function(&key, property.span);
191 }
192 walk::walk_object_property(self, property);
193 }
194 fn visit_jsx_attribute(&mut self, attribute: &JSXAttribute<'a>) {
195 let name = self.text(attribute.name.span());
196 self.push(attribute.span, NodeKind::Assign, &name);
197 walk::walk_jsx_attribute(self, attribute);
198 }
199 fn visit_spread_element(&mut self, spread: &SpreadElement<'a>) {
200 let text = self.text(spread.argument.span());
201 if text.contains("body") || text.contains("query") || text.contains("params") {
202 self.push(spread.span, NodeKind::Spread, &text);
203 }
204 walk::walk_spread_element(self, spread);
205 }
206 fn visit_function(&mut self, function: &Function<'a>, flags: oxc_syntax::scope::ScopeFlags) {
207 if let Some(id) = &function.id {
208 if matches!(
209 id.name.as_str(),
210 "GET" | "POST" | "PUT" | "PATCH" | "DELETE" | "loader" | "action"
211 ) {
212 self.push(function.span, NodeKind::Handler, &id.name);
213 }
214 self.span_function(&id.name, function.span);
215 }
216 walk::walk_function(self, function, flags);
217 }
218 fn visit_class(&mut self, class: &Class<'a>) {
219 if let Some(id) = &class.id {
222 self.span_function(&id.name, class.span);
223 }
224 walk::walk_class(self, class);
225 }
226 fn visit_method_definition(&mut self, method: &MethodDefinition<'a>) {
227 if !method.decorators.is_empty() {
228 self.push(method.span, NodeKind::Handler, "decorated");
229 }
230 let name = self.text(method.key.span());
231 self.span_function(&name, method.span);
232 walk::walk_method_definition(self, method);
233 }
234 fn visit_variable_declarator(&mut self, declarator: &VariableDeclarator<'a>) {
235 if let (BindingPattern::BindingIdentifier(id), Some(init)) =
236 (&declarator.id, &declarator.init)
237 && matches!(
238 init,
239 Expression::ArrowFunctionExpression(_) | Expression::FunctionExpression(_)
240 )
241 {
242 self.span_function(&id.name, declarator.span);
243 }
244 if let Some(Expression::CallExpression(call)) = &declarator.init
245 && self.text(call.callee.span()) == "require"
246 && let Some(Argument::StringLiteral(module)) = call.arguments.first()
247 {
248 let specifier = module.value.to_string();
249 match &declarator.id {
250 BindingPattern::BindingIdentifier(id) => self.structure.imports.push(Import {
251 local: id.name.to_string(),
252 exported: "*".into(),
253 specifier,
254 }),
255 BindingPattern::ObjectPattern(pattern) => {
256 for property in &pattern.properties {
257 let exported = self.text(property.key.span());
258 let local = match &property.value {
259 BindingPattern::BindingIdentifier(id) => id.name.to_string(),
260 _ => exported.clone(),
261 };
262 self.structure.imports.push(Import {
263 local,
264 exported,
265 specifier: specifier.clone(),
266 });
267 }
268 }
269 _ => {}
270 }
271 }
272 walk::walk_variable_declarator(self, declarator);
273 }
274 fn visit_arrow_function_expression(&mut self, arrow: &ArrowFunctionExpression<'a>) {
275 walk::walk_arrow_function_expression(self, arrow);
276 }
277 fn visit_import_declaration(&mut self, import: &ImportDeclaration<'a>) {
278 let specifier = import.source.value.to_string();
279 if let Some(specifiers) = &import.specifiers {
280 for s in specifiers {
281 let (local, exported) = match s {
282 ImportDeclarationSpecifier::ImportSpecifier(i) => {
283 (i.local.name.to_string(), i.imported.name().to_string())
284 }
285 ImportDeclarationSpecifier::ImportDefaultSpecifier(i) => {
286 (i.local.name.to_string(), "default".to_owned())
287 }
288 ImportDeclarationSpecifier::ImportNamespaceSpecifier(i) => {
289 (i.local.name.to_string(), "*".to_owned())
290 }
291 };
292 self.structure.imports.push(Import {
293 local,
294 exported,
295 specifier: specifier.clone(),
296 });
297 }
298 }
299 walk::walk_import_declaration(self, import);
300 }
301 fn visit_expression(&mut self, expression: &Expression<'a>) {
302 if let Expression::AssignmentExpression(assignment) = expression {
303 let target = self.text(assignment.left.span());
304 self.push(assignment.span, NodeKind::Assign, &target);
305 if matches!(
307 assignment.right,
308 Expression::ArrowFunctionExpression(_) | Expression::FunctionExpression(_)
309 ) {
310 let name = target.rsplit('.').next().unwrap_or(&target).to_owned();
311 self.span_function(&name, assignment.span);
312 }
313 }
314 walk::walk_expression(self, expression);
315 }
316}
317
318pub fn nodes(file: &Path, source: &str) -> Result<Vec<Node>, String> {
322 Ok(parse(file, source)?.0)
323}
324
325pub fn structure(file: &Path, source: &str) -> Result<Structure, String> {
327 Ok(parse(file, source)?.1)
328}
329
330fn parse(file: &Path, source: &str) -> Result<(Vec<Node>, Structure), String> {
331 let source_type = SourceType::from_path(file).map_err(|e| e.to_string())?;
332 let allocator = Allocator::default();
333 let parsed = Parser::new(&allocator, source, source_type).parse();
334 if parsed.panicked {
335 return Err("the parser gave up on this file".into());
336 }
337 let mut collector = Collector {
338 source,
339 nodes: Vec::new(),
340 structure: Structure::default(),
341 };
342 collector.visit_program(&parsed.program);
343 collector.nodes.sort_by_key(|n| n.line);
344 collector.structure.functions.sort_by_key(|f| f.start);
345 collector.structure.functions.dedup();
346 Ok((collector.nodes, collector.structure))
347}
348
349#[cfg(test)]
350mod tests {
351 use super::*;
352
353 const FAKE_KEY: &str = concat!("sk_", "live_", "51H8xQ2KmNvBcdEfGhIjKlMnOpQr");
356
357 #[test]
358 fn finds_calls_handlers_literals_and_spreads() {
359 let source = format!(
360 r#"
361import {{ Router }} from 'express'
362const KEY = '{FAKE_KEY}'
363export const router = Router()
364router.get('/x/:id', async (req, res) => {{
365 const row = await db.query(`select * from t where id = ${{req.params.id}}`)
366 await users.update(req.user.id, {{ ...req.body }})
367 res.redirect(req.query.next)
368}})
369"#
370 );
371 let nodes = nodes(Path::new("a.ts"), &source).unwrap();
372 let find = |kind: NodeKind, needle: &str| {
373 nodes
374 .iter()
375 .find(|n| {
376 n.kind == kind
377 && if matches!(kind, NodeKind::Call | NodeKind::Handler) {
378 n.callee.contains(needle)
379 } else {
380 n.text.contains(needle)
381 }
382 })
383 .map(|n| n.line)
384 };
385 assert_eq!(find(NodeKind::Literal, "sk_live"), Some(3));
386 assert_eq!(find(NodeKind::Handler, "router.get"), Some(5));
387 assert_eq!(find(NodeKind::Call, "db.query"), Some(6));
388 assert_eq!(find(NodeKind::Spread, "req.body"), Some(7));
389 assert_eq!(find(NodeKind::Call, "res.redirect"), Some(8));
390 }
391
392 #[test]
393 fn functions_and_imports_are_listed_with_their_lines() {
394 let source = "import { users } from './db'\nconst { pingMe } = require('./utility')\nexport async function handle(req) {\n return users.find(req.id)\n}\nconst helper = (x) => x\nclass A {\n method() {\n return 1\n }\n}\n";
395 let s = structure(Path::new("a.js"), source).unwrap();
396 let names: Vec<(&str, usize, usize)> = s
397 .functions
398 .iter()
399 .map(|f| (f.name.as_str(), f.start, f.end))
400 .collect();
401 assert!(names.contains(&("handle", 3, 5)), "{names:?}");
402 assert!(names.contains(&("helper", 6, 6)));
403 assert!(names.contains(&("method", 8, 10)));
404 assert_eq!(s.imports.len(), 2);
405 assert_eq!(
406 (
407 s.imports[0].local.as_str(),
408 s.imports[0].exported.as_str(),
409 s.imports[0].specifier.as_str()
410 ),
411 ("users", "users", "./db")
412 );
413 assert_eq!(
414 (s.imports[1].local.as_str(), s.imports[1].specifier.as_str()),
415 ("pingMe", "./utility")
416 );
417 }
418
419 #[test]
420 fn a_secret_is_a_prefix_a_long_run_or_a_password_word() {
421 assert!(secretish(FAKE_KEY));
422 assert!(secretish("k9F2mZ8qL4vX1nB7rT3wY6hJ0pS5dG2c"));
423 assert!(secretish("hunter2password"));
424 assert!(!secretish("/api/users"));
425 assert!(!secretish("select * from users"));
426 }
427}