Class: Rules::MissingFrozenLockfile

Inherits:
Base
  • Object
show all
Defined in:
lib/rules/missing_frozen_lockfile.rb

Constant Summary collapse

NPM_INSTALL =

JavaScript/TypeScript npm install without –ci (or use npm ci instead)

/\bnpm\s+install\b/
NPM_SAFE =
/--ci\b|\bnpm\s+ci\b/
PNPM_INSTALL =

pnpm install without –frozen-lockfile

/\bpnpm\s+install\b/
PNPM_SAFE =
/--frozen-lockfile/
YARN_INSTALL =

yarn install without –frozen-lockfile or –immutable

/\byarn\s+install\b/
YARN_SAFE =
/--frozen-lockfile|--immutable/
BUN_INSTALL =

bun install without –frozen-lockfile

/\bbun\s+install\b/
BUN_SAFE =
/--frozen-lockfile/
PIP_INSTALL =

Python pip install / pip3 install with package names (not local installs, not -r)

/\b(?:pip3?|uv\s+pip)\s+install\b/
PIP_SAFE =
/-r\b|--requirement\b|-c\b|--constraint\b|--require-hashes/
PIP_LOCAL =
/\binstall\s+(?:-e\s+)?\.(?:\s|$|\[)/
BUNDLE_INSTALL =

Ruby bundle install (or bare bundle) without –frozen or –deployment

/\bbundle\b(?:\s+install\b)?/
BUNDLE_SAFE =
/--frozen|--deployment|BUNDLE_FROZEN\s*=\s*(?:true|1)/
BUNDLE_OTHER =

Avoid matching unrelated bundle subcommands

/\bbundle\s+(?:exec|add|update|show|list|info|outdated|check|config|lock|cache|clean|console|open|gem|platform|env|doctor|viz|version|init|binstubs|pristine|plugin)\b/
GO_GET =

Go go get in CI is non-deterministic; suggest go mod download

/\bgo\s+get\b/
CARGO_INSTALL =

Rust cargo install without –locked

/\bcargo\s+install\b/
CARGO_SAFE =
/--locked/
COMPOSER_UPDATE =

PHP composer update resolves fresh, ignoring lockfile

/\bcomposer\s+update\b/
CHECKS =
[
    {
        match: NPM_INSTALL,
        safe: NPM_SAFE,
        message: "npm install without lockfile enforcement — dependency resolution may differ from tested versions",
        fix: "Use `npm ci` instead of `npm install`",
    },
    {
        match: PNPM_INSTALL,
        safe: PNPM_SAFE,
        message: "pnpm install without --frozen-lockfile — dependency resolution may differ from tested versions",
        fix: "Use `pnpm install --frozen-lockfile`",
    },
    {
        match: YARN_INSTALL,
        safe: YARN_SAFE,
        message: "yarn install without lockfile enforcement — dependency resolution may differ from tested versions",
        fix: "Use `yarn install --frozen-lockfile` or `yarn install --immutable`",
    },
    {
        match: BUN_INSTALL,
        safe: BUN_SAFE,
        message: "bun install without --frozen-lockfile — dependency resolution may differ from tested versions",
        fix: "Use `bun install --frozen-lockfile`",
    },
    {
        match: PIP_INSTALL,
        safe: PIP_SAFE,
        safe_alt: PIP_LOCAL,
        message: "pip install with unpinned packages — no lockfile or constraints file ensuring reproducibility",
        fix: "Use `pip install -r requirements.txt --require-hashes` or a constraints file",
    },
    {
        match: BUNDLE_INSTALL,
        safe: BUNDLE_SAFE,
        skip: BUNDLE_OTHER,
        message: "bundle install without --frozen — Gemfile.lock may be modified during install",
        fix: "Use `bundle install --frozen` or `bundle install --deployment`",
    },
    {
        match: GO_GET,
        message: "go get in CI is non-deterministic — resolved versions may change between runs",
        fix: "Use `go mod download` instead (uses go.sum for verification)",
    },
    {
        match: CARGO_INSTALL,
        safe: CARGO_SAFE,
        message: "cargo install without --locked — Cargo.lock will be ignored and dependencies re-resolved",
        fix: "Use `cargo install --locked`",
    },
    {
        match: COMPOSER_UPDATE,
        message: "composer update in CI resolves fresh dependencies, ignoring composer.lock",
        fix: "Use `composer install` instead (respects composer.lock)",
    },
]

Instance Method Summary collapse

Instance Method Details

#check(workflow) ⇒ Object



107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
# File 'lib/rules/missing_frozen_lockfile.rb', line 107

def check(workflow)
    findings = []

    workflow.raw_lines.each_with_index do |line, i|
        stripped = line.strip
        next if stripped.start_with?("#")

        CHECKS.each do |chk|
            next unless line.match?(chk[:match])
            next if chk[:skip] && line.match?(chk[:skip])
            next if chk[:safe] && line.match?(chk[:safe])
            next if chk[:safe_alt] && line.match?(chk[:safe_alt])

            # For npm install, also check if the line contains "npm ci" separately
            next if chk[:match] == NPM_INSTALL && line.match?(/\bnpm\s+ci\b/)

            findings << finding(workflow,
                line: i + 1,
                code: stripped,
                message: chk[:message],
                fix: chk[:fix]
            )
        end
    end

    findings
end

#descriptionObject



4
# File 'lib/rules/missing_frozen_lockfile.rb', line 4

def description = "Package install without lockfile enforcement"

#nameObject



3
# File 'lib/rules/missing_frozen_lockfile.rb', line 3

def name = "missing-frozen-lockfile"

#severityObject



5
# File 'lib/rules/missing_frozen_lockfile.rb', line 5

def severity = :medium