Skip to content
Open
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 .poutine.sample.yml
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,10 @@
# default: false
ignoreForks: true

# Disable the once-per-day check for newer poutine releases
# default: false
# disableVersionCheck: true

# Set rule configuration options
rulesConfig:
pr_runs_on_self_hosted:
Expand Down
9 changes: 9 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -115,10 +115,19 @@ poutine analyze_org my-org/project --token "$GL_TOKEN" --scm gitlab --scm-base-u
--skip Add rules to the skip list for the current run (can be specified multiple times)
--verbose Enable debug logging
--fail-on-violation Exit with a non-zero code (10) when violations are found
--disable-version-check Disable the once-per-day check for newer poutine releases (env: POUTINE_DISABLE_VERSION_CHECK, config: disableVersionCheck)
```

See [.poutine.sample.yml](.poutine.sample.yml) for an example configuration file.

#### Version check telemetry

By default, `poutine` reaches out at most once every 24 hours to check whether a newer release is available. The request reports the current poutine version, an anonymous instance identifier persisted in `~/.poutine/config.yaml`, and a count of CLI invocations since the last check. No source, repository, or finding data is sent. To disable, use any of:

- `--disable-version-check` flag
- `POUTINE_DISABLE_VERSION_CHECK=1` environment variable
- `disableVersionCheck: true` in `.poutine.yml`

### Custom Rules

`poutine` supports custom Rego rules to extend its security scanning capabilities. You can write your own rules and include them at runtime.
Expand Down
40 changes: 40 additions & 0 deletions cmd/root.go
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ import (
"github.com/boostsecurityio/poutine/providers/gitops"
"github.com/boostsecurityio/poutine/providers/scm"
scm_domain "github.com/boostsecurityio/poutine/providers/scm/domain"
"github.com/boostsecurityio/poutine/versioncheck"
"github.com/rs/zerolog"
"github.com/rs/zerolog/log"
"github.com/spf13/viper"
Expand All @@ -44,6 +45,7 @@ var config *models.Config = models.DefaultConfig()
var skipRules []string
var allowedRules []string
var failOnViolation bool
var disableVersionCheck bool

// ErrViolationsFound is returned when violations are detected and --fail-on-violation is set.
var ErrViolationsFound = errors.New("poutine: violations found")
Expand Down Expand Up @@ -88,9 +90,46 @@ By BoostSecurity.io - https://github.com/boostsecurityio/poutine `,
return strings.ToUpper(fmt.Sprintf("| %-6s|", i))
}
log.Logger = log.Output(output)

runVersionCheck(cmd)
},
}

// versionCheckSkipCommands lists subcommands that must not trigger the
// version check: "mcp-server" speaks JSON-RPC over stdio (no point delaying
// its handshake), and "completion" is invoked by shells for tab-completion
// lookups. Other subcommands (including "version" and "help") still pay the
// once-per-day check, since the 24h cache means at most one network call.
var versionCheckSkipCommands = map[string]struct{}{
"mcp-server": {},
"completion": {},
}

// runVersionCheck performs the once-per-day update check unless disabled by
// flag, env var, or config. Commands listed in versionCheckSkipCommands and
// any subcommand under them are excluded so users can inspect the binary or
// run the MCP server without triggering a network call.
func runVersionCheck(cmd *cobra.Command) {
for c := cmd; c != nil; c = c.Parent() {
if _, skip := versionCheckSkipCommands[c.Name()]; skip {
return
}
}
disabled := disableVersionCheck || (config != nil && config.DisableVersionCheck)
result := versioncheck.Run(cmd.Context(), Version, disabled)
if result == nil || !result.UpdateAvailable {
return
}
target := result.LatestURL
if target == "" {
target = "https://github.com/boostsecurityio/poutine/releases"
}
log.Warn().
Str("current_version", Version).
Str("latest_version", result.LatestVersion).
Msgf("A new version of poutine is available: %s — %s", result.LatestVersion, target)
}

// Execute adds all child commands to the root command and sets flags appropriately.
// This is called by main.main(). It only needs to happen once to the rootCmd.
func Execute() {
Expand Down Expand Up @@ -147,6 +186,7 @@ func init() {
RootCmd.PersistentFlags().StringSliceVar(&skipRules, "skip", []string{}, "Adds rules to the configured skip list for the current run (optional)")
RootCmd.PersistentFlags().StringSliceVar(&allowedRules, "allowed-rules", []string{}, "Overwrite the configured allowedRules list for the current run (optional)")
RootCmd.PersistentFlags().BoolVar(&failOnViolation, "fail-on-violation", false, "Exit with a non-zero code (10) when violations are found")
RootCmd.PersistentFlags().BoolVar(&disableVersionCheck, "disable-version-check", false, "Disable the once-per-day check for newer poutine releases")

_ = viper.BindPFlag("quiet", RootCmd.PersistentFlags().Lookup("quiet"))
}
Expand Down
13 changes: 7 additions & 6 deletions models/config.go
Original file line number Diff line number Diff line change
Expand Up @@ -23,12 +23,13 @@ type ConfigInclude struct {
}

type Config struct {
Skip []ConfigSkip `json:"skip"`
AllowedRules []string `json:"allowed_rules"`
Include []ConfigInclude `json:"include"`
IgnoreForks bool `json:"ignore_forks"`
Quiet bool `json:"quiet,omitempty"`
RulesConfig map[string]map[string]interface{} `json:"rules_config"`
Skip []ConfigSkip `json:"skip"`
AllowedRules []string `json:"allowed_rules"`
Include []ConfigInclude `json:"include"`
IgnoreForks bool `json:"ignore_forks"`
Quiet bool `json:"quiet,omitempty"`
RulesConfig map[string]map[string]interface{} `json:"rules_config"`
DisableVersionCheck bool `json:"disable_version_check,omitempty"`
}

func DefaultConfig() *Config {
Expand Down
71 changes: 71 additions & 0 deletions versioncheck/config.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,71 @@
// Package versioncheck implements a lightweight, opt-out version-check that
// reports anonymous start telemetry to the Boost OSS telemetry endpoint and
// notifies the user when a newer poutine release is available.
package versioncheck

import (
"fmt"
"os"
"path/filepath"
"time"

"gopkg.in/yaml.v3"
)

// Config is the user-level state persisted between poutine invocations to
// support the once-per-day version check. It is intentionally separate from
// the project-level models.Config that is loaded from .poutine.yml.
type Config struct {
InstanceID string `yaml:"instance_id,omitempty"`
StartCount int `yaml:"start_count,omitempty"`
LastReportedStartCount int `yaml:"last_reported_start_count,omitempty"`
LastVersionCheckAt time.Time `yaml:"last_version_check_timestamp,omitempty"`
}

// ConfigPath returns the path to the user-level state file. It honors
// POUTINE_CONFIG_DIR for tests and constrained environments and otherwise
// defaults to ~/.poutine/config.yaml.
func ConfigPath() string {
if dir := os.Getenv("POUTINE_CONFIG_DIR"); dir != "" {
return filepath.Join(dir, "config.yaml")
}
home, _ := os.UserHomeDir()
return filepath.Join(home, ".poutine", "config.yaml")
}

// LoadConfig reads the user-level state file. A missing file is not an error
// and yields a nil Config so callers can treat it as a first run.
func LoadConfig() (*Config, error) {
path := ConfigPath()
data, err := os.ReadFile(path)
if err != nil {
if os.IsNotExist(err) {
return nil, nil
}
return nil, fmt.Errorf("read version-check state %s: %w", path, err)
}

var cfg Config
if err := yaml.Unmarshal(data, &cfg); err != nil {
return nil, fmt.Errorf("invalid version-check state: %w", err)
}
return &cfg, nil
}

// SaveConfig writes the user-level state file, creating the parent directory
// when needed. The file is written with restrictive permissions because it
// holds an anonymous instance identifier.
func SaveConfig(cfg *Config) error {
path := ConfigPath()
if err := os.MkdirAll(filepath.Dir(path), 0o700); err != nil {
return fmt.Errorf("create version-check state dir: %w", err)
}
data, err := yaml.Marshal(cfg)
if err != nil {
return fmt.Errorf("marshal version-check state: %w", err)
}
if err := os.WriteFile(path, data, 0o600); err != nil {
return fmt.Errorf("write version-check state %s: %w", path, err)
}
return nil
}
Loading
Loading