fix(config): refuse detection-disabling keys from in-repo .gitsafe.json

ober

59020de280d4fc8fa29c78c361560f5f0b777b6a

diff --git a/gitsafe/allowlist.ss b/gitsafe/allowlist.ss
index 1868532..514cd98 100644
--- a/gitsafe/allowlist.ss
+++ b/gitsafe/allowlist.ss
@@ -45,9 +45,13 @@
             [else (string=? specific (symbol->string pattern-id))])))))
 
   ;; --- Allowlist string check ---
-  ;; Returns #t if the matched text contains any known-safe string.
+  ;; Returns #t if the matched text contains any known-safe string. An empty
+  ;; or whitespace-only pattern would match every secret (string-contains
+  ;; returns 0) and suppress all findings, so such patterns are ignored.
   (def (allowlisted? matched-text config)
-    (and (any (lambda (s) (string-contains matched-text s))
+    (and (any (lambda (s)
+                (and (not (string-empty? (string-trim s)))
+                     (string-contains matched-text s)))
               (gitsafe-config-allowlist-strings config))
          #t))
 
diff --git a/gitsafe/cli-common.ss b/gitsafe/cli-common.ss
index d0bbb19..1c851a5 100644
--- a/gitsafe/cli-common.ss
+++ b/gitsafe/cli-common.ss
@@ -282,8 +282,12 @@
            [remote-ref  (list-ref parsed 8)]
            [decode-depth (list-ref parsed 9)]
            [baseline-path (list-ref parsed 10)]
-           [config      (let ([c (load-config cfg-path)])
-                          ;; Override config fields from CLI flags
+            ;; A config the operator named explicitly (--config) is trusted;
+            ;; the default .gitsafe.json auto-loaded from the scanned tree is
+            ;; hostile input and is sanitized by load-config.
+            [config      (let ([c (load-config cfg-path
+                                      (and (member "--config" args) #t))])
+                           ;; Override config fields from CLI flags
                           (make-gitsafe-config
                             (if severity
                               (match severity
diff --git a/gitsafe/config.ss b/gitsafe/config.ss
index 9e12c98..e5c3840 100644
--- a/gitsafe/config.ss
+++ b/gitsafe/config.ss
@@ -17,7 +17,8 @@
           load-config
           load-baseline-fingerprints
           config-excluded?
-          glob-match?)
+          glob-match?
+          match-all-glob?)
   (import (except (scheme)
                   make-hash-table hash-table?
                   sort sort!
@@ -111,6 +112,36 @@
     (any (lambda (glob) (glob-match? glob path))
          (gitsafe-config-exclude-globs config)))
 
+  ;; --- Match-all glob detection ---
+  ;; A glob that matches arbitrary paths (e.g. "**") would let a hostile
+  ;; in-repo config exclude every file. A glob counts as match-all only when
+  ;; it matches several unrelated probe paths, so narrow excludes such as
+  ;; "vendor/**" or "**/*.lock" are preserved.
+  (def *match-all-probes*
+    '("secret.env" "src/config/secret.env" "a/b/c/d.txt"))
+
+  (def (match-all-glob? glob)
+    (let loop ([probes *match-all-probes*])
+      (cond
+        [(null? probes) #t]
+        [(glob-match? glob (car probes)) (loop (cdr probes))]
+        [else #f])))
+
+  ;; An empty/whitespace allowlist string matches every secret via
+  ;; string-contains and would suppress all findings; it is never valid.
+  (def (non-empty-string? s)
+    (and (string? s) (not (string-empty? (string-trim s)))))
+
+  ;; Floor for max_file_size_mb honored from an untrusted config. A tiny
+  ;; positive cap skips normal source files; 0 (no limit) and values at or
+  ;; above the floor scan at least as much as the default and are kept.
+  (def *untrusted-min-file-size-mb* 10)
+
+  (def (sanitize-file-size-mb mb)
+    (if (and (> mb 0) (< mb *untrusted-min-file-size-mb*))
+      *untrusted-min-file-size-mb*
+      mb))
+
   ;; --- Parse severity string to symbol ---
   (def (parse-severity s)
     (match s
@@ -197,7 +228,11 @@
           '()))))
 
   ;; --- Load config from file ---
-  (def (load-config (path ".gitsafe.json"))
+  ;; TRUSTED? distinguishes a config the operator chose explicitly (e.g. via
+  ;; --config) from one auto-loaded out of the scanned tree. The scanned tree
+  ;; is hostile input: an in-repo .gitsafe.json must not be able to disable
+  ;; detection, so security-critical keys are refused when TRUSTED? is false.
+  (def (load-config (path ".gitsafe.json") (trusted? #f))
     (if (not (file-exists? path))
       (default-config)
       (try
@@ -241,13 +276,33 @@
                                (hash-ref obj "max_decode_depth" 0)
                                0)]
                [baseline-path (hash-ref obj "baseline" #f)]
-               [baseline-fps (if (string? baseline-path)
+               ;; A baseline named inside the scanned tree is hostile input;
+               ;; only honor it from a trusted config.
+               [baseline-fps (if (and trusted? (string? baseline-path))
                                (load-baseline-fingerprints baseline-path)
                                '())])
-          (make-gitsafe-config
-            severity entropy disabled custom
-            excludes al-files al-strs
-            max-mb ml-detect decode-depth baseline-fps))
+          (if trusted?
+            (make-gitsafe-config
+              severity entropy disabled custom
+              excludes al-files (filter non-empty-string? al-strs)
+              max-mb ml-detect decode-depth baseline-fps)
+            ;; Untrusted (in-repo) config: refuse the keys that disable
+            ;; detection. Narrow excludes/allowlists and custom rules (which
+            ;; only add detection) still apply; match-all globs, severity
+            ;; overrides, pattern disabling, entropy disabling, sub-floor size
+            ;; caps, empty allowlist strings, and in-tree baselines do not.
+            (make-gitsafe-config
+              'medium
+              #t
+              '()
+              custom
+              (filter (lambda (g) (not (match-all-glob? g))) excludes)
+              (filter (lambda (g) (not (match-all-glob? g))) al-files)
+              (filter non-empty-string? al-strs)
+              (sanitize-file-size-mb max-mb)
+              ml-detect
+              decode-depth
+              '())))
         (catch (e)
           (displayln "gitsafe: warning: could not parse .gitsafe.json, using defaults")
           (default-config)))))
diff --git a/test/test-gitsafe.ss b/test/test-gitsafe.ss
index 3bb3368..c960aa2 100644
--- a/test/test-gitsafe.ss
+++ b/test/test-gitsafe.ss
@@ -225,6 +225,67 @@
         (check-equal? #t (skip-file? "fixtures/example.env" c))
         (check-equal? #f (skip-file? "src/main.ss" c))))
 
+    (test-case "untrusted config: empty allowlist pattern does not suppress secrets"
+      ;; allowlist.patterns:[""] used to match every secret (string-contains
+      ;; returns 0) and silence all findings.
+      (let ([cfg-path "test/fixtures/tmp-empty-allowlist.gitsafe.json"])
+        (write-test-file! cfg-path "{\"allowlist\":{\"patterns\":[\"\"]}}\n")
+        (let* ([c (load-config cfg-path)]
+               [findings (scan-content "app.env"
+                           "aws_key = AKIAIOSFODNN7EXAMPLE\n" c)]
+               [aws (filter (lambda (f) (eq? (finding-pattern-id f) 'aws-access-key))
+                            findings)])
+          (delete-test-file! cfg-path)
+          (check-predicate aws pair?))))
+
+    (test-case "untrusted config: exclude ** is refused"
+      (let ([cfg-path "test/fixtures/tmp-exclude-all.gitsafe.json"])
+        (write-test-file! cfg-path "{\"exclude\":[\"**\"]}\n")
+        (let ([c (load-config cfg-path)])
+          (delete-test-file! cfg-path)
+          (check-equal? #f (config-excluded? c "src/secret.env"))
+          (check-equal? #f (skip-file? "src/secret.env" c)))))
+
+    (test-case "untrusted config: narrow exclude still honored"
+      (let ([cfg-path "test/fixtures/tmp-narrow-exclude.gitsafe.json"])
+        (write-test-file! cfg-path "{\"exclude\":[\"vendor/**\"]}\n")
+        (let ([c (load-config cfg-path)])
+          (delete-test-file! cfg-path)
+          (check-equal? #t (config-excluded? c "vendor/foo.go"))
+          (check-equal? #f (config-excluded? c "src/main.ss")))))
+
+    (test-case "untrusted config: severity override is refused"
+      ;; severity:"critical" used to silence high/medium patterns.
+      (let ([cfg-path "test/fixtures/tmp-severity.gitsafe.json"])
+        (write-test-file! cfg-path "{\"severity\":\"critical\"}\n")
+        (let* ([c (load-config cfg-path)]
+               [findings (scan-content "app.env"
+                           "aws_key = AKIAIOSFODNN7EXAMPLE\n" c)]
+               [aws (filter (lambda (f) (eq? (finding-pattern-id f) 'aws-access-key))
+                            findings)])
+          (delete-test-file! cfg-path)
+          (check-eq? 'medium (gitsafe-config-severity c))
+          (check-predicate aws pair?))))
+
+    (test-case "untrusted config: in-tree baseline is not honored"
+      (let ([cfg-path "test/fixtures/tmp-baseline-cfg.gitsafe.json"]
+            [report-path "test/fixtures/tmp-baseline-report.json"]
+            [secret-path "test/fixtures/tmp-baseline-secret2.env"])
+        (write-test-file! secret-path "AKIAIOSFODNN7EXAMPLE\n")
+        (let ([fp (finding-fingerprint-value secret-path 'aws-access-key 1)])
+          (write-test-file! report-path
+            (string-append "{\"findings\":[{\"fingerprint\":\"" fp "\"}]}\n"))
+          (write-test-file! cfg-path
+            (string-append "{\"baseline\":\"" report-path "\"}\n"))
+          (let* ([c (load-config cfg-path)]
+                 [findings (scan-files (list secret-path) c)]
+                 [aws (filter (lambda (f) (eq? (finding-pattern-id f) 'aws-access-key))
+                              findings)])
+            (delete-test-file! cfg-path)
+            (delete-test-file! report-path)
+            (delete-test-file! secret-path)
+            (check-predicate aws pair?)))))
+
   ))
 
 ;; ============================================================