|
2 | 2 | (:require |
3 | 3 | [br.dev.zz.parc :as parc] |
4 | 4 | [clojure.java.io :as io] |
5 | | - [clojure.java.process :as process] |
6 | 5 | [clojure.string :as string] |
7 | 6 | [eca.logger :as logger] |
8 | 7 | [eca.shared :as shared]) |
|
15 | 14 |
|
16 | 15 | (def ^:private logger-tag "[secrets]") |
17 | 16 |
|
18 | | -(defn ^:private netrc-path? [path] |
19 | | - (string/includes? (string/lower-case (.getName (io/file path))) "netrc")) |
20 | | - |
21 | 17 | (defn ^:private normalize-port |
22 | 18 | [port] |
23 | 19 | (cond |
|
28 | 24 |
|
29 | 25 | (defn credential-file-paths |
30 | 26 | "Returns ordered list of credential file paths to check." |
31 | | - [] |
32 | | - (let [home (System/getProperty "user.home") |
33 | | - files [".authinfo" "_authinfo" ".netrc" "_netrc"]] |
34 | | - (mapcat (fn [filename] |
35 | | - (let [path (str (io/file home filename))] |
36 | | - [(str path ".gpg") path])) |
37 | | - files))) |
38 | | - |
39 | | -(defn ^:private validate-permissions [^File file] |
| 27 | + [netrc-file] |
| 28 | + (if (some? netrc-file) |
| 29 | + [netrc-file] |
| 30 | + (let [home (System/getProperty "user.home") |
| 31 | + files [".netrc" "_netrc"]] |
| 32 | + (->> files |
| 33 | + (mapv #(str (io/file home %))))))) |
| 34 | + |
| 35 | +(defn ^:private validate-permissions [^File file & {:keys [warn?] :or {warn? false}}] |
40 | 36 | (try |
41 | 37 | ;; On Unix systems, check if file is readable by others |
42 | 38 | (when-not (string/includes? (System/getProperty "os.name") "Windows") |
|
45 | 41 | (if (or (contains? perms PosixFilePermission/GROUP_READ) |
46 | 42 | (contains? perms PosixFilePermission/OTHERS_READ)) |
47 | 43 | (do |
48 | | - (logger/warn logger-tag "Credential file has insecure permissions:" |
49 | | - (.getPath file) |
50 | | - "- should be 0600 (readable only by owner)") |
| 44 | + (when warn? |
| 45 | + (logger/warn logger-tag "Credential file has insecure permissions:" |
| 46 | + (.getPath file) |
| 47 | + "- should be 0600 (readable only by owner)")) |
51 | 48 | false) |
52 | 49 | true))) |
53 | 50 | true |
54 | 51 | (catch Exception _e |
55 | 52 | ;; If permission check fails (e.g., on Windows), just proceed |
56 | 53 | true))) |
57 | 54 |
|
58 | | -(defn gpg-available? |
59 | | - "Checks if gpg command is available on system." |
60 | | - [] |
61 | | - (try (string? (process/exec "gpg" "--version")) |
62 | | - (catch Exception _e |
63 | | - false))) |
64 | | - |
65 | | -;; Cache for GPG decryption results (5-second TTL) |
66 | | -(def ^:private gpg-cache (atom {})) |
67 | | - |
68 | | -(defn ^:private gpg-cache-key [^File file] |
69 | | - (str (.getPath file) ":" (.lastModified file))) |
70 | | - |
71 | | -(defn ^:private get-cached-gpg [cache-key] |
72 | | - (when-let [{:keys [content timestamp]} (@gpg-cache cache-key)] |
73 | | - (when (< (- (System/currentTimeMillis) timestamp) 5000) ; 5-second TTL |
74 | | - (logger/debug logger-tag "GPG cache hit for" cache-key) |
75 | | - content))) |
76 | | - |
77 | | -(defn ^:private cache-gpg-result! [cache-key content] |
78 | | - (swap! gpg-cache assoc cache-key {:content content |
79 | | - :timestamp (System/currentTimeMillis)}) |
80 | | - content) |
81 | | - |
82 | | -(defn decrypt-gpg |
83 | | - "Decrypts a .gpg file using gpg command. Returns decrypted content or nil on failure. |
84 | | - Timeout: 30 seconds (configurable via GPG_TIMEOUT env var)." |
85 | | - [file-path] |
86 | | - (try |
87 | | - (let [file (io/file file-path) |
88 | | - cache-key (gpg-cache-key file)] |
89 | | - ;; Check cache first |
90 | | - (or (get-cached-gpg cache-key) |
91 | | - ;; Not in cache, decrypt using clojure.java.process directly |
92 | | - (let [timeout-seconds (try |
93 | | - (Long/parseLong (System/getenv "GPG_TIMEOUT")) |
94 | | - (catch Exception _e 30)) ; default 30 seconds |
95 | | - timeout-ms (* timeout-seconds 1000) |
96 | | - proc (process/start {:out :pipe :err :pipe} |
97 | | - "gpg" "--quiet" "--batch" "--decrypt" file-path) |
98 | | - exit (deref (process/exit-ref proc) timeout-ms ::timeout)] |
99 | | - (if (= exit ::timeout) |
100 | | - (do |
101 | | - (.destroy proc) |
102 | | - (logger/warn logger-tag "GPG decryption timed out after" timeout-seconds "seconds for" file-path) |
103 | | - nil) |
104 | | - (let [out (slurp (process/stdout proc)) |
105 | | - err (slurp (process/stderr proc))] |
106 | | - (if (zero? exit) |
107 | | - (do |
108 | | - (logger/debug logger-tag "GPG decryption successful for" file-path) |
109 | | - (cache-gpg-result! cache-key out)) |
110 | | - (do |
111 | | - (logger/warn logger-tag "GPG decryption failed for" file-path |
112 | | - "- exit code:" exit) |
113 | | - (when-not (string/blank? err) |
114 | | - (logger/debug logger-tag "GPG error:" err)) |
115 | | - nil))))))) |
116 | | - (catch Exception e |
117 | | - (logger/warn logger-tag "GPG command failed for" file-path ":" (.getMessage e)) |
118 | | - nil))) |
119 | | - |
120 | | -(defn ^:private parse-credentials [path content] |
| 55 | +(defn ^:private parse-credentials [content] |
121 | 56 | (let [entries (parc/->netrc (StringReader. (or content "")))] |
122 | | - (cond->> entries |
123 | | - (netrc-path? path) |
124 | | - (map (fn [entry] |
125 | | - (cond-> entry |
126 | | - (contains? entry :port) |
127 | | - (update :port normalize-port))))))) |
| 57 | + (map (fn [entry] |
| 58 | + (cond-> entry |
| 59 | + (contains? entry :port) |
| 60 | + (update :port normalize-port))) |
| 61 | + entries))) |
128 | 62 |
|
129 | 63 | (defn ^:private load-credentials-from-file* [^String file-path] |
130 | 64 | (try |
131 | 65 | (logger/debug logger-tag "Checking credential file:" file-path) |
132 | 66 | (let [file (io/file file-path)] |
133 | | - (when (and (.exists file) (.isFile file) (.canRead file)) |
134 | | - (let [content (if (string/ends-with? file-path ".gpg") |
135 | | - (when (gpg-available?) (decrypt-gpg file-path)) |
136 | | - (do (validate-permissions file) |
137 | | - (slurp file)))] |
| 67 | + (when (and (.exists file) (.canRead file)) |
| 68 | + (validate-permissions file :warn? true) |
| 69 | + (let [content (slurp file)] |
138 | 70 | (when (seq content) |
139 | | - (when-let [credentials (seq (parse-credentials file-path content))] |
| 71 | + (when-let [credentials (seq (parse-credentials content))] |
140 | 72 | (logger/debug logger-tag "Loaded" (count credentials) "credentials from" file-path) |
141 | 73 | (vec credentials)))))) |
142 | 74 | (catch Exception e |
|
146 | 78 | (def ^:private load-credentials-from-file |
147 | 79 | (shared/memoize-by-file-last-modified load-credentials-from-file*)) |
148 | 80 |
|
149 | | -(defn ^:private load-all-credentials [] |
| 81 | +(defn ^:private load-all-credentials [netrc-file] |
150 | 82 | (vec (mapcat #(or (load-credentials-from-file %) []) |
151 | | - (credential-file-paths)))) |
| 83 | + (credential-file-paths netrc-file)))) |
152 | 84 |
|
153 | 85 | (defn parse-key-rc |
154 | 86 | "Parses keyRc value in format [login@]machine[:port]. |
|
179 | 111 | "Retrieves password for keyRc specifier from credential files. |
180 | 112 | Format: [login@]machine[:port] |
181 | 113 | Returns password string or nil if not found." |
182 | | - [key-rc] |
183 | | - (when-let [spec (parse-key-rc key-rc)] |
184 | | - (when-let [credentials (load-all-credentials)] |
185 | | - (when-let [matched (first (filter #(match-credential % spec) credentials))] |
186 | | - (logger/debug logger-tag "Found credential for" key-rc) |
187 | | - (:password matched))))) |
| 114 | + ([key-rc] |
| 115 | + (get-credential key-rc nil)) |
| 116 | + ([key-rc netrc-file] |
| 117 | + (when-let [spec (parse-key-rc key-rc)] |
| 118 | + (when-let [credentials (load-all-credentials netrc-file)] |
| 119 | + (when-let [matched (first (filter #(match-credential % spec) credentials))] |
| 120 | + (logger/debug logger-tag "Found credential for" key-rc) |
| 121 | + (:password matched)))))) |
188 | 122 |
|
189 | 123 | (defn check-credential-files |
190 | 124 | "Performs diagnostic checks on credential files for /doctor command. |
191 | 125 | Returns a map with: |
192 | | - - :gpg-available - boolean indicating if GPG is available |
193 | 126 | - :files - vector of file check results, each containing: |
194 | 127 | - :path - file path |
195 | 128 | - :exists - boolean |
196 | 129 | - :readable - boolean (if exists) |
197 | 130 | - :permissions-secure - boolean (Unix only, if exists) |
198 | | - - :is-gpg - boolean |
199 | 131 | - :credentials-count - number of valid credentials (if readable) |
200 | | - - :parse-error - error message (if parse fails) |
201 | | - - :suggestion - optional security suggestion" |
202 | | - [] |
203 | | - (let [gpg-avail? (gpg-available?) |
204 | | - file-paths (credential-file-paths) |
| 132 | + - :parse-error - error message (if parse fails)" |
| 133 | + [netrc-file] |
| 134 | + (let [file-paths (credential-file-paths netrc-file) |
205 | 135 | file-checks (for [path file-paths] |
206 | 136 | (let [file (io/file path) |
207 | 137 | exists (.exists file) |
208 | | - is-file (and exists (.isFile file)) |
209 | | - readable (and is-file (.canRead file)) |
210 | | - is-gpg (or (string/ends-with? path ".authinfo.gpg") |
211 | | - (string/ends-with? path ".netrc.gpg")) |
212 | | - is-plaintext (and (not is-gpg) |
213 | | - (or (string/ends-with? path ".authinfo") |
214 | | - (string/ends-with? path "_authinfo") |
215 | | - (string/ends-with? path ".netrc") |
216 | | - (string/ends-with? path "_netrc")))] |
| 138 | + readable (.canRead file)] |
217 | 139 | (cond-> {:path path |
218 | | - :exists exists |
219 | | - :is-gpg is-gpg} |
| 140 | + :exists exists} |
220 | 141 | ;; Check readability |
221 | 142 | exists (assoc :readable readable) |
222 | 143 |
|
223 | 144 | ;; Check permissions (Unix only, plaintext only) |
224 | | - (and is-plaintext readable) |
| 145 | + readable |
225 | 146 | (assoc :permissions-secure |
226 | | - (try |
227 | | - (when-not (string/includes? (System/getProperty "os.name") "Windows") |
228 | | - (let [file-path (.toPath file) |
229 | | - perms (Files/getPosixFilePermissions |
230 | | - file-path |
231 | | - (into-array LinkOption []))] |
232 | | - (not (or (contains? perms PosixFilePermission/GROUP_READ) |
233 | | - (contains? perms PosixFilePermission/OTHERS_READ))))) |
234 | | - (catch Exception _e |
235 | | - true))) ; On Windows or permission check failure, consider secure |
236 | | - |
237 | | - ;; Add GPG suggestion for plaintext files |
238 | | - (and is-plaintext readable) |
239 | | - (assoc :suggestion |
240 | | - (str "Consider encrypting with GPG: " |
241 | | - "gpg --output " |
242 | | - (if (string/ends-with? path ".netrc") |
243 | | - "~/.netrc.gpg" |
244 | | - "~/.authinfo.gpg") |
245 | | - " --symmetric " path)) |
| 147 | + (validate-permissions file)) |
246 | 148 |
|
247 | 149 | ;; Try to parse and count credentials |
248 | 150 | readable |
|
252 | 154 | {:credentials-count (count credentials)}) |
253 | 155 | (catch Exception e |
254 | 156 | {:parse-error (.getMessage e)}))))))] |
255 | | - {:gpg-available gpg-avail? |
256 | | - :files (vec file-checks)})) |
| 157 | + {:files (vec file-checks)})) |
0 commit comments