Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,3 +1,7 @@
# 0.0.208 (Oct 7, 2026)
* Changed the CLI to create profile files (`~/.nullstone/<profile>/`), 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.

Expand Down
2 changes: 1 addition & 1 deletion artifacts/package_module.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}
Expand Down
2 changes: 1 addition & 1 deletion aws/ssm/plugin.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
4 changes: 2 additions & 2 deletions azure/vm/bastion.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
2 changes: 1 addition & 1 deletion cmd/iac.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}
Expand Down
2 changes: 1 addition & 1 deletion cmd/run.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}
Expand Down
35 changes: 27 additions & 8 deletions config/profile.go
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,6 @@ package config
import (
"encoding/json"
"fmt"
"io/ioutil"
"os"
"path"
)
Expand All @@ -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
Expand All @@ -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) {
Expand All @@ -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))
Expand All @@ -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 {
Expand All @@ -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 {
Expand All @@ -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)
}
4 changes: 2 additions & 2 deletions git/git_ignore.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
Expand Down Expand Up @@ -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
}
Expand Down
2 changes: 1 addition & 1 deletion go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
4 changes: 2 additions & 2 deletions go.sum
Original file line number Diff line number Diff line change
Expand Up @@ -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=
Expand Down
2 changes: 1 addition & 1 deletion mcp/executor.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
12 changes: 11 additions & 1 deletion modules/checksum.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
// "<sha256(content)> <name>\n" is fed into a running sha256.
Expand Down Expand Up @@ -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 {
Expand All @@ -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
Expand Down
4 changes: 2 additions & 2 deletions modules/generate.go
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
package modules

import (
"io/ioutil"
"os"

"gopkg.in/nullstone-io/go-api-client.v0/types"
)
Expand Down Expand Up @@ -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)
}
3 changes: 2 additions & 1 deletion modules/generate_app_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
6 changes: 3 additions & 3 deletions modules/manifest.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand All @@ -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
}
Expand Down
2 changes: 1 addition & 1 deletion modules/package.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}
Expand Down
2 changes: 1 addition & 1 deletion modules/publish.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
Expand Down
9 changes: 6 additions & 3 deletions tfconfig/creds.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 (
Expand All @@ -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
}
Expand All @@ -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
}
Expand Down
2 changes: 1 addition & 1 deletion workspaces/capabilities.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
2 changes: 1 addition & 1 deletion workspaces/init.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
4 changes: 2 additions & 2 deletions workspaces/manifest.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
Expand Down
4 changes: 2 additions & 2 deletions workspaces/write_backend.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}