fix(config): refuse detection-disabling keys from in-repo .gitsafe.json
ober
59020de280d4fc8fa29c78c361560f5f0b777b6a
--- 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)) --- 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 --- 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))))) --- 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?))))) + )) ;; ============================================================