security: make .semgrepignore loading opt-in and reported

ober

ad463b98b03b4edf8f522ff3e9410b8c4a268d82

diff --git a/lib/semgrep/cli.sls b/lib/semgrep/cli.sls
index c2095b3..5013e1a 100644
--- a/lib/semgrep/cli.sls
+++ b/lib/semgrep/cli.sls
@@ -32,10 +32,12 @@
            target))
   (def (usage)
        (display
-         "usage: semgrep scan --config RULES.yml [--lang LANG] [--severity LEVEL] [--include GLOB] [--exclude GLOB] TARGET\n")
+         "usage: semgrep scan --config RULES.yml [--lang LANG] [--severity LEVEL] [--include GLOB] [--exclude GLOB] [--use-semgrepignore] TARGET\n")
        (display "\n")
        (display
-         "MVP support: YAML configs with pattern/pattern-regex rules; json/python/javascript/dockerfile/html/xml/yaml/c/terraform/php/java/csharp/swift/dart/move/julia/kotlin/ruby/rust/go/scala/cpp targets.\n"))
+         "MVP support: YAML configs with pattern/pattern-regex rules; json/python/javascript/dockerfile/html/xml/yaml/c/terraform/php/java/csharp/swift/dart/move/julia/kotlin/ruby/rust/go/scala/cpp targets.\n")
+       (display
+         "Ignore rules from a scanned tree's .semgrepignore are untrusted and only honored with --use-semgrepignore.\n"))
   (def (parse-args args)
        (let loop ([xs args]
                   [config #f]
@@ -43,67 +45,72 @@
                   [format 'json]
                   [severities '()]
                   [autofix? #f]
+                  [use-ignore? #f]
                   [includes '()]
                   [excludes '()]
                   [targets '()])
          (cond
            [(null? xs)
             (values config language format (reverse severities) autofix?
-              (reverse includes) (reverse excludes) (reverse targets))]
+              use-ignore? (reverse includes) (reverse excludes)
+              (reverse targets))]
            [(string=? (car xs) "--")
             (values config language format (reverse severities) autofix?
-              (reverse includes) (reverse excludes)
+              use-ignore? (reverse includes) (reverse excludes)
               (append (reverse targets) (cdr xs)))]
            [(string=? (car xs) "--json")
             (loop (cdr xs) config language 'json severities autofix?
-              includes excludes targets)]
+              use-ignore? includes excludes targets)]
            [(string=? (car xs) "--sarif")
             (loop (cdr xs) config language 'sarif severities autofix?
-              includes excludes targets)]
+              use-ignore? includes excludes targets)]
            [(string=? (car xs) "--text")
             (loop (cdr xs) config language 'text severities autofix?
-              includes excludes targets)]
+              use-ignore? includes excludes targets)]
            [(string=? (car xs) "--autofix")
-            (loop (cdr xs) config language format severities #t includes
-              excludes targets)]
+            (loop (cdr xs) config language format severities #t
+              use-ignore? includes excludes targets)]
+           [(string=? (car xs) "--use-semgrepignore")
+            (loop (cdr xs) config language format severities autofix? #t
+              includes excludes targets)]
            [(string=? (car xs) "--config")
             (when (null? (cdr xs))
               (error 'semgrep-cli "--config needs a value"))
             (loop (cddr xs) (cadr xs) language format severities
-              autofix? includes excludes targets)]
+              autofix? use-ignore? includes excludes targets)]
            [(string=? (car xs) "-c")
             (when (null? (cdr xs))
               (error 'semgrep-cli "-c needs a value"))
             (loop (cddr xs) (cadr xs) language format severities
-              autofix? includes excludes targets)]
+              autofix? use-ignore? includes excludes targets)]
            [(string=? (car xs) "--lang")
             (when (null? (cdr xs))
               (error 'semgrep-cli "--lang needs a value"))
             (loop (cddr xs) config (cadr xs) format severities autofix?
-              includes excludes targets)]
+              use-ignore? includes excludes targets)]
            [(string=? (car xs) "--severity")
             (when (null? (cdr xs))
               (error 'semgrep-cli "--severity needs a value"))
             (loop (cddr xs) config language format
-              (cons (cadr xs) severities) autofix? includes excludes
-              targets)]
+              (cons (cadr xs) severities) autofix? use-ignore? includes
+              excludes targets)]
            [(string=? (car xs) "--include")
             (when (null? (cdr xs))
               (error 'semgrep-cli "--include needs a value"))
             (loop (cddr xs) config language format severities autofix?
-              (cons (cadr xs) includes) excludes targets)]
+              use-ignore? (cons (cadr xs) includes) excludes targets)]
            [(string=? (car xs) "--exclude")
             (when (null? (cdr xs))
               (error 'semgrep-cli "--exclude needs a value"))
             (loop (cddr xs) config language format severities autofix?
-              includes (cons (cadr xs) excludes) targets)]
+              use-ignore? includes (cons (cadr xs) excludes) targets)]
            [(and (> (string-length (car xs)) 0)
                  (not (string=? (car xs) "-"))
                  (char=? (string-ref (car xs) 0) #\-))
             (error 'semgrep-cli "unknown option" (car xs))]
            [else
             (loop (cdr xs) config language format severities autofix?
-              includes excludes (cons (car xs) targets))])))
+              use-ignore? includes excludes (cons (car xs) targets))])))
   (def (format-findings format findings)
        (case format
          [(json) (findings->json-string findings)]
@@ -179,6 +186,21 @@
                  [(ignore-line? (car lines)) (loop (cdr lines) acc)]
                  [else (loop (cdr lines) (cons (car lines) acc))]))
              '())))
+  (def (report-ignored-paths dir paths)
+       (let ([port (current-error-port)])
+         (display "semgrep: .semgrepignore (opt-in) ignored " port)
+         (display (length paths) port)
+         (display " path(s) under " port)
+         (display dir port)
+         (display ":" port)
+         (newline port)
+         (for-each
+           (lambda (path)
+             (display "  " port)
+             (display path port)
+             (newline port))
+           paths)
+         (flush-output-port port)))
   (def (ignore-pattern-matches? pattern relative name)
        (or (glob-match? pattern relative)
            (and (not (sg-string-contains-char? pattern #\/))
@@ -244,86 +266,130 @@
        (let loop ([xs seen])
          (and (not (null? xs))
               (or (equal? key (car xs)) (loop (cdr xs))))))
-  (def (expand-open-directory-target language-opt dir root)
-       (let ([ignore-patterns (read-semgrepignore dir)]
+  (def (expand-open-directory-target
+         language-opt
+         dir
+         root
+         use-ignore?)
+       (let ([ignore-patterns (if use-ignore?
+                                  (read-semgrepignore dir)
+                                  '())]
              [entry-count 0]
              [path-char-count 0]
+             [ignored-paths '()]
              [seen '()])
-         (let walk ([relative-dir ""] [depth 0] [acc '()])
-           (when (> depth max-recursive-depth)
-             (error 'semgrep-cli
-               "recursive target exceeds depth limit"
-               max-recursive-depth))
-           (let* ([directory-info (secure-entry-info
-                                    (cli-root-handle root)
-                                    relative-dir)]
-                  [key (identity-key directory-info)])
-             (unless (eq? (secure-entry-kind directory-info) 'directory)
-               (error 'semgrep-cli
-                 "recursive target changed type"
-                 relative-dir))
-             (when (identity-seen? key seen)
-               (error 'semgrep-cli
-                 "recursive target contains a directory cycle"
-                 relative-dir))
-             (set! seen (cons key seen))
-             (let loop ([entries (secure-directory-list
-                                   (cli-root-handle root)
-                                   relative-dir
-                                   max-directory-entries
-                                   max-directory-list-bytes)]
-                        [current-acc acc])
-               (if (null? entries)
-                   current-acc
-                   (let* ([name (car entries)]
-                          [relative (if (string=? relative-dir "")
-                                        name
-                                        (sg-path-join relative-dir name))]
-                          [display (sg-path-join dir relative)])
-                     (set! entry-count (+ entry-count 1))
-                     (set! path-char-count
-                       (+ path-char-count (string-length relative)))
-                     (when (> entry-count max-recursive-entries)
-                       (error 'semgrep-cli
-                         "recursive target exceeds entry limit"
-                         max-recursive-entries))
-                     (when (> path-char-count max-recursive-path-chars)
-                       (error 'semgrep-cli
-                         "recursive target exceeds path-byte budget"
-                         max-recursive-path-chars))
-                     (if (ignored-path? ignore-patterns relative name)
-                         (loop (cdr entries) current-acc)
-                         (let ([info (secure-entry-info
-                                       (cli-root-handle root)
-                                       relative)])
-                           (case (secure-entry-kind info)
-                             [(symlink) (loop (cdr entries) current-acc)]
-                             [(directory)
-                              (if (skip-directory-name? name)
-                                  (loop (cdr entries) current-acc)
-                                  (loop
-                                    (cdr entries)
-                                    (walk
-                                      relative
-                                      (+ depth 1)
-                                      current-acc)))]
-                             [(file)
-                              (when (> (secure-entry-links info) 1)
-                                (error 'semgrep-cli
-                                  "recursive target contains a hard-linked file"
-                                  display))
-                              (loop
-                                (cdr entries)
-                                (if (scannable-file? language-opt display)
-                                    (cons
-                                      (make-cli-target
-                                        root
-                                        relative
-                                        display)
-                                      current-acc)
-                                    current-acc))]
-                             [else
-                              (loop (cdr entries) current-acc)]))))))))))
+         (let ([expanded (let walk ([relative-dir ""]
+                                    [depth 0]
+                                    [acc '()])
+                           (when (> depth max-recursive-depth)
+                             (error 'semgrep-cli
+                               "recursive target exceeds depth limit"
+                               max-recursive-depth))
+                           (let* ([directory-info (secure-entry-info
+                                                    (cli-root-handle root)
+                                                    relative-dir)]
+                                  [key (identity-key directory-info)])
+                             (unless (eq? (secure-entry-kind
+                                            directory-info)
+                                          'directory)
+                               (error 'semgrep-cli
+                                 "recursive target changed type"
+                                 relative-dir))
+                             (when (identity-seen? key seen)
+                               (error 'semgrep-cli
+                                 "recursive target contains a directory cycle"
+                                 relative-dir))
+                             (set! seen (cons key seen))
+                             (let loop ([entries (secure-directory-list
+                                                   (cli-root-handle root)
+                                                   relative-dir
+                                                   max-directory-entries
+                                                   max-directory-list-bytes)]
+                                        [current-acc acc])
+                               (if (null? entries)
+                                   current-acc
+                                   (let* ([name (car entries)]
+                                          [relative (if (string=?
+                                                          relative-dir
+                                                          "")
+                                                        name
+                                                        (sg-path-join
+                                                          relative-dir
+                                                          name))]
+                                          [display (sg-path-join
+                                                     dir
+                                                     relative)])
+                                     (set! entry-count (+ entry-count 1))
+                                     (set! path-char-count
+                                       (+ path-char-count
+                                          (string-length relative)))
+                                     (when (> entry-count
+                                              max-recursive-entries)
+                                       (error 'semgrep-cli
+                                         "recursive target exceeds entry limit"
+                                         max-recursive-entries))
+                                     (when (> path-char-count
+                                              max-recursive-path-chars)
+                                       (error 'semgrep-cli
+                                         "recursive target exceeds path-byte budget"
+                                         max-recursive-path-chars))
+                                     (if (ignored-path?
+                                           ignore-patterns
+                                           relative
+                                           name)
+                                         (begin
+                                           (set! ignored-paths
+                                             (cons display ignored-paths))
+                                           (loop
+                                             (cdr entries)
+                                             current-acc))
+                                         (let ([info (secure-entry-info
+                                                       (cli-root-handle
+                                                         root)
+                                                       relative)])
+                                           (case (secure-entry-kind info)
+                                             [(symlink)
+                                              (loop
+                                                (cdr entries)
+                                                current-acc)]
+                                             [(directory)
+                                              (if (skip-directory-name?
+                                                    name)
+                                                  (loop
+                                                    (cdr entries)
+                                                    current-acc)
+                                                  (loop
+                                                    (cdr entries)
+                                                    (walk
+                                                      relative
+                                                      (+ depth 1)
+                                                      current-acc)))]
+                                             [(file)
+                                              (when (> (secure-entry-links
+                                                         info)
+                                                       1)
+                                                (error 'semgrep-cli
+                                                  "recursive target contains a hard-linked file"
+                                                  display))
+                                              (loop
+                                                (cdr entries)
+                                                (if (scannable-file?
+                                                      language-opt
+                                                      display)
+                                                    (cons
+                                                      (make-cli-target
+                                                        root
+                                                        relative
+                                                        display)
+                                                      current-acc)
+                                                    current-acc))]
+                                             [else
+                                              (loop
+                                                (cdr entries)
+                                                current-acc)]))))))))])
+           (when (and use-ignore? (pair? ignored-paths))
+             (report-ignored-paths dir (reverse ignored-paths)))
+           expanded)))
   (def (expand-file-target target)
        (let-values ([(parent name) (path-parent-and-name target)])
          (let ([handle (secure-directory-open parent #f)])
@@ -342,7 +408,7 @@
                (values
                  (list (make-cli-target root name target))
                  (list root)))))))
-  (def (expand-target language-opt target)
+  (def (expand-target language-opt use-ignore? target)
        (if (string=? target "-")
            (values (list target) '())
            (guard (directory-error [#t (expand-file-target target)])
@@ -357,9 +423,11 @@
                      (expand-open-directory-target
                        language-opt
                        target
-                       root))
+                       root
+                       use-ignore?))
                    (list root)))))))
-  (def (expand-targets language-opt includes excludes targets)
+  (def (expand-targets language-opt use-ignore? includes
+         excludes targets)
        (let ([opened-roots '()])
          (guard (failure
                   [#t
@@ -382,6 +450,7 @@
                  (let-values ([(next-targets next-roots)
                                (expand-target
                                  language-opt
+                                 use-ignore?
                                  (car remaining))])
                    (set! opened-roots (append next-roots opened-roots))
                    (loop
@@ -493,7 +562,7 @@
                  (member "--help" args)
                  (member "-h" args))
              (begin (usage) 0)
-             (let-values ([(config language format severities autofix? includes excludes targets)
+             (let-values ([(config language format severities autofix? use-ignore? includes excludes targets)
                            (parse-args args)])
                (unless config
                  (usage)
@@ -503,11 +572,8 @@
                  (error 'semgrep-cli "missing target"))
                (let ([rules (parse-config-file config)])
                  (let-values ([(expanded-targets roots)
-                               (expand-targets
-                                 language
-                                 includes
-                                 excludes
-                                 targets)])
+                               (expand-targets language use-ignore?
+                                 includes excludes targets)])
                    (dynamic-wind
                      (lambda () (void))
                      (lambda ()
diff --git a/src/.jerbuild-hashes b/src/.jerbuild-hashes
index e6e11c0..cddbbb2 100644
--- a/src/.jerbuild-hashes
+++ b/src/.jerbuild-hashes
@@ -34,7 +34,7 @@
    .
    "285FDB423AD7E28C")
  ("src/semgrep/result/builders.ss" . "93A64AF4435E6132")
- ("src/semgrep/cli.ss" . "DF74D39C29AC0141")
+ ("src/semgrep/cli.ss" . "6145E877B2DA1583")
  ("src/semgrep/output/sarif.ss" . "740FF3708E6C1BB")
  ("src/semgrep/engine/js-vardef-scan.ss"
    .
diff --git a/src/semgrep/cli.ss b/src/semgrep/cli.ss
index a8d8da3..bd4a85b 100644
--- a/src/semgrep/cli.ss
+++ b/src/semgrep/cli.ss
@@ -30,9 +30,10 @@
   (if (cli-target? target) (cli-target-display target) target))
 
 (def (usage)
-  (display "usage: semgrep scan --config RULES.yml [--lang LANG] [--severity LEVEL] [--include GLOB] [--exclude GLOB] TARGET\n")
+  (display "usage: semgrep scan --config RULES.yml [--lang LANG] [--severity LEVEL] [--include GLOB] [--exclude GLOB] [--use-semgrepignore] TARGET\n")
   (display "\n")
-  (display "MVP support: YAML configs with pattern/pattern-regex rules; json/python/javascript/dockerfile/html/xml/yaml/c/terraform/php/java/csharp/swift/dart/move/julia/kotlin/ruby/rust/go/scala/cpp targets.\n"))
+  (display "MVP support: YAML configs with pattern/pattern-regex rules; json/python/javascript/dockerfile/html/xml/yaml/c/terraform/php/java/csharp/swift/dart/move/julia/kotlin/ruby/rust/go/scala/cpp targets.\n")
+  (display "Ignore rules from a scanned tree's .semgrepignore are untrusted and only honored with --use-semgrepignore.\n"))
 
 (def (parse-args args)
   (let loop ([xs args]
@@ -41,6 +42,7 @@
              [format 'json]
              [severities '()]
              [autofix? #f]
+             [use-ignore? #f]
              [includes '()]
              [excludes '()]
              [targets '()])
@@ -51,82 +53,90 @@
                format
                (reverse severities)
                autofix?
+               use-ignore?
                (reverse includes)
                (reverse excludes)
                (reverse targets))]
       [(string=? (car xs) "--")
-       (values config
-               language
-               format
-               (reverse severities)
-               autofix?
-               (reverse includes)
-               (reverse excludes)
-               (append (reverse targets) (cdr xs)))]
+        (values config
+                language
+                format
+                (reverse severities)
+                autofix?
+                use-ignore?
+                (reverse includes)
+                (reverse excludes)
+                (append (reverse targets) (cdr xs)))]
       [(string=? (car xs) "--json")
-       (loop (cdr xs) config language 'json severities autofix? includes excludes targets)]
+        (loop (cdr xs) config language 'json severities autofix? use-ignore? includes excludes targets)]
       [(string=? (car xs) "--sarif")
-       (loop (cdr xs) config language 'sarif severities autofix? includes excludes targets)]
+        (loop (cdr xs) config language 'sarif severities autofix? use-ignore? includes excludes targets)]
       [(string=? (car xs) "--text")
-       (loop (cdr xs) config language 'text severities autofix? includes excludes targets)]
+        (loop (cdr xs) config language 'text severities autofix? use-ignore? includes excludes targets)]
       [(string=? (car xs) "--autofix")
-       (loop (cdr xs) config language format severities #t includes excludes targets)]
+        (loop (cdr xs) config language format severities #t use-ignore? includes excludes targets)]
+      [(string=? (car xs) "--use-semgrepignore")
+        (loop (cdr xs) config language format severities autofix? #t includes excludes targets)]
       [(string=? (car xs) "--config")
-       (when (null? (cdr xs)) (error 'semgrep-cli "--config needs a value"))
-       (loop (cddr xs) (cadr xs) language format severities autofix? includes excludes targets)]
+        (when (null? (cdr xs)) (error 'semgrep-cli "--config needs a value"))
+        (loop (cddr xs) (cadr xs) language format severities autofix? use-ignore? includes excludes targets)]
       [(string=? (car xs) "-c")
-       (when (null? (cdr xs)) (error 'semgrep-cli "-c needs a value"))
-       (loop (cddr xs) (cadr xs) language format severities autofix? includes excludes targets)]
+        (when (null? (cdr xs)) (error 'semgrep-cli "-c needs a value"))
+        (loop (cddr xs) (cadr xs) language format severities autofix? use-ignore? includes excludes targets)]
       [(string=? (car xs) "--lang")
-       (when (null? (cdr xs)) (error 'semgrep-cli "--lang needs a value"))
-       (loop (cddr xs) config (cadr xs) format severities autofix? includes excludes targets)]
+        (when (null? (cdr xs)) (error 'semgrep-cli "--lang needs a value"))
+        (loop (cddr xs) config (cadr xs) format severities autofix? use-ignore? includes excludes targets)]
       [(string=? (car xs) "--severity")
-       (when (null? (cdr xs)) (error 'semgrep-cli "--severity needs a value"))
-       (loop (cddr xs)
-             config
-             language
-             format
-             (cons (cadr xs) severities)
-             autofix?
-             includes
-             excludes
-             targets)]
+        (when (null? (cdr xs)) (error 'semgrep-cli "--severity needs a value"))
+        (loop (cddr xs)
+              config
+              language
+              format
+              (cons (cadr xs) severities)
+              autofix?
+              use-ignore?
+              includes
+              excludes
+              targets)]
       [(string=? (car xs) "--include")
-       (when (null? (cdr xs)) (error 'semgrep-cli "--include needs a value"))
-       (loop (cddr xs)
-             config
-             language
-             format
-             severities
-             autofix?
-             (cons (cadr xs) includes)
-             excludes
-             targets)]
+        (when (null? (cdr xs)) (error 'semgrep-cli "--include needs a value"))
+        (loop (cddr xs)
+              config
+              language
+              format
+              severities
+              autofix?
+              use-ignore?
+              (cons (cadr xs) includes)
+              excludes
+              targets)]
       [(string=? (car xs) "--exclude")
-       (when (null? (cdr xs)) (error 'semgrep-cli "--exclude needs a value"))
-       (loop (cddr xs)
-             config
-             language
-             format
-             severities
-             autofix?
-             includes
-             (cons (cadr xs) excludes)
-             targets)]
+        (when (null? (cdr xs)) (error 'semgrep-cli "--exclude needs a value"))
+        (loop (cddr xs)
+              config
+              language
+              format
+              severities
+              autofix?
+              use-ignore?
+              includes
+              (cons (cadr xs) excludes)
+              targets)]
       [(and (> (string-length (car xs)) 0)
             (not (string=? (car xs) "-"))
             (char=? (string-ref (car xs) 0) #\-))
-       (error 'semgrep-cli "unknown option" (car xs))]
+        (error 'semgrep-cli "unknown option" (car xs))]
       [else
-       (loop (cdr xs)
-             config
-             language
-             format
-             severities
-             autofix?
-             includes
-             excludes
-             (cons (car xs) targets))])))
+        (loop (cdr xs)
+              config
+              language
+              format
+              severities
+              autofix?
+              use-ignore?
+              includes
+              excludes
+              (cons (car xs) targets))])))
 
 (def (format-findings format findings)
   (case format
@@ -207,6 +217,21 @@
             [else (loop (cdr lines) (cons (car lines) acc))]))
         '())))
 
+(def (report-ignored-paths dir paths)
+  (let ([port (current-error-port)])
+    (display "semgrep: .semgrepignore (opt-in) ignored " port)
+    (display (length paths) port)
+    (display " path(s) under " port)
+    (display dir port)
+    (display ":" port)
+    (newline port)
+    (for-each (lambda (path)
+                (display "  " port)
+                (display path port)
+                (newline port))
+              paths)
+    (flush-output-port port)))
+
 (def (ignore-pattern-matches? pattern relative name)
   (or (glob-match? pattern relative)
       (and (not (sg-string-contains-char? pattern #\/))
@@ -283,70 +308,77 @@
          (or (equal? key (car xs))
              (loop (cdr xs))))))
 
-(def (expand-open-directory-target language-opt dir root)
-  (let ([ignore-patterns (read-semgrepignore dir)]
+(def (expand-open-directory-target language-opt dir root use-ignore?)
+  (let ([ignore-patterns (if use-ignore? (read-semgrepignore dir) '())]
         [entry-count 0]
         [path-char-count 0]
+        [ignored-paths '()]
         [seen '()])
-    (let walk ([relative-dir ""] [depth 0] [acc '()])
-      (when (> depth max-recursive-depth)
-        (error 'semgrep-cli "recursive target exceeds depth limit"
-               max-recursive-depth))
-      (let* ([directory-info
-              (secure-entry-info (cli-root-handle root) relative-dir)]
-             [key (identity-key directory-info)])
-        (unless (eq? (secure-entry-kind directory-info) 'directory)
-          (error 'semgrep-cli "recursive target changed type" relative-dir))
-        (when (identity-seen? key seen)
-          (error 'semgrep-cli "recursive target contains a directory cycle"
-                 relative-dir))
-        (set! seen (cons key seen))
-        (let loop ([entries (secure-directory-list
-                              (cli-root-handle root)
-                              relative-dir
-                              max-directory-entries
-                              max-directory-list-bytes)]
-                   [current-acc acc])
-          (if (null? entries)
-              current-acc
-              (let* ([name (car entries)]
-                     [relative (if (string=? relative-dir "")
-                                   name
-                                   (sg-path-join relative-dir name))]
-                     [display (sg-path-join dir relative)])
-                (set! entry-count (+ entry-count 1))
-                (set! path-char-count
-                      (+ path-char-count (string-length relative)))
-                (when (> entry-count max-recursive-entries)
-                  (error 'semgrep-cli "recursive target exceeds entry limit"
-                         max-recursive-entries))
-                (when (> path-char-count max-recursive-path-chars)
-                  (error 'semgrep-cli "recursive target exceeds path-byte budget"
-                         max-recursive-path-chars))
-                (if (ignored-path? ignore-patterns relative name)
-                    (loop (cdr entries) current-acc)
-                    (let ([info (secure-entry-info
-                                  (cli-root-handle root) relative)])
-                      (case (secure-entry-kind info)
-                        [(symlink)
-                         (loop (cdr entries) current-acc)]
-                        [(directory)
-                         (if (skip-directory-name? name)
-                             (loop (cdr entries) current-acc)
-                             (loop (cdr entries)
-                                   (walk relative (+ depth 1) current-acc)))]
-                        [(file)
-                         (when (> (secure-entry-links info) 1)
-                           (error 'semgrep-cli
-                                  "recursive target contains a hard-linked file"
-                                  display))
-                         (loop
-                           (cdr entries)
-                           (if (scannable-file? language-opt display)
-                               (cons (make-cli-target root relative display)
-                                     current-acc)
-                               current-acc))]
-                        [else (loop (cdr entries) current-acc)]))))))))))
+    (let ([expanded
+           (let walk ([relative-dir ""] [depth 0] [acc '()])
+             (when (> depth max-recursive-depth)
+               (error 'semgrep-cli "recursive target exceeds depth limit"
+                      max-recursive-depth))
+             (let* ([directory-info
+                     (secure-entry-info (cli-root-handle root) relative-dir)]
+                    [key (identity-key directory-info)])
+               (unless (eq? (secure-entry-kind directory-info) 'directory)
+                 (error 'semgrep-cli "recursive target changed type" relative-dir))
+               (when (identity-seen? key seen)
+                 (error 'semgrep-cli "recursive target contains a directory cycle"
+                        relative-dir))
+               (set! seen (cons key seen))
+               (let loop ([entries (secure-directory-list
+                                     (cli-root-handle root)
+                                     relative-dir
+                                     max-directory-entries
+                                     max-directory-list-bytes)]
+                          [current-acc acc])
+                 (if (null? entries)
+                     current-acc
+                     (let* ([name (car entries)]
+                            [relative (if (string=? relative-dir "")
+                                          name
+                                          (sg-path-join relative-dir name))]
+                            [display (sg-path-join dir relative)])
+                       (set! entry-count (+ entry-count 1))
+                       (set! path-char-count
+                             (+ path-char-count (string-length relative)))
+                       (when (> entry-count max-recursive-entries)
+                         (error 'semgrep-cli "recursive target exceeds entry limit"
+                                max-recursive-entries))
+                       (when (> path-char-count max-recursive-path-chars)
+                         (error 'semgrep-cli "recursive target exceeds path-byte budget"
+                                max-recursive-path-chars))
+                       (if (ignored-path? ignore-patterns relative name)
+                           (begin
+                             (set! ignored-paths (cons display ignored-paths))
+                             (loop (cdr entries) current-acc))
+                           (let ([info (secure-entry-info
+                                         (cli-root-handle root) relative)])
+                             (case (secure-entry-kind info)
+                               [(symlink)
+                                (loop (cdr entries) current-acc)]
+                               [(directory)
+                                (if (skip-directory-name? name)
+                                    (loop (cdr entries) current-acc)
+                                    (loop (cdr entries)
+                                          (walk relative (+ depth 1) current-acc)))]
+                               [(file)
+                                (when (> (secure-entry-links info) 1)
+                                  (error 'semgrep-cli
+                                         "recursive target contains a hard-linked file"
+                                         display))
+                                (loop
+                                  (cdr entries)
+                                  (if (scannable-file? language-opt display)
+                                      (cons (make-cli-target root relative display)
+                                            current-acc)
+                                      current-acc))]
+                               [else (loop (cdr entries) current-acc)]))))))))])
+      (when (and use-ignore? (pair? ignored-paths))
+        (report-ignored-paths dir (reverse ignored-paths)))
+      expanded)))
 
 (def (expand-file-target target)
   (let-values ([(parent name) (path-parent-and-name target)])
@@ -363,7 +395,7 @@
             (error 'semgrep-cli "target has multiple hard links" target))
           (values (list (make-cli-target root name target)) (list root)))))))
 
-(def (expand-target language-opt target)
+(def (expand-target language-opt use-ignore? target)
   (if (string=? target "-")
       (values (list target) '())
       (guard (directory-error
@@ -376,10 +408,10 @@
                     (raise walk-error)])
             (values
               (reverse (expand-open-directory-target
-                         language-opt target root))
+                          language-opt target root use-ignore?))
               (list root)))))))
 
-(def (expand-targets language-opt includes excludes targets)
+(def (expand-targets language-opt use-ignore? includes excludes targets)
   (let ([opened-roots '()])
     (guard (failure
              [#t
@@ -397,7 +429,7 @@
                       (reverse expanded))
               opened-roots)
             (let-values ([(next-targets next-roots)
-                          (expand-target language-opt (car remaining))])
+                          (expand-target language-opt use-ignore? (car remaining))])
               (set! opened-roots (append next-roots opened-roots))
               (loop (cdr remaining)
                     (append (reverse next-targets) expanded))))))))
@@ -506,7 +538,7 @@
             (member "--help" args)
             (member "-h" args))
         (begin (usage) 0)
-        (let-values ([(config language format severities autofix? includes excludes targets)
+        (let-values ([(config language format severities autofix? use-ignore? includes excludes targets)
                       (parse-args args)])
           (unless config
             (usage)
@@ -516,7 +548,7 @@
             (error 'semgrep-cli "missing target"))
           (let ([rules (parse-config-file config)])
             (let-values ([(expanded-targets roots)
-                          (expand-targets language includes excludes targets)])
+                          (expand-targets language use-ignore? includes excludes targets)])
             (dynamic-wind
               (lambda () (void))
               (lambda ()
diff --git a/tests/ignorehostile/.semgrepignore b/tests/ignorehostile/.semgrepignore
new file mode 100644
index 0000000..f104652
--- /dev/null
+++ b/tests/ignorehostile/.semgrepignore
@@ -0,0 +1 @@
+*.py
diff --git a/tests/ignorehostile/payload.py b/tests/ignorehostile/payload.py
new file mode 100644
index 0000000..a231016
--- /dev/null
+++ b/tests/ignorehostile/payload.py
@@ -0,0 +1 @@
+eval(payload)
diff --git a/tests/smoke.ss b/tests/smoke.ss
index 3938cd4..fb65e60 100644
--- a/tests/smoke.ss
+++ b/tests/smoke.ss
@@ -57,6 +57,14 @@
                 (or (string=? (substring haystack i (+ i nlen)) needle)
                     (loop (+ i 1))))))))
 
+(define (call-with-captured-output thunk)
+  (let ([out (open-output-string)]
+        [err (open-output-string)])
+    (let ([code (parameterize ([current-output-port out]
+                               [current-error-port err])
+                  (thunk))])
+      (values code (get-output-string out) (get-output-string err)))))
+
 (define (count-findings pred findings)
   (let loop ([xs findings] [count 0])
     (cond
@@ -5245,9 +5253,39 @@
   (check (main '("scan" "--config" "tests/fixtures/eval.yml" "tests/fixtures"))
          => 1))
 
+(test-case "cli main does not auto-trust a hostile semgrepignore by default"
+  (let-values ([(code out err)
+                (call-with-captured-output
+                  (lambda ()
+                    (main '("scan" "--json" "--config" "tests/fixtures/eval.yml"
+                           "tests/ignorehostile"))))])
+    (check code => 1)
+    (check (string-contains? out "payload.py") => #t)
+    (check (string-contains? err "ignored") => #f)))
+
+(test-case "cli main honors semgrepignore only when opted in"
+  (let-values ([(code out err)
+                (call-with-captured-output
+                  (lambda ()
+                    (main '("scan" "--json" "--use-semgrepignore"
+                           "--config" "tests/fixtures/eval.yml"
+                           "tests/ignorehostile"))))])
+    (check code => 0)
+    (check (string-contains? out "payload.py") => #f)
+    (check (string-contains? err "payload.py") => #t)
+    (check (string-contains? err "ignored") => #t)))
+
 (test-case "cli main honors semgrepignore negation"
-  (check (main '("scan" "--config" "tests/fixtures/eval.yml" "tests/ignorefixtures"))
-         => 1))
+  (let-values ([(code out err)
+                (call-with-captured-output
+                  (lambda ()
+                    (main '("scan" "--json" "--use-semgrepignore"
+                           "--config" "tests/fixtures/eval.yml"
+                           "tests/ignorefixtures"))))])
+    (check code => 1)
+    (check (string-contains? out "keep.py") => #t)
+    (check (string-contains? out "skip.py") => #f)
+    (check (string-contains? err "skip.py") => #t)))
 
 (test-case "cli main accepts json flag"
   (check (main '("scan" "--json" "--config" "tests/fixtures/eval.yml" "tests/fixtures/demo.py"))
@@ -5257,6 +5295,7 @@
   (check (main '("scan" "--config" "tests/fixtures/eval.yml"
                  "--include" "*.py"
                  "--exclude" "demo.py"
+                 "--exclude" "ignored.py"
                  "tests/fixtures"))
          => 0))