Advance field-sensitive taint parity
ober
24796db0618ea5e3f038a76dbf605399a66b4c74
--- a/HANDOFF_OPUS_4_8.md +++ b/HANDOFF_OPUS_4_8.md @@ -31,6 +31,7 @@ taint/dataflow, path and target semantics, autofix, and output schemas. Recent checkpoints before this handoff: ```text +971b714 Advance taint exception and field parity 29dd690 Clear taint exact source frontier 7beb039 Advance taint control parity e429d34 Advance taint safe function parity @@ -67,7 +68,7 @@ make test Result: ```text -182 tests, 182 passed, 0 failed +184 tests, 184 passed, 0 failed ``` Local oracle: @@ -82,19 +83,32 @@ Result: oracle: 42 passed, 0 failed ``` -Focused upstream guardrail containing the cases fixed in this checkpoint: +Focused upstream guardrail containing the field-sensitive cases fixed in this +checkpoint: ```sh -SEMGREP_CURRENT=/Users/user/.local/bin/semgrep CASE_REGEX='^(taint_exception|taint_exact_sources|taint_field_sensitive1|taint_clean_in_try_no_finally|taint_best_fit_sink|taint_assume_safe_booleans|taint_assume_safe_numbers|taint_assume_safe_indexes)$' LIST_MISMATCHES=1 MAX_DIFFS=160 tests/oracle/upstream-sweep.sh +SEMGREP_CURRENT=/Users/user/.local/bin/semgrep CASE_REGEX='^(taint_field_sensitive2|taint_field_sensitive4|taint_field_sensitive6|taint_field_sensitive8)$' LIST_MISMATCHES=1 MAX_DIFFS=160 tests/oracle/upstream-sweep.sh ``` Result: ```text -upstream-sweep: 8 passed, 0 mismatched, 0 jerboa errors, 0 current errors, 8 compared +upstream-sweep: 4 passed, 0 mismatched, 0 jerboa errors, 0 current errors, 4 compared ``` -Intended first-220 upstream sweep: +Nearby regression guardrail: + +```sh +SEMGREP_CURRENT=/Users/user/.local/bin/semgrep CASE_REGEX='^(taint_field_sensitive2|taint_field_sensitive1|taint_exact_sources)$' LIST_MISMATCHES=1 MAX_DIFFS=220 tests/oracle/upstream-sweep.sh +``` + +Result: + +```text +upstream-sweep: 3 passed, 0 mismatched, 0 jerboa errors, 0 current errors, 3 compared +``` + +Current first-220 upstream sweep: ```sh SEMGREP_CURRENT=/Users/user/.local/bin/semgrep MAX_CASES=220 LIST_MISMATCHES=1 MAX_DIFFS=0 tests/oracle/upstream-sweep.sh @@ -103,11 +117,12 @@ SEMGREP_CURRENT=/Users/user/.local/bin/semgrep MAX_CASES=220 LIST_MISMATCHES=1 M Result: ```text -upstream-sweep: 189 passed, 29 mismatched, 0 jerboa errors, 2 current errors, 220 compared +upstream-sweep: 193 passed, 25 mismatched, 0 jerboa errors, 2 current errors, 220 compared ``` The full 241-case sweep was not rerun after this checkpoint. The last full -run, before the `taint_exception` and `taint_field_sensitive1` fixes, was: +run, before the `taint_exception`, `taint_field_sensitive1`, and current +field-clean fixes, was: ```sh SEMGREP_CURRENT=/Users/user/.local/bin/semgrep CASE_LIMIT=220 LIST_MISMATCHES=1 MAX_DIFFS=160 tests/oracle/upstream-sweep.sh @@ -124,32 +139,40 @@ errors. ## What Changed In This Checkpoint -This checkpoint clears `taint_exception` and `taint_field_sensitive1`, the next -two mismatches after `29dd690`. +This checkpoint clears `taint_field_sensitive2`, `taint_field_sensitive4`, +`taint_field_sensitive6`, and `taint_field_sensitive8`, building on `971b714` +which cleared +`taint_exception` and `taint_field_sensitive1`. Implementation changes in `src/semgrep/scan.ss`: -- Python taint facts are now filtered out of trivially impossible - `try`/`except`/`else` branches. The filter is deliberately narrow: - `try: pass` makes a matching `except` branch impossible, `try: raise ...` or - `try: return ...` makes the matching `else` branch impossible, and unknown - calls keep both branches possible. -- The reachability filter is applied only to taint source, sanitizer, sink, and - propagator findings. General structural matching is untouched. -- Field/member taint can now reach an opaque base sink call. For example, if - `x.a` is tainted, `sink(x)` is treated as tainted because the callee could - inspect `x.a`, while `sink(x.b)` is not treated as tainted by `x.a`. +- Clean assignment kills are now field-aware instead of token-wide. +- New access-path helpers parse simple member/index paths such as `x`, + `x.a`, `x.c[i].d`, and `params["sql"]`. +- Dynamic bracket indexes normalize to `*`, so `x.c[i].d = safe` can kill + `x.c[j].d` and descendants without killing `x.c[j]`. +- Quoted bracket indexes keep their literal segment, so the earlier + `params["sql"]` exact-source behavior remains preserved. +- Base clean assignments still kill the base and descendants, while member + clean assignments kill only their compatible field subtree. +- Opaque whole-object sinks remain compatible with reachable member taint from + `971b714`, so `x.a.i = source()` can still make `sink(x.a)` and `sink(x)` + findings. New smoke coverage in `tests/smoke.ss`: ```text scan Python taint filters impossible exception branches scan taint field source reaches opaque base sink +scan taint whole object source honors field cleans +scan taint ancestor field clean clears descendant sources ``` -The exception test covers `try: pass`, `try: raise`, and unknown-call try -bodies. The field-source test covers `x.a` taint reaching `sink(x)` without -also tainting unrelated `sink(x.b)`. +The new field-clean test mirrors the upstream `taint_field_sensitive2` shape: +`x = source` taints `sink(x)`, `sink(x.b)`, `sink(x.b.c)`, and `sink(x.c[j])`, +while clean assignments to `x.a` and `x.c[i].d` suppress only those field +subtrees. The ancestor-clean test mirrors `taint_field_sensitive4`: +`x.a = safe` clears earlier descendant sources under `x.a`. ## Resolved Recent Cases @@ -233,17 +256,94 @@ What changed: - The compatibility is prefix-bounded, so `x.a` does not taint unrelated `sink(x.b)`. +`taint_field_sensitive2` is now cleared: + +```sh +SEMGREP_CURRENT=/Users/user/.local/bin/semgrep CASE_REGEX='^taint_field_sensitive2$' LIST_MISMATCHES=1 MAX_DIFFS=220 tests/oracle/upstream-sweep.sh +``` + +It is also included in the verified focused field-sensitive guardrail above. + +What changed: + +- Jerboa previously reported no findings for + `/Users/user/mine/semgrep/tests/rules/taint_field_sensitive2.js`. +- The broad source `x = source` was killed completely by later clean member + assignments `x.a = safe` and `x.c[i].d = safe`. +- The access-path kill logic now keeps the base `x` source available for + `sink(x)`, `sink(x.b)`, `sink(x.b.c)`, and `sink(x.c[j])`. +- The same kill logic suppresses only the cleaned subtrees: `sink(x.a)`, + `sink(x.a.b)`, `sink(x.c[j].d)`, and `sink(x.c[j].d.e)`. + +Expected matched lines for this case are now exactly: + +```text +8, 10, 12, 14 +``` + +`taint_field_sensitive4` is now cleared: + +```sh +SEMGREP_CURRENT=/Users/user/.local/bin/semgrep CASE_REGEX='^taint_field_sensitive4$' LIST_MISMATCHES=1 MAX_DIFFS=220 tests/oracle/upstream-sweep.sh +``` + +What changed: + +- Target file: + `/Users/user/mine/semgrep/tests/rules/taint_field_sensitive4.js`. +- Shape: + `x.a.b = source; x.a.c = source; ...; x.a = safe; sink(x.a.b); + sink(x.a.c); sink(x.a.b.x)`. +- Jerboa previously kept reporting descendants after the ancestor clean + assignment at `x.a = safe`. +- The kill predicate now also handles the inverse relation from + `taint_field_sensitive2`: when the clean assignment path is an ancestor of + the source path, the source is dead for later sinks. +- This fixed false positives at lines 17, 19, and 21 while preserving the + expected pre-clean findings at lines 8, 10, and 12. + +`taint_field_sensitive6` is now cleared: + +```sh +SEMGREP_CURRENT=/Users/user/.local/bin/semgrep CASE_REGEX='^taint_field_sensitive6$' LIST_MISMATCHES=1 MAX_DIFFS=120 tests/oracle/upstream-sweep.sh +``` + +What changed: + +- Target file: + `/Users/user/mine/semgrep/tests/rules/taint_field_sensitive6.py`. +- Shape: + `x = source(); x.a = safe; exotic(x.a); sink(x.a)`. +- The sink spec includes a focused `exotic($X.$A)` sink whose finding range is + `$X`. Semgrep treats `exotic(x.a)` as a finding because the actual focused + sink is `x`, and `x` remains tainted even though `x.a` was cleaned. +- The new field-aware kill keeps base `x` tainted while clearing only `x.a`, + so `exotic(x.a)` reports and `sink(x.a)` stays clean. + +`taint_field_sensitive8` is now cleared: + +```sh +SEMGREP_CURRENT=/Users/user/.local/bin/semgrep CASE_REGEX='^taint_field_sensitive8$' LIST_MISMATCHES=1 MAX_DIFFS=120 tests/oracle/upstream-sweep.sh +``` + +What changed: + +- Target file: + `/Users/user/mine/semgrep/tests/rules/taint_field_sensitive8.py`. +- Shape: + `x = source(); x.a = sanitize(); sink(x); x.a.i = source(); sink(x.a.i); + sink(x.a); sink(x)`. +- The sanitizer assignment should clear only `x.a`, not the base `x`. +- The later member source `x.a.i = source()` should re-taint `x.a.i`, make the + ancestor `x.a` reachable again, and keep `sink(x)` reportable. + ## Current First-220 Frontier -The current first-220 sweep has these 29 mismatches: +The current first-220 sweep has these 25 mismatches: ```text -taint_field_sensitive2 taint_field_sensitive3 -taint_field_sensitive4 -taint_field_sensitive6 taint_field_sensitive7 -taint_field_sensitive8 taint_final_globals1 taint_labels2 taint_labels3 @@ -285,12 +385,12 @@ vardef_assign_true1 vardef_assign_true2 ``` -## Immediate Next Case: taint_field_sensitive2 +## Immediate Next Case: taint_field_sensitive3 Focused command: ```sh -SEMGREP_CURRENT=/Users/user/.local/bin/semgrep CASE_REGEX='^taint_field_sensitive2$' LIST_MISMATCHES=1 MAX_DIFFS=220 tests/oracle/upstream-sweep.sh +SEMGREP_CURRENT=/Users/user/.local/bin/semgrep CASE_REGEX='^taint_field_sensitive3$' LIST_MISMATCHES=1 MAX_DIFFS=260 tests/oracle/upstream-sweep.sh ``` Current result: @@ -319,51 +419,62 @@ rules: Target: ```text -/Users/user/mine/semgrep/tests/rules/taint_field_sensitive2.js +/Users/user/mine/semgrep/tests/rules/taint_field_sensitive3.js ``` Relevant target: ```text -3 x = source -4 x.a = safe -5 x.c[i].d = safe -8 sink(x) -10 sink(x.b) -12 sink(x.b.c) -14 sink(x.c[j]) -17 sink(x.a) +3 x.a.b.c = source +4 x.d.e[i].f.g[j].h = source +7 sink(x.a.b.c) +9 sink(x.a.b.c.d) +11 sink(x.d.e[k].f.g[l].h) +13 sink(x.d.e[k].f.g[l].h.i) 19 sink(x.a.b) -22 sink(x.c[j].d) -24 sink(x.c[j].d.e) +21 sink(x.a) +23 sink(x.d.e[i].f.g[j]) +25 sink(x.d.e) +27 sink(x.d) +29 sink(x) +33 sink(x.a.b.d) +35 sink(x.a.c) ``` Packaged Semgrep expects findings on: ```text -8, 10, 12, 14 +7, 9, 11, 13, 19, 21, 23, 25, 27, 29 ``` -Jerboa currently reports no findings: +Jerboa currently misses only the dynamic-index exact/descendant member sinks: ```diff -@@ -1,4 +0,0 @@ --(finding "test" ".../taint_field_sensitive2.js" 10 5 ... "WARNING" "test" "") --(finding "test" ".../taint_field_sensitive2.js" 12 5 ... "WARNING" "test" "") --(finding "test" ".../taint_field_sensitive2.js" 14 5 ... "WARNING" "test" "") --(finding "test" ".../taint_field_sensitive2.js" 8 5 ... "WARNING" "test" "") +@@ -1,5 +1,3 @@ +-(finding "test" ".../taint_field_sensitive3.js" 11 5 ... "WARNING" "test" "") +-(finding "test" ".../taint_field_sensitive3.js" 13 5 ... "WARNING" "test" "") + (finding "test" ".../taint_field_sensitive3.js" 19 5 ... "WARNING" "test" "") + (finding "test" ".../taint_field_sensitive3.js" 21 5 ... "WARNING" "test" "") + (finding "test" ".../taint_field_sensitive3.js" 23 5 ... "WARNING" "test" "") ``` Interpretation for the next fix: -- This is the inverse of `taint_field_sensitive1`: a whole-object source - (`x = source`) should taint member sinks such as `x.b` and `x.c[j]`. -- Clean member assignments should suppress only their matching field subtree: - `x.a = safe` suppresses `x.a` and `x.a.b`; `x.c[i].d = safe` suppresses - `x.c[j].d` and `x.c[j].d.e` but still allows `x.c[j]`. -- Likely implementation area is field-prefix compatibility plus field-specific - assignment kills. Be careful not to undo the `taint_field_sensitive1` - behavior where member taint reaches an opaque whole-object sink. +- The `x.a.b.c` source path works in both exact and descendant directions: + `sink(x.a.b.c)`, `sink(x.a.b.c.d)`, `sink(x.a.b)`, `sink(x.a)`, and + `sink(x)` are already compatible. +- The dynamic-index path `x.d.e[i].f.g[j].h` works for opaque ancestor sinks: + `sink(x.d.e[i].f.g[j])`, `sink(x.d.e)`, `sink(x.d)`, and `sink(x)` are + already compatible. +- The missing edge is dynamic-index source-to-sink equality/descendant + compatibility for `x.d.e[i].f.g[j].h` to `x.d.e[k].f.g[l].h` and + `x.d.e[k].f.g[l].h.i`. +- Start in the access-path compatibility helpers in `src/semgrep/scan.ss`. + The assignment-kill parser already normalizes dynamic bracket segments to + `*`; source/sink compatibility probably needs the same path parsing instead + of relying only on text token/prefix checks. +- Be careful not to make unrelated sibling paths report. `sink(x.a.b.d)` and + `sink(x.a.c)` must remain clean. ## Resolved Earlier Frontier --- a/lib/semgrep/scan.sls +++ b/lib/semgrep/scan.sls @@ -8709,23 +8709,163 @@ sanitizer-state sink source-text))))))) - (def (assignment-target-matches-source? + (def (access-path-identifier-end text start) + (let ([len (string-length text)]) + (let loop ([i start]) + (if (and (< i len) (identifier-char? (string-ref text i))) + (loop (+ i 1)) + i)))) + (def (access-path-bracket-end text start) + (let ([len (string-length text)]) + (let loop ([i start] + [state 'normal] + [quote-ch #f] + [escaped? #f]) + (cond + [(>= i len) #f] + [(eq? state 'string) + (let ([ch (string-ref text i)]) + (cond + [escaped? (loop (+ i 1) state quote-ch #f)] + [(char=? ch #\\) (loop (+ i 1) state quote-ch #t)] + [(char=? ch quote-ch) (loop (+ i 1) 'normal #f #f)] + [else (loop (+ i 1) state quote-ch #f)]))] + [else + (let ([ch (string-ref text i)]) + (cond + [(or (char=? ch #\") (char=? ch #\')) + (loop (+ i 1) 'string ch #f)] + [(char=? ch #\]) i] + [else (loop (+ i 1) state quote-ch escaped?)]))])))) + (def (access-path-bracket-segment text start end) + (let ([content (string-trim (substring text start end))]) + (if (quoted-string? content) (unquote-string content) "*"))) + (def (access-path-from-text text) + (let* ([trimmed (string-trim text)] + [len (string-length trimmed)]) + (and (> len 0) + (identifier-char? (string-ref trimmed 0)) + (let ([base-end (access-path-identifier-end trimmed 0)]) + (let loop ([i base-end] + [segments (list + (substring trimmed 0 base-end))]) + (let ([j (skip-horizontal-forward trimmed i)]) + (cond + [(>= j len) (reverse segments)] + [(char=? (string-ref trimmed j) #\.) + (let* ([name-start (+ j 1)] + [name-end (access-path-identifier-end + trimmed + name-start)]) + (and (> name-end name-start) + (loop + name-end + (cons + (substring trimmed name-start name-end) + segments))))] + [(char=? (string-ref trimmed j) #\[) + (let ([end (access-path-bracket-end + trimmed + (+ j 1))]) + (and end + (loop + (+ end 1) + (cons + (access-path-bracket-segment + trimmed + (+ j 1) + end) + segments))))] + [else #f]))))))) + (def (access-path-segment-compatible? a b) + (or (string=? a b) (string=? a "*") (string=? b "*"))) + (def (access-path-prefix? prefix path) + (let loop ([xs prefix] [ys path]) + (cond + [(null? xs) #t] + [(null? ys) #f] + [(access-path-segment-compatible? (car xs) (car ys)) + (loop (cdr xs) (cdr ys))] + [else #f]))) + (def (access-path-equal? a b) + (and (access-path-prefix? a b) (access-path-prefix? b a))) + (def (assignment-lhs-text assignment-finding source-text) + (let* ([assignment-text (finding-text + assignment-finding + source-text)] + [eq-index (string-find-substring assignment-text "=")]) + (and eq-index (substring assignment-text 0 eq-index)))) + (def (first-bracket-segment-quoted? lhs source-path) + (and source-path + (not (null? source-path)) + (null? (cdr source-path)) + (let* ([trimmed (string-trim lhs)] + [base (car source-path)] + [base-len (string-length base)] + [len (string-length trimmed)]) + (and (> len base-len) + (substring-at? trimmed base 0) + (let ([index (skip-horizontal-forward + trimmed + base-len)]) + (and (< index len) + (char=? (string-ref trimmed index) #\[) + (let* ([content-start (+ index 1)] + [content-end (access-path-bracket-end + trimmed + content-start)] + [content (and content-end + (string-trim + (substring + trimmed + content-start + content-end)))]) + (and content (quoted-string? content))))))))) + (def (sink-access-path sink source-text) + (or (let ([arg-text (direct-call-argument-text + sink + source-text)]) + (and arg-text (access-path-from-text arg-text))) + (access-path-from-text (finding-text sink source-text)))) + (def (assignment-path-kills-source? source assignment-finding - to-binding + sink source-text) - (let ([source-name (finding-text source source-text)]) - (or (and to-binding - (string=? - source-name - (metavariable-binding-text to-binding))) - (let* ([assignment-text (finding-text - assignment-finding - source-text)] - [eq-index (string-find-substring assignment-text "=")] - [lhs (and eq-index - (substring assignment-text 0 eq-index))]) - (and lhs (string-contains-token? lhs source-name)))))) + (let* ([lhs (assignment-lhs-text + assignment-finding + source-text)] + [source-path (access-path-from-text + (finding-text source source-text))] + [assignment-path (and lhs (access-path-from-text lhs))] + [sink-path (sink-access-path sink source-text)]) + (and lhs + source-path + assignment-path + sink-path + (or (access-path-prefix? assignment-path source-path) + (and (access-path-prefix? source-path assignment-path) + (or (and (access-path-equal? + source-path + assignment-path) + (access-path-prefix? + source-path + sink-path)) + (access-path-prefix? assignment-path sink-path) + (and (access-path-equal? source-path sink-path) + (first-bracket-segment-quoted? + lhs + source-path)))))))) + (def (assignment-target-matches-source? source assignment-finding to-binding sink source-text) + (or (assignment-path-kills-source? + source + assignment-finding + sink + source-text) + (and to-binding + (string=? + (finding-text source source-text) + (metavariable-binding-text to-binding))))) (def (taint-assignment-kills-source? source-state assignment @@ -8756,11 +8896,8 @@ (finding-start-offset source)) (< (finding-start-offset assignment-finding) (finding-start-offset sink)) - (assignment-target-matches-source? - source - assignment-finding - to-binding - source-text)))) + (assignment-target-matches-source? source assignment-finding + to-binding sink source-text)))) (def (taint-source-killed-before-sink? source-state sink --- a/src/.jerbuild-hashes +++ b/src/.jerbuild-hashes @@ -3,7 +3,7 @@ ("src/semgrep/output/json.ss" . "293881CFA2ADB7BC") ("src/semgrep/lang.ss" . "7E5441BD00A7F1D4") ("src/semgrep/parse/parse-target.ss" . "E74854DDDACF6BA") - ("src/semgrep/scan.ss" . "38CD0141D5589AF0") + ("src/semgrep/scan.ss" . "EB4346CDD34F7AE7") ("src/semgrep/rule.ss" . "E12C108153C181FA") ("src/semgrep/schema/lang.ss" . "CAE2CA859C9A9FD0") ("src/semgrep/output/text.ss" . "BE476CB84B807FBA") --- a/src/semgrep/scan.ss +++ b/src/semgrep/scan.ss @@ -9177,17 +9177,162 @@ sink source-text))))))) -(def (assignment-target-matches-source? source assignment-finding to-binding source-text) - (let ([source-name (finding-text source source-text)]) - (or (and to-binding - (string=? source-name - (metavariable-binding-text to-binding))) - (let* ([assignment-text (finding-text assignment-finding source-text)] - [eq-index (string-find-substring assignment-text "=")] - [lhs (and eq-index - (substring assignment-text 0 eq-index))]) - (and lhs - (string-contains-token? lhs source-name)))))) +(def (access-path-identifier-end text start) + (let ([len (string-length text)]) + (let loop ([i start]) + (if (and (< i len) + (identifier-char? (string-ref text i))) + (loop (+ i 1)) + i)))) + +(def (access-path-bracket-end text start) + (let ([len (string-length text)]) + (let loop ([i start] [state 'normal] [quote-ch #f] [escaped? #f]) + (cond + [(>= i len) #f] + [(eq? state 'string) + (let ([ch (string-ref text i)]) + (cond + [escaped? (loop (+ i 1) state quote-ch #f)] + [(char=? ch #\\) (loop (+ i 1) state quote-ch #t)] + [(char=? ch quote-ch) (loop (+ i 1) 'normal #f #f)] + [else (loop (+ i 1) state quote-ch #f)]))] + [else + (let ([ch (string-ref text i)]) + (cond + [(or (char=? ch #\") (char=? ch #\')) + (loop (+ i 1) 'string ch #f)] + [(char=? ch #\]) i] + [else (loop (+ i 1) state quote-ch escaped?)]))])))) + +(def (access-path-bracket-segment text start end) + (let ([content (string-trim (substring text start end))]) + (if (quoted-string? content) + (unquote-string content) + "*"))) + +(def (access-path-from-text text) + (let* ([trimmed (string-trim text)] + [len (string-length trimmed)]) + (and (> len 0) + (identifier-char? (string-ref trimmed 0)) + (let ([base-end (access-path-identifier-end trimmed 0)]) + (let loop ([i base-end] + [segments (list (substring trimmed 0 base-end))]) + (let ([j (skip-horizontal-forward trimmed i)]) + (cond + [(>= j len) (reverse segments)] + [(char=? (string-ref trimmed j) #\.) + (let* ([name-start (+ j 1)] + [name-end (access-path-identifier-end + trimmed + name-start)]) + (and (> name-end name-start) + (loop name-end + (cons + (substring trimmed name-start name-end) + segments))))] + [(char=? (string-ref trimmed j) #\[) + (let ([end (access-path-bracket-end trimmed (+ j 1))]) + (and end + (loop (+ end 1) + (cons + (access-path-bracket-segment + trimmed + (+ j 1) + end) + segments))))] + [else #f]))))))) + +(def (access-path-segment-compatible? a b) + (or (string=? a b) + (string=? a "*") + (string=? b "*"))) + +(def (access-path-prefix? prefix path) + (let loop ([xs prefix] [ys path]) + (cond + [(null? xs) #t] + [(null? ys) #f] + [(access-path-segment-compatible? (car xs) (car ys)) + (loop (cdr xs) (cdr ys))] + [else #f]))) + +(def (access-path-equal? a b) + (and (access-path-prefix? a b) + (access-path-prefix? b a))) + +(def (assignment-lhs-text assignment-finding source-text) + (let* ([assignment-text (finding-text assignment-finding source-text)] + [eq-index (string-find-substring assignment-text "=")]) + (and eq-index + (substring assignment-text 0 eq-index)))) + +(def (first-bracket-segment-quoted? lhs source-path) + (and source-path + (not (null? source-path)) + (null? (cdr source-path)) + (let* ([trimmed (string-trim lhs)] + [base (car source-path)] + [base-len (string-length base)] + [len (string-length trimmed)]) + (and (> len base-len) + (substring-at? trimmed base 0) + (let ([index (skip-horizontal-forward trimmed base-len)]) + (and (< index len) + (char=? (string-ref trimmed index) #\[) + (let* ([content-start (+ index 1)] + [content-end + (access-path-bracket-end trimmed content-start)] + [content + (and content-end + (string-trim + (substring + trimmed + content-start + content-end)))]) + (and content + (quoted-string? content))))))))) + +(def (sink-access-path sink source-text) + (or (let ([arg-text (direct-call-argument-text sink source-text)]) + (and arg-text (access-path-from-text arg-text))) + (access-path-from-text (finding-text sink source-text)))) + +(def (assignment-path-kills-source? source assignment-finding sink source-text) + (let* ([lhs (assignment-lhs-text assignment-finding source-text)] + [source-path (access-path-from-text (finding-text source source-text))] + [assignment-path (and lhs (access-path-from-text lhs))] + [sink-path (sink-access-path sink source-text)]) + (and lhs + source-path + assignment-path + sink-path + (or (access-path-prefix? assignment-path source-path) + (and (access-path-prefix? source-path assignment-path) + (or (and (access-path-equal? source-path assignment-path) + (access-path-prefix? source-path sink-path)) + (access-path-prefix? assignment-path sink-path) + (and (access-path-equal? source-path sink-path) + (first-bracket-segment-quoted? + lhs + source-path)))))))) + +(def (assignment-target-matches-source? + source + assignment-finding + to-binding + sink + source-text) + (or (assignment-path-kills-source? + source + assignment-finding + sink + source-text) + (and to-binding + (string=? + (finding-text source source-text) + (metavariable-binding-text to-binding))))) (def (taint-assignment-kills-source? source-state assignment sink source-text) (let* ([source (taint-state-finding source-state)] @@ -9210,6 +9355,7 @@ source assignment-finding to-binding + sink source-text)))) (def (taint-source-killed-before-sink? source-state sink assignments source-text) --- a/tests/smoke.ss +++ b/tests/smoke.ss @@ -1621,6 +1621,35 @@ (check (finding-start-line (car findings)) => 4) (check (finding-start-line (cadr findings)) => 5))) +(test-case "scan taint whole object source honors field cleans" + (let* ([taint-config + "rules:\n - id: demo.taint.field-clean\n mode: taint\n languages: [javascript]\n message: field clean taint\n severity: WARNING\n pattern-sources:\n - pattern: source\n pattern-sinks:\n - pattern: sink(...)\n"] + [findings + (scan-config-string + taint-config + "javascript" + "demo.js" + "function f() {\n x = source;\n x.a = safe;\n x.c[i].d = safe;\n sink(x);\n sink(x.b);\n sink(x.b.c);\n sink(x.c[j]);\n sink(x.a);\n sink(x.a.b);\n sink(x.c[j].d);\n sink(x.c[j].d.e);\n}\n")]) + (check (length findings) => 4) + (check (finding-start-line (car findings)) => 5) + (check (finding-start-line (cadr findings)) => 6) + (check (finding-start-line (caddr findings)) => 7) + (check (finding-start-line (cadddr findings)) => 8))) + +(test-case "scan taint ancestor field clean clears descendant sources" + (let* ([taint-config + "rules:\n - id: demo.taint.field-ancestor-clean\n mode: taint\n languages: [javascript]\n message: ancestor field clean taint\n severity: WARNING\n pattern-sources:\n - pattern: source\n pattern-sinks:\n - pattern: sink(...)\n"] + [findings + (scan-config-string + taint-config + "javascript" + "demo.js" + "function f() {\n x.a.b = source;\n x.a.c = source;\n sink(x.a.b);\n sink(x.a.c);\n sink(x.a.b.x);\n x.a = safe;\n sink(x.a.b);\n sink(x.a.c);\n sink(x.a.b.x);\n}\n")]) + (check (length findings) => 3) + (check (finding-start-line (car findings)) => 4) + (check (finding-start-line (cadr findings)) => 5) + (check (finding-start-line (caddr findings)) => 6))) + (test-case "scan taint by-side-effect source reaches wildcard sink" (let* ([taint-config "rules:\n - id: demo.taint.by-side-effect-source\n mode: taint\n languages: [python]\n message: side-effect taint\n severity: WARNING\n pattern-sources:\n - by-side-effect: true\n patterns:\n - pattern: $X = source()\n - focus-metavariable: $X\n pattern-sinks:\n - pattern: sink(...)\n"]