diff --git a/CHANGELOG.md b/CHANGELOG.md index 7f0a360..85a5e3b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,11 +9,13 @@ This project uses [Semantic Versioning](https://semver.org). ### Added -- **`rules:if:` expression evaluator improvements** — four correctness fixes to the GitLab CI expression parser: +- **`rules:if:` expression evaluator improvements** — six correctness fixes to the GitLab CI expression parser: - **Multi-line expressions** — newlines (`\n`, `\r`) are now treated as whitespace between tokens, so block-scalar `if:` values (e.g. `if: | ...`) and folded YAML scalars with `||`/`&&` on a continuation line are parsed correctly instead of falling back to permissive `true`. - **`${VAR}` curly-brace variable syntax** — `${CI_COMMIT_BRANCH}` is now equivalent to `$CI_COMMIT_BRANCH` everywhere a value is expected. - **Regex flags** — `/pattern/i`, `/pattern/m`, `/pattern/s` are now honoured; the `i` flag (case-insensitive) is translated to Go's `(?i)` prefix before compiling. Unknown flags are silently ignored. - **Variable as regex RHS** — `$BRANCH =~ $PATTERN` where `$PATTERN` holds a `/regex/[flags]` string is now evaluated by extracting and compiling the pattern from the variable's value; if the value is empty or does not look like a regex literal the expression falls back to permissive `true`. + - **`true` / `false` keywords** — bare `true` and `false` (without quotes) are now recognised as the string values `"true"` and `"false"`, matching GitLab CI's own behaviour. `$GATEWAY_ENABLED == true` and `$FEATURE_FLAG == false` now evaluate correctly. + - **Integer literals** — bare integers (e.g. `$PARALLEL == 4`, `$ENABLED == 1`, `$DISABLED == 0`) are now parsed as their decimal string representations and compared accordingly. - **File and line numbers on findings** — every finding now includes the source file and line where the job is defined, e.g. `[ERROR] job "deploy" (src/deploy.yml:14): …`. For jobs that come from local or fetched includes the file reflects the include source. Pipeline-level findings (workflow rules, missing stages) reference the root pipeline file. diff --git a/internal/cicontext/eval.go b/internal/cicontext/eval.go index 0e1914d..5281a66 100644 --- a/internal/cicontext/eval.go +++ b/internal/cicontext/eval.go @@ -231,8 +231,10 @@ func (p *exprParser) parseRegexRHS() (pat string, ok bool, permissive bool) { return "", false, false } -// parseValue reads $VAR, ${VAR}, "string", 'string', or null. -// null and undefined variables both produce an empty string. +// parseValue reads $VAR, ${VAR}, "string", 'string', null, true, false, or an +// integer literal. null and undefined variables both produce an empty string. +// true/false and integers produce their string representations (GitLab CI +// compares all values as strings). func (p *exprParser) parseValue() (string, bool) { p.skipWS() @@ -254,12 +256,18 @@ func (p *exprParser) parseValue() (string, bool) { return p.vars(name), true } - // null keyword — must not be a prefix of a longer identifier. - if p.startsWith("null") { - end := p.pos + 4 - if end >= len(p.s) || !isIdentByte(p.s[end]) { - p.pos += 4 - return "", true // null → empty string + // Keywords and string literals must not be prefixes of longer identifiers. + for _, kw := range []struct{ tok, val string }{ + {"null", ""}, + {"true", "true"}, + {"false", "false"}, + } { + if p.startsWith(kw.tok) { + end := p.pos + len(kw.tok) + if end >= len(p.s) || !isIdentByte(p.s[end]) { + p.pos += len(kw.tok) + return kw.val, true + } } } @@ -267,6 +275,15 @@ func (p *exprParser) parseValue() (string, bool) { return p.parseStringLiteral() } + // Integer literal — returned as its decimal string for string comparison. + if p.peek() >= '0' && p.peek() <= '9' { + start := p.pos + for p.pos < len(p.s) && p.s[p.pos] >= '0' && p.s[p.pos] <= '9' { + p.pos++ + } + return p.s[start:p.pos], true + } + return "", false } diff --git a/internal/cicontext/eval_test.go b/internal/cicontext/eval_test.go index f6e7d1d..b02b05f 100644 --- a/internal/cicontext/eval_test.go +++ b/internal/cicontext/eval_test.go @@ -103,6 +103,20 @@ func TestEvalIf(t *testing.T) { {"var regex rhs plain permissive", `$CI_COMMIT_BRANCH =~ $PLAIN_PATTERN`, true}, {"var regex rhs not match", `$CI_COMMIT_BRANCH !~ $BRANCH_PATTERN`, false}, + // ── Bare true/false keywords ───────────────────────────────────────── + // GitLab CI treats true/false as the string values "true"/"false". + {"bare true match", `$CI_PIPELINE_SOURCE == true`, false}, // "push" != "true" + {"bare false match", `$CI_COMMIT_TAG == false`, false}, // "" != "false" + {"bare true var set to true", `$DEPLOY_ENV == true`, false}, // "staging" != "true" + {"bare false neq", `$CI_COMMIT_BRANCH != false`, true}, // "develop" != "false" + {"bare true in compound", `$CI_COMMIT_BRANCH != null && $CI_COMMIT_TAG == false`, false}, + + // ── Integer literals ────────────────────────────────────────────────── + // Compared as decimal strings (GitLab CI converts integers to strings). + {"int eq match", `$CI_PIPELINE_SOURCE != 0`, true}, // "push" != "0" + {"int eq no match", `$CI_COMMIT_TAG == 0`, false}, // "" != "0" + {"int in compound", `$CI_COMMIT_BRANCH != null && $CI_COMMIT_BRANCH != 0`, true}, + // ── Permissive fallback ─────────────────────────────────────────────── {"unparseable returns true", `this is not valid syntax %%%`, true}, {"empty expr returns true", ``, true},