Skip to content

Repository files navigation

Vuer

A security-focused, AST-based static analyser for Vue.js Single File Components, written in Rust. Inspired by zizmor, Ruff, Clippy, Semgrep, and CodeQL.

Vuer is not an ESLint plugin. It parses each .vue file with its own template parser and oxc_parser for the script block, then runs every enabled rule against the resulting AST.

Goals

  1. Accuracy over convenience - low false positives, low false negatives, actionable remediation.
  2. AST analysis over string matching - rules consume structure, not text.
  3. Performance over abstraction - arena allocation, borrowed data, zero-copy parsing where possible.
  4. Developer experience over cleverness - clear diagnostics, stable rule ids, and SARIF output for CI integration.

Features

  • Security rules: v-html, innerHTML, document.write, eval, new Function, dangerous URL schemes, open-redirect, localStorage token storage, missing sandbox on iframe, postMessage with wildcard targetOrigin, window.open with _blank and no noopener, fetch without an AbortSignal.
  • Vue best practices: missing :key on v-for, inline styles, watch callbacks that may leak.
  • Severity model: Critical / High / Medium / Low / Info, with a clean SARIF mapping.
  • Taint analysis: a single-pass intra-file taint engine (Phase 2) tracks untrusted data from sources (localStorage, fetch, useRoute, defineProps props, event, ...) through propagation to sinks (v-html, .innerHTML, dynamic :src, location writes). Taint-aware rules report flowstaint from localStorage.getItem (line 2) reaches v-html binding via userInput — and stay silent on provably clean bindings, cutting false positives without losing the unsafe path.
  • Output formats: pretty, JSON, minimal, SARIF 2.1.0 (GitHub Code Scanning / GitLab Security Reports ready).
  • Category and severity filters to scope runs to one area or to only fail the build on high-severity findings.
  • Fast: Rust-powered, no runtime overhead, .gitignore aware.

Installation

cargo install --path .

Or build from source:

cargo build --release

The binary is at target/release/vuer.

Usage

Scan a single file

vuer src/components/MyComponent.vue

Scan a directory

vuer src/

This recursively scans all .vue files (respecting .gitignore).

List available rules

vuer --list

Run specific rules

vuer --rules no-v-html,no-dynamic-bind-src src/
vuer --rules vue/security/no-v-html src/

You can mix short names and stable ids.

Filter by category or severity

vuer --category security src/
vuer --min-severity high src/

Output formats

# Pretty (default) - coloured diagnostics
vuer src/

# JSON - one structured record per finding
vuer --format json src/

# SARIF 2.1.0 - GitHub Code Scanning / GitLab
vuer --format sarif src/ > results.sarif

# Minimal - one line per violation
vuer --format minimal src/

CI integration

Fail with exit code 1 if any violation is found:

vuer --deny-warnings src/

Or only on at least high severity:

vuer --min-severity high --deny-warnings src/

Suppressing individual findings

Use a vuer-ignore[...] comment on the same line (or the line above) the finding to silence it. Both the short rule name (no-v-html) and the full stable id (vue/security/no-v-html) are accepted. The colon form (vuer: ignore[...]) is also recognised.

<template>
  <div v-html="trusted">accepted</div>
  <!-- vuer-ignore[no-v-html] -->
  <div v-html="trusted">silenced by the previous-line comment</div>
</template>

<script setup>
el.innerHTML = userInput  // vuer-ignore[no-inner-html]
</script>

Use --no-ignores to disable every inline suppression and report what the linter would otherwise silence. This is the right flag for CI runs that want to see the raw signal.

Output

The pretty output is rustc-style: each finding is a coloured error[rule-id] block with an --> file:line:col header, a snippet with carets under the violation, and a = help: line with the remediation advice. The summary at the bottom counts findings by severity, each coloured to match the finding (critical = magenta, high = red, medium = yellow, low = cyan, info = green).

error[vue/security/no-v-html]: Unsafe `v-html` directive renders untrusted HTML
 --> src/components/Post.vue:8:10
  |
8 |     <div v-html="user.bio">Bio</div>
  |          ^^^^^^^^^^^^^^^^^^^ here
  |
  = help: Rendering untrusted HTML can execute arbitrary JavaScript. Sanitise the input
          with DOMPurify (or an equivalent library), or use `v-text` / `{{ }}` interpolation.

13 violations: 3 critical, 7 high, 2 medium, 1 low

Colours are auto-detected from the terminal. They are stripped when output is piped to a file, when stdout is not a TTY, or when NO_COLOR=1 is set in the environment. Set FORCE_COLOR=1 to force them on for CI logs.

Configuration

Drop a .vuerc.yml (or vuer.yml) at the project root to set project-wide defaults. The first one found walking up from the scan path is loaded.

# .vuerc.yml — every field is optional.

# Disable rules by short name or full stable id.
disable:
  - no-v-html
  - vue/security/no-eval

# Only show findings at this severity or higher.
# Allowed: info, low, medium, high, critical
min-severity: medium

# Only show findings whose category is in this list.
# Allowed: security, best-practice, performance, accessibility, architecture
category:
  - security
  - best-practice

CLI flags layer on top of the config: --rules is an enable-list that further narrows the result, --min-severity and --category override the config when set, and --no-config skips discovery entirely (handy for hermetic CI runs).

Unknown keys are rejected (deny_unknown_fields), so a typo like min-sev: high will print a parse warning and fall back to the default config. The run is never blocked by a broken config file.

Performance

vuer walks the directory tree single-threaded (the work is just path filtering + .gitignore checks) and then fans out the per-file parsing across the rayon thread pool. On a large Vue monorepo this gives a near-linear speedup with the number of cores.

Documentation

Full reference documentation lives under docs/:

  • Installation — install via crates.io, GitHub, pre-built binaries, or from source; editor and CI integration.
  • Usage — every CLI flag, output format, suppression mechanism, and recommended workflows.
  • Audits — one section per rule, with vulnerable and safe examples plus remediation advice.
  • Upgrading — the oxc bump / MSRV discipline.

Available rules

Rule id Severity Category Description
vue/security/no-v-html Critical security Disallow v-html when the binding may carry untrusted data
vue/security/no-inner-html Critical security Disallow el.innerHTML = ... when the value may be untrusted
vue/security/no-document-write High security Disallow document.write / writeln
vue/security/no-eval Critical security Disallow eval, new Function, string setTimeout
vue/security/no-dangerous-url Critical security Disallow javascript: / data:text/html / vbscript: URLs
vue/security/no-open-redirect High security Disallow location.* writes of untrusted values
vue/security/no-unsafe-localstorage High security Disallow auth-looking values in localStorage
vue/security/no-unsafe-iframe Medium security Disallow <iframe> without sandbox
vue/security/no-dynamic-bind-src High security Disallow :src bindings of untrusted values
vue/security/no-postmessage-wildcard High security Disallow postMessage(..., '*')
vue/security/no-window-open-blank-noopener High security Require noopener on window.open(..., '_blank', ...)
vue/security/no-fetch-without-timeout High security Require an AbortSignal on fetch
vue/best-practice/no-inline-style Low best-practice Disallow inline style
vue/best-practice/no-watch-with-callback Low best-practice Warn on watch(src, cb) without disposal
vue/best-practice/v-for-missing-key Medium best-practice Require :key on v-for
vue/performance/no-v-if-with-v-for Medium performance Disallow v-if with v-for on the same element
vue/performance/no-deep-watch-without-handler Low performance Warn on watch(..., { deep: true })
vue/performance/no-reactive-in-v-for Low performance Disallow reactive creation in loop bodies
vue/performance/no-large-list-without-virtualization Low performance Heuristic: large lists need virtual scrolling
vue/accessibility/no-img-without-alt Medium accessibility Require alt on <img>
vue/accessibility/no-click-without-role-keyboard Medium accessibility Require role + keyboard on @click of non-interactive elements
vue/accessibility/no-form-without-label Medium accessibility Require a label on form fields
vue/accessibility/no-button-without-type Low accessibility Require type on <button>
vue/architecture/no-side-effect-in-computed Medium architecture Disallow side effects in computed
vue/architecture/no-mutation-of-props Medium architecture Disallow defineProps writes
vue/architecture/no-async-setup-without-error-boundary Low architecture Heuristic: async setup() needs <Suspense>

Architecture

.vue file
    |
    v
SFC extraction (template / script / style)
    |
    +-- template  -> native recursive-descent parser -> TemplateRoot
    |                                                          |
    |                                                          v
    |                                                    visitor + rules
    |
    +-- script    -> oxc_parser (oxc_ast) -> Program
                                                            |
                                                            v
                                                      visitor + rules

Key design decisions:

  • Taint-aware, not just syntactic. After parsing, one taint pass annotates every expression in the file (script + template bindings); rules that query it report "this pattern carries untrusted data" with a source→sink flow instead of just "this pattern exists".
  • No regex in any rule. The only place strings are read is the SFC block extractor; from then on everything is structural.
  • No unwrap(), expect(), or panic!() in production code. Errors in the SFC extractor and the parsers are surfaced alongside the parsed AST; rules that fail to apply skip the file and report zero violations. The template parser is additionally gated in CI against ever adding one.
  • A parse failure degrades to "needs review", never "clean". When a <template> block does not parse cleanly, the CLI warns per error (file, byte offset, message) and counts the malformed files in the summary; --deny-warnings fails the run. Machine formats on stdout (JSON/SARIF) are untouched — the warning goes to stderr.
  • All rules are independent and deterministic. They take an immutable ScanContext and return a Vec<Box<dyn Diagnostic>>. Running the same file twice produces the same output.
  • Spans are absolute - rules produce diagnostics pointing at the original file, not at the trimmed template body. A property test re-slices the source by every node's span and asserts it equals the node's text (see tests/offset_integrity.rs).

Scope: <style> blocks

<style> blocks are extracted into ScanContext::style_blocks so future rules can inspect them, but CSS analysis is out of scope for v1. In particular, CSS injection via scoped :deep() selectors fed by dynamic values is documented, not detected: it requires CSS-level data flow that the Phase 1/2 taint model does not cover. The extractor's Style arm is therefore used (extraction) but no rule consumes it yet.

Layout

src/
  main.rs               # CLI (clap)
  lib.rs                # module root
  context.rs            # ScanContext, ScriptLang
  scanner.rs            # file walking, Violation
  severity.rs           # Critical/High/Medium/Low/Info
  rule_id.rs            # stable string id
  parser/
    mod.rs              # SFC extraction
    template/
      ast.rs            # TemplateRoot data model
      parser.rs         # recursive-descent template parser
      mod.rs            # public re-exports
    script.rs           # oxc_parser wrapper + callee_path / is_call_named helpers
  rules/
    mod.rs              # Rule trait, Category, RuleRegistry
    no_v_html.rs
    no_inner_html.rs
    no_document_write.rs
    no_eval.rs
    no_dangerous_url.rs
    no_open_redirect.rs
    no_unsafe_localstorage.rs
    no_unsafe_iframe.rs
    no_dynamic_bind.rs
    no_inline_styles.rs
    no_watch_with_callback.rs
    v_for_missing_key.rs
    no_v_if_with_v_for.rs       # performance
    no_deep_watch_without_handler.rs
    no_reactive_in_v_for.rs
    no_large_list_without_virtualization.rs
    no_img_without_alt.rs       # accessibility
    no_click_without_role_keyboard.rs
    no_form_without_label.rs
    no_button_without_type.rs
    no_side_effect_in_computed.rs  # architecture
    no_mutation_of_props.rs
    no_async_setup_without_error_boundary.rs
  visitor/
    mod.rs              # walk / for_each_element
  report/
    mod.rs              # output formats
    sarif.rs            # SARIF 2.1.0 serializer
tests/
  integration.rs        # end-to-end tests against fixture files
  fixtures/             # clean / vulnerable Vue files

Adding a new rule

  1. Create src/rules/your_rule.rs with a diagnostic struct and a Rule impl.
  2. Pick the Category and Severity.
  3. Register the rule in src/rules/mod.rs.
  4. Add the rule id to rule_meta in src/report/sarif.rs so the SARIF output picks up the description.
  5. Add a clean, vulnerable, and edge-case fixture (or extend an existing one).
  6. Add a unit test and, where useful, an integration test.

Development

cargo build              # debug build
cargo build --release    # release build
cargo test               # unit + integration
cargo run -- --list      # see all rules
cargo run -- tests/      # scan the fixture files

License

MIT

About

A security-focused, AST-based static analyser for Vue.js Single File Components

Topics

Resources

Stars

2 stars

Watchers

0 watching

Forks

Releases

Packages

Used by

Contributors

Languages