diff --git a/.gitignore b/.gitignore index cd8b71e..24e5e5f 100644 --- a/.gitignore +++ b/.gitignore @@ -74,6 +74,9 @@ release/ # External benchmark corpus (separate/large repo, cloned locally) /fusion-benchmarks/ +# Local linux/mac export from `./build_all_platforms.sh` (binary + copied rules) +/sighthound_release/ + # Generated benchmark scan artifacts (reproducible via bench/run_bench.py) /bench/results/ /bench/*.json diff --git a/build.rs b/build.rs new file mode 100644 index 0000000..0f6452a --- /dev/null +++ b/build.rs @@ -0,0 +1,24 @@ +fn main() { + emit_rerun("rules"); +} + +fn emit_rerun(root: &str) { + let mut pending = vec![std::path::PathBuf::from(root)]; + while let Some(path) = pending.pop() { + println!("cargo:rerun-if-changed={}", path.display()); + let entries = std::fs::read_dir(&path).unwrap_or_else(|error| { + panic!("failed to read directory '{}': {error}", path.display()) + }); + for entry in entries { + let entry = entry.unwrap_or_else(|error| { + panic!("failed to read directory entry in '{}': {error}", path.display()) + }); + let child = entry.path(); + if child.is_dir() { + pending.push(child); + } else { + println!("cargo:rerun-if-changed={}", child.display()); + } + } + } +} diff --git a/rules/backend_javascript/backend_security.ron b/rules/backend_javascript/backend_security.ron index 5e9375a..5fbfedc 100644 --- a/rules/backend_javascript/backend_security.ron +++ b/rules/backend_javascript/backend_security.ron @@ -474,25 +474,14 @@ ]), sinks: Some([ - // Direct code execution + // Eval injection only. Template engines / dynamic require are not CWE-95. "eval", "Function", "setTimeout", "setInterval", - - // VM module "vm.runInThisContext", "vm.runInNewContext", - "vm.runInContext", - - // Dynamic requires - "require", - "import", - - // Template engines (unsafe usage) - "ejs.render", - "handlebars.compile", - "mustache.render" + "vm.runInContext" ]), sanitizers: Some([ @@ -525,6 +514,49 @@ )), tags: Some(["code-injection", "cwe-94", "server-side", "backend"]) + ), + + ( + id: Some("js-server-template-injection-001"), + name: Some("Server-side template injection"), + category: Some("code-injection"), + mode: "taint", + sources: Some([ + "req.query.*", + "req.params.*", + "req.body.*", + "request.query.*", + "request.params.*", + "request.body.*", + "ctx.query.*", + "ctx.params.*", + "req.headers.*" + ]), + sinks: Some([ + "ejs.render", + "handlebars.compile", + "mustache.render" + ]), + sanitizers: Some([ + "validateCode", + "sanitizeCode", + "isValidCode" + ]), + finding_type: Some("Code Injection"), + severity: Some("Critical"), + confidence: Some("High"), + cwe_id: Some("cwe-94"), + description: Some("User-controlled request data is passed to a template engine"), + file_types: Some(( + extensions: Some([".js", ".jsx", ".ts", ".tsx"]), + exclude_patterns: Some([ + "*.min.js", "*.min.jsx", "*.min.ts", "*.min.tsx", + "*/public/*", "*/static/*", "*/assets/*", "*/dist/*", + "*/build/*", "*/frontend/*", "*/client/*", + "*/node_modules/*", "*/lib/*", "*/vendor/*" + ]) + )), + tags: Some(["code-injection", "cwe-94", "ssti", "server-side", "backend"]) ) ] ) \ No newline at end of file diff --git a/rules/html/django.ron b/rules/html/django.ron new file mode 100644 index 0000000..492c616 --- /dev/null +++ b/rules/html/django.ron @@ -0,0 +1,31 @@ +( + rules: [ + // Autoescaped `{{ var }}` is not XSS. Flag `|safe`/`|mark_safe` only on + // request data inside one `{{ ... }}` (`[^}]*` cannot join adjacent + // expressions). Dummy `=` names pass the `|safe` / `|mark_safe` / `{{` + // call-name prefilter (`django-{{=` contains `{{` but is not template + // syntax). Spaced filters (`| safe`) resolve as `{{`. + ( + id: Some("html-django-safe-request-xss"), + name: Some("Django |safe on request data"), + category: Some("xss"), + mode: "search", + patterns: Some([ + "django-safe-filter-sink=", + "|safe=", + "|mark_safe=", + "django-{{=", + "regex:\\{\\{[^}]*request\\.(GET|POST|COOKIES)[^}]*\\|\\s*(mark_)?safe[^}]*\\}\\}" + ]), + finding_type: Some("Cross-Site Scripting"), + severity: Some("High"), + confidence: Some("High"), + cwe_id: Some("cwe-79"), + description: Some("Request data passed through |safe / |mark_safe disables autoescape and is reflected XSS"), + file_types: Some(( + extensions: Some([".html", ".htm"]) + )), + tags: Some(["xss", "django", "cwe-79"]) + ) + ] +) diff --git a/rules/html/xss.ron b/rules/html/xss.ron index a100fbc..981197c 100644 --- a/rules/html/xss.ron +++ b/rules/html/xss.ron @@ -20,6 +20,11 @@ // All real sinks require a template literal (backtick) sink target, i.e. // dynamic/interpolated HTML, which avoids flagging assignments of static // string constants. + // + // Not sinks: HTMX `hx-swap="innerHTML"` (swap strategy, not reflected XSS), + // Django/Jinja default `{{ title }}` autoescape, or `json_script` / + // `\n", + ); + let findings = scan_language_unified_with_rules( + staging.path(), + "html", + Rules::load_from_directory("rules/html/").expect("load html rules"), + ); + let xss: Vec<_> = findings.iter().filter(|f| is_xss(f)).cloned().collect(); + assert!( + xss.iter().any(|f| f.snippet.contains("outerHTML")), + "outerHTML write next to textContent escape must stay XSS, got: {:?}", + findings + .iter() + .map(|f| (f.line, f.finding_type.as_str(), f.snippet.as_str())) + .collect::>() + ); +} + +#[test] +#[cfg(feature = "javascript")] +fn request_body_to_ejs_render_is_cwe94_not_cwe95() { + let staging = stage_dir(); + write_staged_file( + staging.path(), + "render.js", + "function render(req) {\n const template = req.body.template;\n ejs.render(template);\n}\n", + ); + let findings = scan_language_unified_with_rules( + staging.path(), + "javascript", + Rules::load_from_directory("rules/backend_javascript/").expect("load backend js rules"), + ); + assert!( + findings + .iter() + .any(|f| f.cwe_id.as_deref() == Some("cwe-94") && f.snippet.contains("ejs.render")), + "ejs.render(req.body) must be CWE-94 SSTI, got: {:?}", + findings + .iter() + .map(|f| (f.line, f.cwe_id.as_deref(), f.snippet.as_str())) + .collect::>() + ); + assert!( + findings.iter().all(|f| f.cwe_id.as_deref() != Some("cwe-95")), + "SSTI must not be labeled CWE-95, got: {:?}", + findings + .iter() + .map(|f| (f.line, f.cwe_id.as_deref(), f.snippet.as_str())) + .collect::>() + ); +} diff --git a/tests/strictness/main.rs b/tests/strictness/main.rs index a44825d..1bdcd08 100644 --- a/tests/strictness/main.rs +++ b/tests/strictness/main.rs @@ -8,6 +8,7 @@ mod helpers; mod ast_provenance; mod cross_file_taint; +mod cwe95_xss_sink_precision; mod django_security; mod false_positive_regressions; mod html_security; diff --git a/tests/test_files/django/fixtures/template_xss.html b/tests/test_files/django/fixtures/template_xss.html new file mode 100644 index 0000000..8df17f0 --- /dev/null +++ b/tests/test_files/django/fixtures/template_xss.html @@ -0,0 +1,22 @@ + + + + {{ title }} + {{ bootstrap|json_script:"bootstrap" }} + + +

{{ title }}

+

{{ note }}

+ + +
{{ request.GET.q|safe }}
+ +
{{ request.COOKIES.sid| mark_safe }}
+ + + +
{{ request.GET.q }} {{ title|safe }}
+ +

{{= heading }}

+ + diff --git a/tests/test_files/javascript/cwe95_xss_sink_precision.js b/tests/test_files/javascript/cwe95_xss_sink_precision.js new file mode 100644 index 0000000..5e6b377 --- /dev/null +++ b/tests/test_files/javascript/cwe95_xss_sink_precision.js @@ -0,0 +1,125 @@ +// CWE-95 vs CWE-79 sink precision. Eval-family sinks are CWE-95. +// HTML writes, HTMX, DOMPurify, and parse-only template helpers are not. + +// TP: eval / Function / setTimeout(string) / setInterval(string) / vm.runIn* → CWE-95 +function evalUser(userInput) { + eval(userInput); +} + +function functionUser(userInput) { + const fn = new Function(userInput); + return fn(); +} + +function timeoutUser(userInput) { + setTimeout('alert(' + userInput, 1000); +} + +function intervalUser(userInput) { + setInterval('alert(' + userInput, 1000); +} + +function evalFromHash() { + eval(location.hash); +} + +function vmUser(userInput) { + vm.runInNewContext(userInput); +} + +// TP XSS, not CWE-95: tainted HTML write +function xssFromHash() { + document.body.innerHTML = location.hash; +} + +// TN CWE-95: sanitized HTML write +function sanitizedInnerHtml(userInput) { + const el = document.createElement('div'); + el.innerHTML = DOMPurify.sanitize(userInput); +} + +// TN CWE-95: parse-only helper (doghouse issues_table.js shape) +function htmlToElement(html) { + const template = document.createElement('template'); + template.innerHTML = html; + return template.content.firstElementChild; +} + +// TN CWE-79 / CWE-95: textContent → escaped innerHTML helper +function escapeHtml(text) { + const div = document.createElement('div'); + div.textContent = text; + return div.innerHTML; +} + +// TN CWE-95: HTMX trigger is not eval +function applyFilters() { + htmx.trigger('#applyFilterButton', 'click'); +} + +// TN CWE-95: Bootstrap / jQuery HTML without eval +function renderPanel(html) { + $('.collapse').html(html); + new bootstrap.Collapse(document.getElementById('panel')); +} + +// TN CWE-95: setTimeout/setInterval function callbacks are not eval +function delayedPaint(userInput) { + setTimeout(function () { + document.getElementById('out').textContent = userInput; + }, 0); +} + +function delayedInterval(userInput) { + setInterval(function () { + document.getElementById('out').textContent = userInput; + }, 0); +} + +function delayedArrow(userInput) { + setTimeout(x => { + document.getElementById('out').textContent = userInput; + }, 0); +} + +function delayedParenArrow(userInput) { + setTimeout(() => { + document.getElementById('out').textContent = userInput; + }, 0); +} + +// TN CWE-95: identifier callback is a function reference, not string eval +function timeoutHandler(handler) { + setTimeout(handler, 1000); +} + +// TN CWE-95: constant timer string is not attacker-controlled +function timeoutConst() { + setTimeout('refreshUI', 100); +} + +// TP CWE-95: bare Function() concatenation (no `new`) +function functionConcat(userInput) { + Function('return ' + userInput); +} + +// TP CWE-95: qualified window timers with string concat +function windowTimeoutUser(userInput) { + window.setTimeout('alert(' + userInput, 1000); +} + +function windowIntervalUser(userInput) { + window.setInterval('alert(' + userInput, 1000); +} + +// TP: logical assign of a tainted intermediate (DOM sink) +function innerHtmlLogicalAssign() { + const html = window.location.hash; + document.body.innerHTML ||= html; +} + +// TP CWE-95: callback timer must not hide a later string-eval timer +function mixedTimers(userInput) { + setTimeout(function () { paint(); }, 0); + setTimeout('alert(' + userInput, 1000); +}