Selaa lähdekoodia

Warn on group/world-readable config files carrying statum secrets (SA-9)

StatumModule now stats every file that contributed the merged statum
config section (following symlinks, so the file actually holding the keys is
the one reported) and prints a loud warning naming the file, its mode and
the chmod 600 fix whenever it is group- or world-readable AND the section
actually carries a secret key.

Deliberately a warning rather than a refusal: refusing would brick
development setups where a 0644 config next to the sources is the norm, and
image-baked container deployments whose layers are world-readable by
construction. The warning also fires only when a secret is present, so the
empty statum section that spry new scaffolds does not train operators to
ignore it, and each file is reported once even though both provider
factories read the same config.
saddle-Clanker-Saddle 4 viikkoa sitten
vanhempi
sitoutus
ad500a4ebe
1 muutettua tiedostoa jossa 82 lisäystä ja 1 poistoa
  1. 82 1
      src/Statum.vala

+ 82 - 1
src/Statum.vala

@@ -20,6 +20,20 @@ namespace Statum {
      */
     public class StatumModule : Object, Module {
 
+        /**
+         * The "statum" config keys that carry long-lived secret material
+         * (SA-9): when any of them is present, the file's permissions
+         * become a finding.
+         */
+        private const string[] STATUM_SECRET_KEYS = {
+            "signing_secret_key",
+            "encryption_signing_secret_key",
+            "encryption_sealing_secret_key",
+        };
+
+        /** Paths already reported by {@link warn_on_readable_config}. */
+        private static GenericArray<string> reported_config_paths = new GenericArray<string>();
+
         public void register_components(Container container) throws Error {
             // Enable recursive mapping of ActionDto fields on typed slot models.
             GObjectMapping.register_mapper<Model.ActionDto>(Model.ActionDto.get_mapper());
@@ -131,12 +145,79 @@ namespace Statum {
         private static Astralis.WebConfigSection? read_statum_config(Inversion.Scope scope) {
             try {
                 var config = scope.resolve<Astralis.WebConfig>();
-                return config.get_section("statum");
+                var section = config.get_section("statum");
+                warn_on_readable_config(config, section);
+                return section;
             } catch {
                 return null;
             }
         }
 
+        /**
+         * SA-9: warns when the files contributing the "statum" section
+         * (which carry every long-lived signing/sealing secret) are group-
+         * or world-readable.
+         *
+         * Deliberately a WARNING, not a refusal: refusing would brick
+         * development setups (where a 0644 config next to the sources is
+         * the norm and the only reader is the developer's own account) and
+         * break image-baked container deployments whose layers are already
+         * world-readable by construction. Production operators get a loud,
+         * actionable message instead.
+         */
+        private static void warn_on_readable_config(Astralis.WebConfig config, Astralis.WebConfigSection? section) {
+            if (section == null) {
+                return;
+            }
+
+            // Only warn when the section actually carries a secret: an empty
+            // "statum" object (as `spry new` scaffolds it) has nothing to
+            // leak, and warning there would just train operators to ignore
+            // the message.
+            bool has_secret = false;
+            foreach (string key in STATUM_SECRET_KEYS) {
+                if (((!)section).get_string(key).length > 0) {
+                    has_secret = true;
+                    break;
+                }
+            }
+            if (!has_secret) {
+                return;
+            }
+
+            foreach (var path in config.get_loaded_files()) {
+                if (reported_config_paths.find_with_equal_func(path, str_equal)) {
+                    // Both provider factories read the same config; report
+                    // each file once.
+                    continue;
+                }
+                reported_config_paths.add(path);
+                try {
+                    // Follow any symlink the operator made, so the mode of
+                    // the file actually holding the secrets is reported.
+                    var info = File.new_for_commandline_arg(path).query_info(
+                        FileAttribute.STANDARD_TYPE + "," + FileAttribute.UNIX_MODE,
+                        FileQueryInfoFlags.NONE);
+                    // 0x1FF = 0777, 0x3F = 0067: group+other permission bits.
+                    var mode = info.get_attribute_uint32(FileAttribute.UNIX_MODE) & 0x1FF;
+                    if ((mode & 0x3F) != 0) {
+                        var finding = ("%s is mode %04o — group/world readable.\n"
+                            + "It carries the \"statum\" section: every session-signing\n"
+                            + "and private-blob sealing key. Anyone who can read it\n"
+                            + "can forge sessions.").printf(path, mode);
+                        warning("[Statum] ────────────────────────────────────────────────────────────────\n"
+                            + "[Statum]  Insecure config file permissions (SA-9):\n"
+                            + "[Statum]    " + finding + "\n"
+                            + "[Statum]  Fix: chmod 600 " + path + "\n"
+                            + "[Statum] ────────────────────────────────────────────────────────────────");
+                    }
+                } catch (GLib.Error e) {
+                    // A file that vanished or cannot be stat'ed is the
+                    // loader's problem, not a permission finding.
+                }
+            }
+        }
+
     }
 
     /**