Skip to main content

supercov_engine/
security_candidates.rs

1//! Syntax-level candidates for the security surface catalog, from oxc.
2//!
3//! A per-file security question cannot say which line; a per-line question
4//! can, but only about lines somebody found. This lists the lines worth
5//! asking about in JavaScript and TypeScript: every call with its callee
6//! text, every string literal that looks like a secret, the body line of every
7//! route handler, every property or JSX attribute assignment, and every
8//! spread of a request body. Classifying a node into a check is the CLI's
9//! job; this only knows syntax. Measured against a regex over the same files,
10//! the union of the two moved line-level recall from 42% to 49% on the
11//! held-out half of RealVuln and secrets from 27% to 81%.
12
13use 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, Span};
24
25#[derive(Debug, Clone, PartialEq, Eq)]
26pub enum NodeKind {
27    /// A call or construction; `callee` is its callee expression text.
28    Call,
29    /// The line where a route handler's body starts.
30    Handler,
31    /// A string literal that looks like a credential.
32    Literal,
33    /// A property assignment, JSX attribute or plain assignment.
34    Assign,
35    /// A spread of a request body into an object.
36    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/// A function with the lines it spans, for the graph stage: every function
48/// is labelled once, and a path is confirmed with the bodies along it.
49#[derive(Debug, Clone, PartialEq, Eq)]
50pub struct FunctionSpan {
51    pub name: String,
52    pub start: usize,
53    pub end: usize,
54}
55
56/// A name this file imports from a module specifier, with the exported name
57/// it refers to (`default`, `*`, or the identifier).
58#[derive(Debug, Clone, PartialEq, Eq)]
59pub struct Import {
60    pub local: String,
61    pub exported: String,
62    pub specifier: String,
63}
64
65/// Functions and imports of a JavaScript or TypeScript file.
66#[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
129/// A literal that would grant access if copied: a known key prefix, a long
130/// hex or base64 run, or a password-shaped word inside it.
131pub 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        // `resolvers: { user: async (parent, args) => {} }`: a GraphQL
185        // resolver or a handler table entry is a function with a name.
186        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        // A class is a span too: models are classes, and the reading loop
220        // needs to fetch a model by name.
221        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            // `this.handle = (req, res) => {}` and `exports.f = function () {}`
306            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
318/// Every candidate node of a JavaScript or TypeScript file, in source order.
319/// A file oxc cannot parse yields an error rather than a partial list, so the
320/// caller can fall back to patterns and say so.
321pub fn nodes(file: &Path, source: &str) -> Result<Vec<Node>, String> {
322    Ok(parse(file, source)?.0)
323}
324
325/// The functions and imports of a JavaScript or TypeScript file.
326pub 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 = crate::js_instrumenter::project_source_type(file)?;
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    /// A fake key in a live-key shape, assembled so no source line holds the
354    /// whole pattern a push-protection scanner looks for.
355    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}