diff --git a/CHANGELOG.md b/CHANGELOG.md index b9b4f21c..ca18f35f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,7 @@ +# 0.0.208 (Oct 7, 2026) +* Changed the CLI to create profile files (`~/.nullstone//`), the API key, and the Terraform credentials file readable only by the owner (`0600`/`0700`). Existing files from older versions are tightened on next use. +* Limited the module archive size that `nullstone modules publish` and `nullstone modules package` will hash to 512 MiB of uncompressed content. + # 0.0.207 (Oct 2, 2026) * Updated `nullstone modules generate` for app modules: `env_vars.tf` now aggregates environment variables and secrets with the `ns_env_layout`, `ns_env_values`, and `ns_env_platform_data` data sources, so a generated module reports its environment to Nullstone. Set `local.env_platform` in the generated file to the module's runtime platform. diff --git a/artifacts/package_module.go b/artifacts/package_module.go index 3d6ced11..32cc1a2a 100644 --- a/artifacts/package_module.go +++ b/artifacts/package_module.go @@ -13,7 +13,7 @@ import ( // 'patterns' allows a developer to specify which file patterns are included in the tar.gz // This is more effective than the built-in tar command because it won't fail if a pattern doesn't match any files func PackageModule(logger *log.Logger, dir, filename string, patterns []string, excludeFn func(entry GlobEntry) bool) error { - targzFile, err := os.Create(filename) + targzFile, err := os.Create(filename) // #nosec G304 -- output archive path supplied by the user if err != nil { return fmt.Errorf("error creating module package: %w", err) } diff --git a/aws/ssm/plugin.go b/aws/ssm/plugin.go index 11ae660c..fa6c3fbc 100644 --- a/aws/ssm/plugin.go +++ b/aws/ssm/plugin.go @@ -33,7 +33,7 @@ func StartSession(ctx context.Context, session interface{}, target ssm.StartSess } ctx = context.Background() // Ignore signal cancellations on the context - cmd := exec.CommandContext(ctx, process, args...) + cmd := exec.CommandContext(ctx, process, args...) // #nosec G204 -- session-manager-plugin resolved from PATH; args built by the CLI cmd.Stderr = os.Stderr cmd.Stdout = os.Stdout cmd.Stdin = os.Stdin diff --git a/azure/vm/bastion.go b/azure/vm/bastion.go index 621ce34d..ad8c940c 100644 --- a/azure/vm/bastion.go +++ b/azure/vm/bastion.go @@ -47,8 +47,8 @@ func StartBastionSsh(ctx context.Context, infra Outputs, username string) error env := os.Environ() env = append(env, "AZURE_ACCESS_TOKEN="+token.Token) - ctx = context.Background() // Ignore signal cancellations on the context - cmd := exec.CommandContext(ctx, azPath, args...) + ctx = context.Background() // Ignore signal cancellations on the context + cmd := exec.CommandContext(ctx, azPath, args...) // #nosec G204 -- az resolved from PATH; args built by the CLI cmd.Stderr = os.Stderr cmd.Stdout = os.Stdout cmd.Stdin = os.Stdin diff --git a/cmd/iac.go b/cmd/iac.go index 6fd85827..29701b09 100644 --- a/cmd/iac.go +++ b/cmd/iac.go @@ -270,7 +270,7 @@ func readDiscoveredIacFiles(curDir string, pmr *iac.ConfigFiles) (map[string]str // treats keys as opaque labels but downstream code compares against `.nullstone/...` // strings produced by the GitHub-fetch path on Linux. key = strings.ReplaceAll(key, string(filepath.Separator), "/") - raw, err := os.ReadFile(filename) + raw, err := os.ReadFile(filename) // #nosec G304 -- file supplied by the user on the command line if err != nil { return fmt.Errorf("error reading %s: %w", filename, err) } diff --git a/cmd/run.go b/cmd/run.go index c3f1976f..b8c76456 100644 --- a/cmd/run.go +++ b/cmd/run.go @@ -127,7 +127,7 @@ func readRunPayload(raw string) ([]byte, error) { return payload, nil case strings.HasPrefix(raw, "@"): filename := raw[1:] - payload, err := os.ReadFile(filename) + payload, err := os.ReadFile(filename) // #nosec G304 -- file supplied by the user on the command line if err != nil { return nil, fmt.Errorf("unable to read the payload from file (%s): %w", filename, err) } diff --git a/config/profile.go b/config/profile.go index 1974e882..23c17cf5 100644 --- a/config/profile.go +++ b/config/profile.go @@ -3,7 +3,6 @@ package config import ( "encoding/json" "fmt" - "io/ioutil" "os" "path" ) @@ -22,10 +21,10 @@ func (p Profile) Save() error { if err != nil { return fmt.Errorf("error generating profile file: %w", err) } - if err := ioutil.WriteFile(p.ConfigFilename(), raw, 0644); err != nil { + if err := os.WriteFile(p.ConfigFilename(), raw, 0600); err != nil { return fmt.Errorf("error saving profile configuration: %w", err) } - if err := ioutil.WriteFile(p.ApiKeyFilename(), []byte(p.ApiKey), 0644); err != nil { + if err := os.WriteFile(p.ApiKeyFilename(), []byte(p.ApiKey), 0600); err != nil { return fmt.Errorf("error saving api key: %w", err) } return nil @@ -39,7 +38,13 @@ func LoadProfile(name string) (*Profile, error) { if err := p.ensureDir(); err != nil { return nil, err } - raw, err := ioutil.ReadFile(p.ConfigFilename()) + // Profiles written by older CLI versions were created world-readable; + // tighten them in place so the API key is only readable by the owner. + TightenPerms(p.Directory(), 0700) + TightenPerms(p.ConfigFilename(), 0600) + TightenPerms(p.ApiKeyFilename(), 0600) + + raw, err := os.ReadFile(p.ConfigFilename()) if err != nil { // If profile configuration file does not exist, just return our defaults if os.IsNotExist(err) { @@ -53,7 +58,7 @@ func LoadProfile(name string) (*Profile, error) { // The name in the configuration file should not override the requested profile p.Name = name - if raw, err := ioutil.ReadFile(p.ApiKeyFilename()); err != nil { + if raw, err := os.ReadFile(p.ApiKeyFilename()); err != nil { return nil, fmt.Errorf("error reading api key: %w", err) } else { p.ApiKey = CleanseApiKey(string(raw)) @@ -62,7 +67,7 @@ func LoadProfile(name string) (*Profile, error) { } func (p Profile) LoadOrg() (string, error) { - raw, err := ioutil.ReadFile(path.Join(p.Directory(), "org")) + raw, err := os.ReadFile(path.Join(p.Directory(), "org")) if os.IsNotExist(err) { return "", nil } else if err != nil { @@ -75,7 +80,7 @@ func (p Profile) SaveOrg(org string) error { if err := p.ensureDir(); err != nil { return err } - return ioutil.WriteFile(path.Join(p.Directory(), "org"), []byte(org), 0644) + return os.WriteFile(path.Join(p.Directory(), "org"), []byte(org), 0600) } func (p Profile) Directory() string { @@ -91,8 +96,22 @@ func (p Profile) ApiKeyFilename() string { } func (p Profile) ensureDir() error { - if err := os.MkdirAll(p.Directory(), 0755); !os.IsExist(err) { + if err := os.MkdirAll(p.Directory(), 0700); !os.IsExist(err) { return err } return nil } + +// TightenPerms narrows filename to mode if it exists and is readable by group or +// other. It is a best-effort migration for files created by older CLI versions: +// a missing file or a failed chmod is silently ignored. +func TightenPerms(filename string, mode os.FileMode) { + fi, err := os.Stat(filename) + if err != nil { + return + } + if fi.Mode().Perm()&0077 == 0 { + return + } + _ = os.Chmod(filename, mode) +} diff --git a/git/git_ignore.go b/git/git_ignore.go index 1ceb87d3..8f2bb8b5 100644 --- a/git/git_ignore.go +++ b/git/git_ignore.go @@ -20,7 +20,7 @@ func FindGitIgnores(repo *git.Repository, patterns []string) (found []string, mi } filename := wt.Filesystem.Join(".gitignore") - file, err := os.Open(filename) + file, err := os.Open(filename) // #nosec G304 -- .gitignore in the user's repo if err != nil { return } @@ -50,7 +50,7 @@ func AddGitIgnores(repo *git.Repository, patterns []string) { } filename := wt.Filesystem.Join(".gitignore") - file, err := os.OpenFile(filename, os.O_RDWR|os.O_CREATE|os.O_APPEND, 0644) + file, err := os.OpenFile(filename, os.O_RDWR|os.O_CREATE|os.O_APPEND, 0644) // #nosec G302 G304 -- .gitignore in the user's repo must stay world-readable if err != nil { return } diff --git a/go.mod b/go.mod index 561a2608..8e3fac99 100644 --- a/go.mod +++ b/go.mod @@ -16,7 +16,7 @@ require ( github.com/gosuri/uilive v0.0.4 github.com/mark3labs/mcp-go v0.55.1 github.com/mitchellh/colorstring v0.0.0-20190213212951-d06e56a500db - github.com/nullstone-io/deployment-sdk v0.0.0-20260918134614-6a3ea61e737a + github.com/nullstone-io/deployment-sdk v0.0.0-20261007145731-cd49c268fe2a github.com/nullstone-io/iac v0.0.0-20260911002157-ddfc80f7061e github.com/nullstone-io/module v0.3.1 github.com/ryanuber/columnize v2.1.2+incompatible diff --git a/go.sum b/go.sum index ce817691..24dfcc5e 100644 --- a/go.sum +++ b/go.sum @@ -372,8 +372,8 @@ github.com/monochromegane/go-gitignore v0.0.0-20200626010858-205db1a8cc00 h1:n6/ github.com/monochromegane/go-gitignore v0.0.0-20200626010858-205db1a8cc00/go.mod h1:Pm3mSP3c5uWn86xMLZ5Sa7JB9GsEZySvHYXCTK4E9q4= github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 h1:C3w9PqII01/Oq1c1nUAm88MOHcQC9l5mIlSMApZMrHA= github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822/go.mod h1:+n7T8mK8HuQTcFwEeznm/DIxMOiR9yIdICNftLE1DvQ= -github.com/nullstone-io/deployment-sdk v0.0.0-20260918134614-6a3ea61e737a h1:OurmpQvDPh/sqsLM2PGfxtR3QCNSfYdvU1zZuOq965w= -github.com/nullstone-io/deployment-sdk v0.0.0-20260918134614-6a3ea61e737a/go.mod h1:hgFpPKzxd+CAc+BN4ZsBwoJol7bobdeWG1LnVcfjyS8= +github.com/nullstone-io/deployment-sdk v0.0.0-20261007145731-cd49c268fe2a h1:likr3F5NQTKX99DrBiQdxKLEpuiJ7H2TANK+esv5ABA= +github.com/nullstone-io/deployment-sdk v0.0.0-20261007145731-cd49c268fe2a/go.mod h1:hgFpPKzxd+CAc+BN4ZsBwoJol7bobdeWG1LnVcfjyS8= github.com/nullstone-io/iac v0.0.0-20260911002157-ddfc80f7061e h1:9tdHveQ4BxP7JxHi39b3mg3vv3p3i6bzjXf1WfCVKjo= github.com/nullstone-io/iac v0.0.0-20260911002157-ddfc80f7061e/go.mod h1:P+5N4HXTLVbAGmqlCnS9TSQD2N3VIVtHSvuM1axPxFU= github.com/nullstone-io/module v0.3.1 h1:w65+1+1HfGSoUwNj3V+j4IuN/GOeuR1AKJqEoLPCVgQ= diff --git a/mcp/executor.go b/mcp/executor.go index 953629ed..fa31a7e2 100644 --- a/mcp/executor.go +++ b/mcp/executor.go @@ -32,7 +32,7 @@ func Execute(ctx context.Context, args []string) (*ExecuteResult, error) { ctx, cancel := context.WithTimeout(ctx, DefaultTimeout) defer cancel() - cmd := exec.CommandContext(ctx, binaryPath, args...) + cmd := exec.CommandContext(ctx, binaryPath, args...) // #nosec G204 -- re-executes this same binary (os.Args[0]) with MCP-supplied args var stdout, stderr bytes.Buffer cmd.Stdout = &stdout diff --git a/modules/checksum.go b/modules/checksum.go index 51612fd7..837a0faa 100644 --- a/modules/checksum.go +++ b/modules/checksum.go @@ -12,6 +12,10 @@ import ( "strings" ) +// MaxArchiveContentBytes caps the total uncompressed bytes HashArchiveContents will +// read from one archive (CWE-409). 512 MiB is far above any real module package. +const MaxArchiveContentBytes int64 = 512 << 20 + // HashArchiveContents returns a hex-encoded sha256 over a canonical digest of the // archive's regular file entries: for each file, sorted by name, the line // " \n" is fed into a running sha256. @@ -45,6 +49,7 @@ func HashArchiveContents(data []byte, ext string) (string, error) { sum [sha256.Size]byte } var entries []entry + remaining := MaxArchiveContentBytes for { hdr, err := tr.Next() if err == io.EOF { @@ -57,7 +62,12 @@ func HashArchiveContents(data []byte, ext string) (string, error) { continue } fh := sha256.New() - if _, err := io.Copy(fh, tr); err != nil { + n, err := io.Copy(fh, io.LimitReader(tr, remaining+1)) + remaining -= n + if remaining < 0 { + return "", fmt.Errorf("archive exceeds the %d byte uncompressed limit", MaxArchiveContentBytes) + } + if err != nil { return "", fmt.Errorf("hash %q: %w", hdr.Name, err) } var sum [sha256.Size]byte diff --git a/modules/generate.go b/modules/generate.go index 62fcd7a2..019fd955 100644 --- a/modules/generate.go +++ b/modules/generate.go @@ -1,7 +1,7 @@ package modules import ( - "io/ioutil" + "os" "gopkg.in/nullstone-io/go-api-client.v0/types" ) @@ -59,5 +59,5 @@ func generateScaffold(manifest *types.ModuleManifest) error { } func generateFile(filename string, content string) error { - return ioutil.WriteFile(filename, []byte(content), 0644) + return os.WriteFile(filename, []byte(content), 0600) } diff --git a/modules/generate_app_test.go b/modules/generate_app_test.go index d6f7eaf9..dafc68a1 100644 --- a/modules/generate_app_test.go +++ b/modules/generate_app_test.go @@ -16,7 +16,8 @@ func TestGenerate_App(t *testing.T) { scaffold, err := os.ReadFile(scaffoldTfFilename) require.NoError(t, err) - assert.Contains(t, string(scaffold), `version = "~> 0.13.0"`) + assert.Contains(t, string(scaffold), `source = "nullstone-io/ns"`) + assert.NotContains(t, string(scaffold), "version =", "generated modules must not pin the ns provider") envVars, err := os.ReadFile(appEnvVarsTfFilename) require.NoError(t, err) diff --git a/modules/manifest.go b/modules/manifest.go index 7f7de540..d7cb26e9 100644 --- a/modules/manifest.go +++ b/modules/manifest.go @@ -12,7 +12,7 @@ import ( ) func ManifestFromFile(filename string) (*types.ModuleManifest, error) { - file, err := os.Open(filename) + file, err := os.Open(filename) // #nosec G304 -- manifest file in the user's module dir if err != nil { if os.IsNotExist(err) { return nil, fmt.Errorf("module manifest file %q does not exist", filename) @@ -29,10 +29,10 @@ func ManifestFromFile(filename string) (*types.ModuleManifest, error) { } func WriteManifestToFile(m types.ModuleManifest, filename string) error { - if err := os.MkdirAll(filepath.Dir(filename), 0755); err != nil { + if err := os.MkdirAll(filepath.Dir(filename), 0750); err != nil { return err } - file, err := os.Create(filename) + file, err := os.Create(filename) // #nosec G304 -- manifest file in the user's module dir if err != nil { return err } diff --git a/modules/package.go b/modules/package.go index 547e857f..8e246ca5 100644 --- a/modules/package.go +++ b/modules/package.go @@ -46,7 +46,7 @@ func Package(logger *log.Logger, manifest *types.ModuleManifest, version string, } colorstring.Fprintln(logger.Writer(), "[green]Packaged module") - data, err := os.ReadFile(tarballFilename) + data, err := os.ReadFile(tarballFilename) // #nosec G304 -- module tarball supplied by the user if err != nil { return tarballFilename, "", fmt.Errorf("reading packaged tarball for checksum: %w", err) } diff --git a/modules/publish.go b/modules/publish.go index 7aebc6cf..a3b9fa5e 100644 --- a/modules/publish.go +++ b/modules/publish.go @@ -116,7 +116,7 @@ func Publish(ctx context.Context, cfg api.Config, logger *log.Logger, input Publ logger.Println("Publishing module...") // Open tarball to publish - tarball, err := os.Open(tarballFilename) + tarball, err := os.Open(tarballFilename) // #nosec G304 -- module tarball supplied by the user if err != nil { return nil, err } diff --git a/tfconfig/creds.go b/tfconfig/creds.go index 54814300..aef7ecf5 100644 --- a/tfconfig/creds.go +++ b/tfconfig/creds.go @@ -4,9 +4,10 @@ import ( "context" "fmt" "gopkg.in/nullstone-io/go-api-client.v0" - "io/ioutil" "os" "strings" + + "gopkg.in/nullstone-io/nullstone.v0/config" ) var ( @@ -24,7 +25,7 @@ func IsCredsConfigured(cfg api.Config) bool { } hostname := getNullstoneHostname(cfg) - raw, err := ioutil.ReadFile(credsFilename) + raw, err := os.ReadFile(credsFilename) // #nosec G304 -- terraform credentials file at a well-known path if err != nil { return false } @@ -42,7 +43,9 @@ func ConfigCreds(ctx context.Context, cfg api.Config) error { return err } - file, err := os.OpenFile(credsFilename, os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0644) + // Credentials files written by older CLI versions were created world-readable. + config.TightenPerms(credsFilename, 0600) + file, err := os.OpenFile(credsFilename, os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0600) // #nosec G304 -- terraform credentials file at a well-known path if err != nil { return err } diff --git a/workspaces/capabilities.go b/workspaces/capabilities.go index a43552aa..c4dbf124 100644 --- a/workspaces/capabilities.go +++ b/workspaces/capabilities.go @@ -81,7 +81,7 @@ func (g CapabilitiesGenerator) Generate(config types.WorkspaceConfig) error { return fmt.Errorf("error executing capabilities template: %w", err) } - if err := os.WriteFile(g.TargetFilename, content.Bytes(), 0644); err != nil { + if err := os.WriteFile(g.TargetFilename, content.Bytes(), 0600); err != nil { return fmt.Errorf("error writing %q: %s", g.TargetFilename, err) } return nil diff --git a/workspaces/init.go b/workspaces/init.go index 2e73193f..683ad0fd 100644 --- a/workspaces/init.go +++ b/workspaces/init.go @@ -24,7 +24,7 @@ func Init(ctx context.Context, toolName string) error { "-reconfigure", } fmt.Printf("Running `%s %s`\n", process, strings.Join(args, " ")) - cmd := exec.CommandContext(ctx, process, args...) + cmd := exec.CommandContext(ctx, process, args...) // #nosec G204 -- terraform resolved from PATH; args built by the CLI cmd.Stderr = os.Stderr cmd.Stdout = os.Stdout cmd.Stdin = os.Stdin diff --git a/workspaces/manifest.go b/workspaces/manifest.go index a0bb2159..5b53e21a 100644 --- a/workspaces/manifest.go +++ b/workspaces/manifest.go @@ -47,10 +47,10 @@ type ManifestCapability struct { } func (m Manifest) WriteToFile(filename string) error { - if err := os.MkdirAll(filepath.Dir(filename), 0755); err != nil { + if err := os.MkdirAll(filepath.Dir(filename), 0750); err != nil { return err } - file, err := os.Create(filename) + file, err := os.Create(filename) // #nosec G304 -- workspace manifest under the user's .nullstone dir if err != nil { return err } diff --git a/workspaces/write_backend.go b/workspaces/write_backend.go index 3f47f310..2423457a 100644 --- a/workspaces/write_backend.go +++ b/workspaces/write_backend.go @@ -25,8 +25,8 @@ func WriteBackendTf(cfg api.Config, workspaceUid string, filename string) error // backend stanza expects a hostname without the scheme -> TF will add `https://` automatically hostname := strings.Replace(strings.Replace(cfg.BaseAddress, "https://", "", 1), "http://", "", 1) backend := fmt.Sprintf(backendTmpl, hostname, cfg.OrgName, workspaceUid) - if err := os.MkdirAll(filepath.Dir(filename), 0755); err != nil { + if err := os.MkdirAll(filepath.Dir(filename), 0750); err != nil { return err } - return os.WriteFile(filename, []byte(backend), 0644) + return os.WriteFile(filename, []byte(backend), 0600) }