Clear JSX href taint frontier
ober
a928d72692d0f68fe54d416d36a39fd6409a5d3d
--- a/HANDOFF_OPUS_4_8.md +++ b/HANDOFF_OPUS_4_8.md @@ -1,10 +1,10 @@ # Opus 4.8 Handoff: jerboa-semgrep Semgrep Parity -Date: 2026-05-29 01:56 MDT +Date: 2026-05-29 02:12 MDT Workspace: `/Users/user/mine/jerboa-semgrep` Sibling upstream Semgrep checkout: `/Users/user/mine/semgrep` Packaged Semgrep oracle: `/Users/user/.local/bin/semgrep` -Current pre-handoff HEAD: `efdf406 Clear parameter source taint frontier` +Current pre-handoff HEAD: `d767d71 Clear method sequence propagator frontier` The user wants this project carried forward until the pure Jerboa port reaches Semgrep parity. Continue from this state. Do not restart broad discovery from @@ -52,7 +52,7 @@ make test Result: ```text -204 tests, 204 passed, 0 failed +205 tests, 205 passed, 0 failed ``` Local oracle: @@ -67,10 +67,10 @@ Result: oracle: 42 passed, 0 failed ``` -Focused `taint_propagator2` case: +Focused `taint_react` case: ```sh -SEMGREP_CURRENT=/Users/user/.local/bin/semgrep CASE_REGEX='^taint_propagator2$' LIST_MISMATCHES=1 MAX_DIFFS=5 tests/oracle/upstream-sweep.sh +SEMGREP_CURRENT=/Users/user/.local/bin/semgrep CASE_REGEX='^taint_react$' LIST_MISMATCHES=1 MAX_DIFFS=80 tests/oracle/upstream-sweep.sh ``` Result: @@ -82,17 +82,17 @@ upstream-sweep: 1 passed, 0 mismatched, 0 jerboa errors, 0 current errors, 1 com Nearby taint guardrail: ```sh -SEMGREP_CURRENT=/Users/user/.local/bin/semgrep CASE_REGEX='^(taint_propagator2|taint_param_source1|taint_param_source2|taint_param_source3|taint_typestate|taint_spread_record_big)$' LIST_MISMATCHES=1 MAX_DIFFS=260 tests/oracle/upstream-sweep.sh +SEMGREP_CURRENT=/Users/user/.local/bin/semgrep CASE_REGEX='^(taint_react|taint_safe_comparisons|taint_spread_record_big|taint_param_source3|taint_propagator2)$' LIST_MISMATCHES=1 MAX_DIFFS=260 tests/oracle/upstream-sweep.sh ``` Result: ```text -upstream-sweep: 5 passed, 1 mismatched, 0 jerboa errors, 0 current errors, 6 compared +upstream-sweep: 3 passed, 2 mismatched, 0 jerboa errors, 0 current errors, 5 compared ``` -The remaining mismatch in that guardrail is the known frontier -`taint_spread_record_big`. +The two remaining mismatches in that guardrail are `taint_safe_comparisons` +and the known wider frontier `taint_spread_record_big`. Full upstream sweep: @@ -103,7 +103,7 @@ SEMGREP_CURRENT=/Users/user/.local/bin/semgrep LIST_MISMATCHES=1 MAX_DIFFS=0 tes Result: ```text -upstream-sweep: 227 passed, 12 mismatched, 0 jerboa errors, 2 current errors, 241 compared +upstream-sweep: 228 passed, 11 mismatched, 0 jerboa errors, 2 current errors, 241 compared ``` The two current errors are packaged-Semgrep oracle errors, not Jerboa scanner @@ -112,53 +112,54 @@ the command above intentionally ran the full sorted 241-case sweep. ## What Changed In This Checkpoint -This checkpoint clears upstream `taint_propagator2`, reducing the full sweep +This checkpoint clears upstream `taint_react`, reducing the full sweep frontier from: ```text -226 passed, 13 mismatched, 0 jerboa errors, 2 current errors +227 passed, 12 mismatched, 0 jerboa errors, 2 current errors ``` to: ```text -227 passed, 12 mismatched, 0 jerboa errors, 2 current errors +228 passed, 11 mismatched, 0 jerboa errors, 2 current errors ``` Implementation changes in `src/semgrep/scan.ss`: -- Added a narrow Python method-sequence scanner for patterns shaped like: +- Added a JavaScript/JSX opening-tag attribute scanner for patterns shaped + like: ```text - $Y.f($X) - ... - $Y.g($Z) + <SourceLink ... href=$HREF .../> ``` -- The scanner emits one finding from the first method call through the second - method call and binds `$Y`, `$X`, and `$Z`. This lets the existing taint - propagator engine apply `from: $X` / `to: $Z`. -- The implementation is intentionally scoped to the observed upstream pattern: - simple identifier receiver, simple one-argument calls, matching receiver - text, and the `.f(...)` / `.g(...)` method names. -- This avoids changing general structural matching for all multiline Python - statement sequences while still moving the upstream parity frontier. +- The scanner parses the JSX tag name, attribute name, and metavariable from + the pattern, scans target opening tags, and binds the expression inside + braced JSX attributes such as `href={rule.source_uri}`. +- It returns a finding covering the opening tag so existing + `focus-metavariable` logic can narrow the report to `$HREF`. Existing taint + sanitizer logic then blocks safe `href={validateUrl(...)}` cases. +- The implementation is intentionally scoped to JSX opening tags with braced + attribute expressions. It does not attempt to become a general JSX structural + matcher. New smoke coverage in `tests/smoke.ss`: ```text -scan Python taint propagator method sequence +scan JavaScript taint focused JSX href attribute ``` ## Recently Cleared Cases -Clean in the two most recent checkpoints: +Clean in the three most recent checkpoints: ```text taint_param_source1 taint_param_source2 taint_param_source3 taint_propagator2 +taint_react ``` Relevant upstream files: @@ -172,24 +173,34 @@ Relevant upstream files: /Users/user/mine/semgrep/tests/rules/taint_param_source3.js /Users/user/mine/semgrep/tests/rules/taint_propagator2.yaml /Users/user/mine/semgrep/tests/rules/taint_propagator2.py +/Users/user/mine/semgrep/tests/rules/taint_react.yaml +/Users/user/mine/semgrep/tests/rules/taint_react.js ``` -Important isolation finding for `taint_propagator2`: +Important isolation finding for `taint_react`: + +- `pattern: rule` matched the relevant source tokens, including + `rule.source_uri`. +- `pattern: url` matched the callback parameter and uses. +- `pattern: validateUrl(...)` matched the two safe sanitizer calls. +- The sink formula returned zero before this checkpoint: + + ```yaml + patterns: + - pattern-inside: | + (<SourceLink ... href=$HREF .../>) + - focus-metavariable: $HREF + ``` -- `pattern: user_input` matched line 2. -- Implicit assignment `x = user_input` matched and can taint `x`. -- The sink formula `sink($SINK)` with focus matched `z` at line 7. -- The custom propagator pattern itself returned zero before this checkpoint. - The issue was not assignment kill or sink focus; it was missing structural - support for the multiline method-call sequence. +- The missing piece was JSX attribute binding for `$HREF`; the taint engine and + sanitizer blocking were already sufficient once that binding existed. ## Current Full-Sweep Frontier The current full sorted upstream sweep compared 241 rule/target pairs and has -these 12 mismatches: +these 11 mismatches: ```text -taint_react taint_safe_comparisons taint_spread_record_big taint_unify_mvars @@ -209,23 +220,23 @@ Use this command to refresh the full list: SEMGREP_CURRENT=/Users/user/.local/bin/semgrep LIST_MISMATCHES=1 MAX_DIFFS=0 tests/oracle/upstream-sweep.sh ``` -## Recommended Next Target: taint_react +## Recommended Next Target: taint_safe_comparisons -The next sorted frontier is `taint_react`. +The next sorted frontier is `taint_safe_comparisons`. Focused command: ```sh -SEMGREP_CURRENT=/Users/user/.local/bin/semgrep CASE_REGEX='^taint_react$' LIST_MISMATCHES=1 MAX_DIFFS=1 tests/oracle/upstream-sweep.sh +SEMGREP_CURRENT=/Users/user/.local/bin/semgrep CASE_REGEX='^taint_safe_comparisons$' LIST_MISMATCHES=1 MAX_DIFFS=80 tests/oracle/upstream-sweep.sh ``` Current result: ```text -MISMATCH taint_react -@@ -1,2 +0,0 @@ --(finding "href-semgrep-app" ".../taint_react.js" 50 19 1301 50 34 1316 "WARNING" "Found logging of sensitive data" "") --(finding "href-semgrep-app" ".../taint_react.js" 61 75 1627 61 78 1630 "WARNING" "Found logging of sensitive data" "") +MISMATCH taint_safe_comparisons +@@ -1 +1,2 @@ + (finding "tainting" ".../taint_safe_comparisons.py" 3 1 32 3 8 39 "ERROR" "Test" "") ++(finding "tainting" ".../taint_safe_comparisons.py" 6 1 75 6 8 82 "ERROR" "Test" "") upstream-sweep: 0 passed, 1 mismatched, 0 jerboa errors, 0 current errors, 1 compared ``` @@ -233,43 +244,39 @@ Rule: ```yaml rules: -- id: href-semgrep-app +- id: tainting mode: taint + options: + taint_assume_safe_comparisons: true pattern-sources: - - pattern: rule - - pattern: url - pattern-sanitizers: - - pattern: validateUrl(...) + - pattern: | + "tainted" pattern-sinks: - - patterns: - - pattern-inside: | - (<SourceLink ... href=$HREF .../>) - - focus-metavariable: $HREF - languages: [js] + - pattern: sink(...) + languages: [python] ``` -Target highlights: +Target: -```jsx -href={validateUrl(rule.source_uri)} // ok -href={validateUrl(url)} // ok -href={rule.source_uri} // expected finding on rule.source_uri -<SourceLink ... href={url}> // expected finding on url +```python +x = "tainted" +#ruleid: tainting +sink(x) +y = x != "something" +#ok: tainting +sink(y) ``` Likely fix area: -- JSX `pattern-inside` and focus handling for attributes. The sink formula is - only a `pattern-inside` with `href=$HREF` plus `focus-metavariable`, so the - scanner needs to produce a focused `$HREF` finding from JSX attributes. -- Sanitizer `validateUrl(...)` already exists in the rule; verify the source - side first, then isolate the sink formula separately: - - ```sh - # Source-only probes: pattern rule, url - # Sink-only probe: patterns with the SourceLink pattern-inside and focus - # Sanitizer-only probe: pattern validateUrl(...) - ``` +- The option is named `taint_assume_safe_comparisons`; current code has + `taint-assume-safe-booleans?` and blocks comparisons only when + `taint_assume_safe_booleans` is enabled. +- Implement option recognition for `taint_assume_safe_comparisons` and ensure + the assignment `y = x != "something"` either does not propagate taint to `y` + or is treated as safe when `sink(y)` is evaluated. +- Add a smoke test that keeps `sink(x)` positive but suppresses `sink(y)` for + the comparison-derived boolean. ## Useful Commands @@ -325,10 +332,10 @@ SCM `function ...` lines are recognized as simple scopes. This avoids sibling leaks without breaking nested callback taint cases already covered by smoke tests. -- After touching JSX sink logic, run at minimum: +- After touching comparison-taint logic, run at minimum: ```sh make test - SEMGREP_CURRENT=/Users/user/.local/bin/semgrep CASE_REGEX='^(taint_react|taint_spread_record_big|taint_param_source3)$' LIST_MISMATCHES=1 MAX_DIFFS=260 tests/oracle/upstream-sweep.sh + SEMGREP_CURRENT=/Users/user/.local/bin/semgrep CASE_REGEX='^(taint_safe_comparisons|taint_react|taint_param_source3|taint_propagator2)$' LIST_MISMATCHES=1 MAX_DIFFS=260 tests/oracle/upstream-sweep.sh SEMGREP_CURRENT=/Users/user/.local/bin/semgrep make oracle ``` --- a/lib/semgrep/scan.sls +++ b/lib/semgrep/scan.sls @@ -2593,6 +2593,261 @@ pattern) (scan-javascript-jsx-style-pattern-with-bindings rule path source pattern '())) + (def (jsx-name-char? ch) + (or (char-alphabetic? ch) + (char-numeric? ch) + (char=? ch #\_) + (char=? ch #\$) + (char=? ch #\-) + (char=? ch #\:) + (char=? ch #\.))) + (def (jsx-name-range-at source start) + (let ([len (string-length source)]) + (and (< start len) + (jsx-name-char? (string-ref source start)) + (let loop ([i (+ start 1)]) + (if (and (< i len) (jsx-name-char? (string-ref source i))) + (loop (+ i 1)) + (cons start i)))))) + (def (jsx-name-boundary? source index) + (or (<= index 0) + (>= index (string-length source)) + (not (jsx-name-char? (string-ref source index))))) + (def (skip-whitespace-backward-exclusive source start end) + (let loop ([i (- end 1)]) + (cond + [(< i start) start] + [(char-whitespace? (string-ref source i)) (loop (- i 1))] + [else (+ i 1)]))) + (def (trim-source-range source start end) + (let ([trimmed-start (skip-whitespace source start)]) + (cons + trimmed-start + (skip-whitespace-backward-exclusive + source + trimmed-start + end)))) + (def (jsx-metavariable-name-end pattern name-start) + (let ([len (string-length pattern)]) + (let loop ([i name-start]) + (if (and (< i len) + (let ([ch (string-ref pattern i)]) + (or (char-alphabetic? ch) + (char-numeric? ch) + (char=? ch #\_)))) + (loop (+ i 1)) + i)))) + (def (jsx-attribute-name-before-equals pattern eq-index) + (let ([end (skip-whitespace-backward-exclusive + pattern + 0 + eq-index)]) + (let loop ([i (- end 1)]) + (cond + [(< i 0) #f] + [(jsx-name-char? (string-ref pattern i)) (loop (- i 1))] + [else + (let ([start (+ i 1)]) + (and (< start end) (substring pattern start end)))])))) + (def (jsx-attribute-metavariable-spec-after + pattern + dollar-index) + (let* ([name-start (+ dollar-index 1)] + [name-end (jsx-metavariable-name-end pattern name-start)]) + (and (> name-end name-start) + (let ([eq-index (let loop ([i (- dollar-index 1)]) + (cond + [(< i 0) #f] + [(char-whitespace? + (string-ref pattern i)) + (loop (- i 1))] + [(char=? (string-ref pattern i) #\=) i] + [else #f]))]) + (and eq-index + (let ([attr (jsx-attribute-name-before-equals + pattern + eq-index)]) + (and attr + (list + (cons 'attribute attr) + (cons + 'metavariable + (substring + pattern + name-start + name-end)))))))))) + (def (jsx-pattern-tag-name pattern) + (let ([lt (string-find-substring pattern "<")]) + (and lt + (let* ([name-start (skip-whitespace pattern (+ lt 1))] + [range (jsx-name-range-at pattern name-start)]) + (and range (substring pattern (car range) (cdr range))))))) + (def (jsx-attribute-metavariable-pattern-spec pattern) + (let ([tag (jsx-pattern-tag-name pattern)]) + (and tag + (let loop ([start 0]) + (let ([dollar (string-find-substring-from + pattern + "$" + start)]) + (and dollar + (or (let ([spec (jsx-attribute-metavariable-spec-after + pattern + dollar)]) + (and spec (cons (cons 'tag tag) spec))) + (loop (+ dollar 1))))))))) + (def (jsx-opening-tag-end source start) + (let ([len (string-length source)]) + (let loop ([i start] + [brace-depth 0] + [state 'normal] + [escaped? #f]) + (cond + [(>= i len) #f] + [(eq? state 'string) + (let ([ch (string-ref source i)]) + (cond + [escaped? (loop (+ i 1) brace-depth state #f)] + [(char=? ch #\\) (loop (+ i 1) brace-depth state #t)] + [(char=? ch #\") (loop (+ i 1) brace-depth 'normal #f)] + [else (loop (+ i 1) brace-depth state #f)]))] + [(eq? state 'single) + (let ([ch (string-ref source i)]) + (cond + [escaped? (loop (+ i 1) brace-depth state #f)] + [(char=? ch #\\) (loop (+ i 1) brace-depth state #t)] + [(char=? ch #\') (loop (+ i 1) brace-depth 'normal #f)] + [else (loop (+ i 1) brace-depth state #f)]))] + [(eq? state 'template) + (let ([ch (string-ref source i)]) + (cond + [escaped? (loop (+ i 1) brace-depth state #f)] + [(char=? ch #\\) (loop (+ i 1) brace-depth state #t)] + [(char=? ch #\`) (loop (+ i 1) brace-depth 'normal #f)] + [else (loop (+ i 1) brace-depth state #f)]))] + [else + (let ([ch (string-ref source i)]) + (cond + [(char=? ch #\") (loop (+ i 1) brace-depth 'string #f)] + [(char=? ch #\') (loop (+ i 1) brace-depth 'single #f)] + [(char=? ch #\`) (loop (+ i 1) brace-depth 'template #f)] + [(char=? ch #\{) + (loop (+ i 1) (+ brace-depth 1) state #f)] + [(char=? ch #\}) + (loop (+ i 1) (max 0 (- brace-depth 1)) state #f)] + [(and (= brace-depth 0) (char=? ch #\>)) (+ i 1)] + [else (loop (+ i 1) brace-depth state #f)]))])))) + (def (jsx-braced-attribute-value-range + source + value-start + tag-end) + (and (< value-start tag-end) + (char=? (string-ref source value-start) #\{) + (let ([close (find-matching-close-brace + source + value-start)]) + (and close + (<= close tag-end) + (trim-source-range + source + (+ value-start 1) + (- close 1)))))) + (def (jsx-attribute-value-range + source + tag-start + tag-end + attribute) + (let ([attr-len (string-length attribute)]) + (let loop ([start tag-start]) + (let ([index (string-find-substring-from + source + attribute + start)]) + (and index + (< index tag-end) + (if (and (jsx-name-boundary? source (- index 1)) + (jsx-name-boundary? source (+ index attr-len))) + (let* ([after-name (skip-whitespace + source + (+ index attr-len))] + [value-start0 (and (< after-name tag-end) + (char=? + (string-ref + source + after-name) + #\=) + (skip-whitespace + source + (+ after-name 1)))] + [value-range (and value-start0 + (jsx-braced-attribute-value-range + source + value-start0 + tag-end))]) + (or value-range (loop (+ index attr-len)))) + (loop (+ index 1)))))))) + (def (scan-javascript-jsx-attribute-pattern-with-bindings rule path source pattern initial-bindings) + (let ([spec (jsx-attribute-metavariable-pattern-spec + pattern)]) + (and spec + (let* ([tag (alist-ref/default spec 'tag #f)] + [attribute (alist-ref/default spec 'attribute #f)] + [name (alist-ref/default spec 'metavariable #f)] + [needle (string-append "<" tag)] + [needle-len (string-length needle)] + [len (string-length source)]) + (let loop ([start 0] [acc '()]) + (let ([tag-start (string-find-substring-from + source + needle + start)]) + (if (not tag-start) + (reverse acc) + (let* ([tag-name-end (+ tag-start needle-len)] + [tag-end (and (jsx-name-boundary? + source + tag-name-end) + (jsx-opening-tag-end + source + tag-name-end))] + [value-range (and tag-end + (jsx-attribute-value-range + source + tag-name-end + tag-end + attribute))] + [binding-start (and value-range + (car value-range))] + [binding-end (and value-range + (cdr value-range))] + [bindings (and binding-start + binding-end + (< binding-start binding-end) + (merge-binding-list + initial-bindings + (list + (cons + name + (metavariable-binding-for-range + name + source + binding-start + binding-end)))))] + [finding (and bindings + (finding-for-range-with-bindings rule path source tag-start + tag-end bindings))] + [next (if tag-end + (max (+ tag-start 1) tag-end) + (+ tag-start needle-len))]) + (loop + next + (if finding (cons finding acc) acc)))))))))) + (def (scan-javascript-jsx-attribute-pattern + rule + path + source + pattern) + (scan-javascript-jsx-attribute-pattern-with-bindings rule path source pattern '())) (def (javascript-record-pattern-kind pattern) (let ([trimmed (string-trim pattern)]) (cond @@ -4294,6 +4549,12 @@ source (cdr entry))) (and (javascript-like-language? language) + (scan-javascript-jsx-attribute-pattern + rule + path + source + (cdr entry))) + (and (javascript-like-language? language) (scan-javascript-record-pattern rule path @@ -4485,6 +4746,8 @@ (and (javascript-like-language? language) (scan-javascript-jsx-style-pattern-with-bindings rule path source (cdr entry) initial-bindings)) (and (javascript-like-language? language) + (scan-javascript-jsx-attribute-pattern-with-bindings rule path source (cdr entry) initial-bindings)) + (and (javascript-like-language? language) (scan-javascript-record-pattern rule path @@ -11006,6 +11269,12 @@ source (rule-pattern rule))) (and (javascript-like-language? language) + (scan-javascript-jsx-attribute-pattern + rule + path + source + (rule-pattern rule))) + (and (javascript-like-language? language) (scan-javascript-record-pattern rule path --- a/src/.jerbuild-hashes +++ b/src/.jerbuild-hashes @@ -3,11 +3,11 @@ ("src/semgrep/output/json.ss" . "293881CFA2ADB7BC") ("src/semgrep/lang.ss" . "7E5441BD00A7F1D4") ("src/semgrep/parse/parse-target.ss" . "E74854DDDACF6BA") - ("src/semgrep/scan.ss" . "2D06D9BC49A20C00") - ("src/semgrep/rule.ss" . "E12C108153C181FA") + ("src/semgrep/scan.ss" . "886D36FBE7824066") ("src/semgrep/schema/lang.ss" . "CAE2CA859C9A9FD0") - ("src/semgrep/output/text.ss" . "BE476CB84B807FBA") + ("src/semgrep/rule.ss" . "E12C108153C181FA") ("src/semgrep/fix.ss" . "2E5B65B1FEF3B2B1") + ("src/semgrep/output/text.ss" . "BE476CB84B807FBA") ("src/semgrep/match/structural.ss" . "F7B63A9A6FA028B") ("src/semgrep/main.ss" . "A4EC9E7F2A09D25E") ("src/semgrep/cli.ss" . "D56FC2D2EB449BA6")) --- a/src/semgrep/scan.ss +++ b/src/semgrep/scan.ss @@ -2738,6 +2738,250 @@ pattern '())) +(def (jsx-name-char? ch) + (or (char-alphabetic? ch) + (char-numeric? ch) + (char=? ch #\_) + (char=? ch #\$) + (char=? ch #\-) + (char=? ch #\:) + (char=? ch #\.))) + +(def (jsx-name-range-at source start) + (let ([len (string-length source)]) + (and (< start len) + (jsx-name-char? (string-ref source start)) + (let loop ([i (+ start 1)]) + (if (and (< i len) + (jsx-name-char? (string-ref source i))) + (loop (+ i 1)) + (cons start i)))))) + +(def (jsx-name-boundary? source index) + (or (<= index 0) + (>= index (string-length source)) + (not (jsx-name-char? (string-ref source index))))) + +(def (skip-whitespace-backward-exclusive source start end) + (let loop ([i (- end 1)]) + (cond + [(< i start) start] + [(char-whitespace? (string-ref source i)) (loop (- i 1))] + [else (+ i 1)]))) + +(def (trim-source-range source start end) + (let ([trimmed-start (skip-whitespace source start)]) + (cons trimmed-start + (skip-whitespace-backward-exclusive + source + trimmed-start + end)))) + +(def (jsx-metavariable-name-end pattern name-start) + (let ([len (string-length pattern)]) + (let loop ([i name-start]) + (if (and (< i len) + (let ([ch (string-ref pattern i)]) + (or (char-alphabetic? ch) + (char-numeric? ch) + (char=? ch #\_)))) + (loop (+ i 1)) + i)))) + +(def (jsx-attribute-name-before-equals pattern eq-index) + (let ([end (skip-whitespace-backward-exclusive pattern 0 eq-index)]) + (let loop ([i (- end 1)]) + (cond + [(< i 0) #f] + [(jsx-name-char? (string-ref pattern i)) (loop (- i 1))] + [else + (let ([start (+ i 1)]) + (and (< start end) + (substring pattern start end)))])))) + +(def (jsx-attribute-metavariable-spec-after pattern dollar-index) + (let* ([name-start (+ dollar-index 1)] + [name-end (jsx-metavariable-name-end pattern name-start)]) + (and (> name-end name-start) + (let ([eq-index + (let loop ([i (- dollar-index 1)]) + (cond + [(< i 0) #f] + [(char-whitespace? (string-ref pattern i)) + (loop (- i 1))] + [(char=? (string-ref pattern i) #\=) i] + [else #f]))]) + (and eq-index + (let ([attr (jsx-attribute-name-before-equals + pattern + eq-index)]) + (and attr + (list + (cons 'attribute attr) + (cons 'metavariable + (substring pattern name-start name-end)))))))))) + +(def (jsx-pattern-tag-name pattern) + (let ([lt (string-find-substring pattern "<")]) + (and lt + (let* ([name-start (skip-whitespace pattern (+ lt 1))] + [range (jsx-name-range-at pattern name-start)]) + (and range + (substring pattern (car range) (cdr range))))))) + +(def (jsx-attribute-metavariable-pattern-spec pattern) + (let ([tag (jsx-pattern-tag-name pattern)]) + (and tag + (let loop ([start 0]) + (let ([dollar (string-find-substring-from pattern "$" start)]) + (and dollar + (or (let ([spec + (jsx-attribute-metavariable-spec-after + pattern + dollar)]) + (and spec (cons (cons 'tag tag) spec))) + (loop (+ dollar 1))))))))) + +(def (jsx-opening-tag-end source start) + (let ([len (string-length source)]) + (let loop ([i start] [brace-depth 0] [state 'normal] [escaped? #f]) + (cond + [(>= i len) #f] + [(eq? state 'string) + (let ([ch (string-ref source i)]) + (cond + [escaped? (loop (+ i 1) brace-depth state #f)] + [(char=? ch #\\) (loop (+ i 1) brace-depth state #t)] + [(char=? ch #\") (loop (+ i 1) brace-depth 'normal #f)] + [else (loop (+ i 1) brace-depth state #f)]))] + [(eq? state 'single) + (let ([ch (string-ref source i)]) + (cond + [escaped? (loop (+ i 1) brace-depth state #f)] + [(char=? ch #\\) (loop (+ i 1) brace-depth state #t)] + [(char=? ch #\') (loop (+ i 1) brace-depth 'normal #f)] + [else (loop (+ i 1) brace-depth state #f)]))] + [(eq? state 'template) + (let ([ch (string-ref source i)]) + (cond + [escaped? (loop (+ i 1) brace-depth state #f)] + [(char=? ch #\\) (loop (+ i 1) brace-depth state #t)] + [(char=? ch #\`) (loop (+ i 1) brace-depth 'normal #f)] + [else (loop (+ i 1) brace-depth state #f)]))] + [else + (let ([ch (string-ref source i)]) + (cond + [(char=? ch #\") (loop (+ i 1) brace-depth 'string #f)] + [(char=? ch #\') (loop (+ i 1) brace-depth 'single #f)] + [(char=? ch #\`) (loop (+ i 1) brace-depth 'template #f)] + [(char=? ch #\{) (loop (+ i 1) (+ brace-depth 1) state #f)] + [(char=? ch #\}) + (loop (+ i 1) (max 0 (- brace-depth 1)) state #f)] + [(and (= brace-depth 0) (char=? ch #\>)) (+ i 1)] + [else (loop (+ i 1) brace-depth state #f)]))])))) + +(def (jsx-braced-attribute-value-range source value-start tag-end) + (and (< value-start tag-end) + (char=? (string-ref source value-start) #\{) + (let ([close (find-matching-close-brace source value-start)]) + (and close + (<= close tag-end) + (trim-source-range source (+ value-start 1) (- close 1)))))) + +(def (jsx-attribute-value-range source tag-start tag-end attribute) + (let ([attr-len (string-length attribute)]) + (let loop ([start tag-start]) + (let ([index (string-find-substring-from source attribute start)]) + (and index + (< index tag-end) + (if (and (jsx-name-boundary? source (- index 1)) + (jsx-name-boundary? source (+ index attr-len))) + (let* ([after-name (skip-whitespace source + (+ index attr-len))] + [value-start0 + (and (< after-name tag-end) + (char=? (string-ref source after-name) #\=) + (skip-whitespace source (+ after-name 1)))] + [value-range + (and value-start0 + (jsx-braced-attribute-value-range + source + value-start0 + tag-end))]) + (or value-range + (loop (+ index attr-len)))) + (loop (+ index 1)))))))) + +(def (scan-javascript-jsx-attribute-pattern-with-bindings + rule + path + source + pattern + initial-bindings) + (let ([spec (jsx-attribute-metavariable-pattern-spec pattern)]) + (and spec + (let* ([tag (alist-ref/default spec 'tag #f)] + [attribute (alist-ref/default spec 'attribute #f)] + [name (alist-ref/default spec 'metavariable #f)] + [needle (string-append "<" tag)] + [needle-len (string-length needle)] + [len (string-length source)]) + (let loop ([start 0] [acc '()]) + (let ([tag-start (string-find-substring-from + source + needle + start)]) + (if (not tag-start) + (reverse acc) + (let* ([tag-name-end (+ tag-start needle-len)] + [tag-end + (and (jsx-name-boundary? source tag-name-end) + (jsx-opening-tag-end source tag-name-end))] + [value-range + (and tag-end + (jsx-attribute-value-range + source + tag-name-end + tag-end + attribute))] + [binding-start (and value-range (car value-range))] + [binding-end (and value-range (cdr value-range))] + [bindings + (and binding-start + binding-end + (< binding-start binding-end) + (merge-binding-list + initial-bindings + (list + (cons name + (metavariable-binding-for-range + name + source + binding-start + binding-end)))))] + [finding + (and bindings + (finding-for-range-with-bindings + rule + path + source + tag-start + tag-end + bindings))] + [next (if tag-end + (max (+ tag-start 1) tag-end) + (+ tag-start needle-len))]) + (loop next + (if finding (cons finding acc) acc)))))))))) + +(def (scan-javascript-jsx-attribute-pattern rule path source pattern) + (scan-javascript-jsx-attribute-pattern-with-bindings + rule + path + source + pattern + '())) + (def (javascript-record-pattern-kind pattern) (let ([trimmed (string-trim pattern)]) (cond @@ -4467,6 +4711,12 @@ source (cdr entry))) (and (javascript-like-language? language) + (scan-javascript-jsx-attribute-pattern + rule + path + source + (cdr entry))) + (and (javascript-like-language? language) (scan-javascript-record-pattern rule path @@ -4734,6 +4984,13 @@ (cdr entry) initial-bindings)) (and (javascript-like-language? language) + (scan-javascript-jsx-attribute-pattern-with-bindings + rule + path + source + (cdr entry) + initial-bindings)) + (and (javascript-like-language? language) (scan-javascript-record-pattern rule path @@ -11629,6 +11886,12 @@ source (rule-pattern rule))) (and (javascript-like-language? language) + (scan-javascript-jsx-attribute-pattern + rule + path + source + (rule-pattern rule))) + (and (javascript-like-language? language) (scan-javascript-record-pattern rule path --- a/tests/smoke.ss +++ b/tests/smoke.ss @@ -2030,6 +2030,23 @@ (check (finding-start-line (car findings)) => 6) (check (finding-start-col (car findings)) => 10))) +(test-case "scan JavaScript taint focused JSX href attribute" + (let* ([taint-config + "rules:\n - id: demo.taint.jsx.href\n mode: taint\n languages: [javascript]\n message: jsx href\n severity: WARNING\n pattern-sources:\n - pattern: rule\n - pattern: url\n pattern-sanitizers:\n - pattern: validateUrl(...)\n pattern-sinks:\n - patterns:\n - pattern-inside: |\n (<SourceLink ... href=$HREF .../>)\n - focus-metavariable: $HREF\n"] + [findings + (scan-config-string + taint-config + "javascript" + "demo.js" + "const Sources = ({ rule, url }) => (\n <div>\n <SourceLink href={validateUrl(rule.source_uri)} />\n <SourceLink href={rule.source_uri} />\n <SourceLink href={validateUrl(url)} />\n <SourceLink href={url} />\n </div>\n);\n")]) + (check (length findings) => 2) + (check (finding-start-line (car findings)) => 4) + (check (finding-start-col (car findings)) => 23) + (check (finding-end-col (car findings)) => 38) + (check (finding-start-line (cadr findings)) => 6) + (check (finding-start-col (cadr findings)) => 23) + (check (finding-end-col (cadr findings)) => 26))) + (test-case "scan taint propagation stays within Python function scope" (let* ([taint-config "rules:\n - id: demo.taint.propagator.scope\n mode: taint\n languages: [python]\n message: scoped propagated sink\n severity: WARNING\n pattern-sources:\n - label: tainted\n pattern: user_input\n - label: safe\n pattern: safe_data\n pattern-propagators:\n - pattern: $S.add($A)\n from: $A\n to: $S\n pattern-sinks:\n - requires: tainted\n patterns:\n - pattern: sink($SINK)\n - focus-metavariable: $SINK\n"]