2026-06-05 01:29:07 +02:00
package linter
import (
"fmt"
"regexp"
"strings"
2026-06-10 22:40:42 +02:00
"git.k3nny.fr/glint/internal/model"
2026-06-05 01:29:07 +02:00
)
2026-06-14 10:09:16 +02:00
// dnsLabelPattern matches a valid hostname label: starts and ends with
// alphanumeric, middle may contain hyphens and dots; max 63 chars per label.
var dnsLabelPattern = regexp . MustCompile ( `^[a-zA-Z0-9]([a-zA-Z0-9.-]{0,61}[a-zA-Z0-9])?$` )
// timeoutPattern matches a GitLab CI duration string: one or more pairs of
// "<number> <unit>" where unit is a recognised time word or abbreviation.
var timeoutPattern = regexp . MustCompile (
`(?i)^\s*(\d+\s*(weeks?|w|days?|d|hours?|h|minutes?|mins?|m|seconds?|secs?|s)\s*)+$` )
2026-06-05 01:29:07 +02:00
var validJobWhen = map [ string ] bool {
"on_success" : true ,
"on_failure" : true ,
"always" : true ,
"manual" : true ,
"delayed" : true ,
"never" : true ,
}
var validRuleWhen = map [ string ] bool {
"on_success" : true ,
"on_failure" : true ,
"always" : true ,
"manual" : true ,
"delayed" : true ,
"never" : true ,
}
var validWorkflowRuleWhen = map [ string ] bool {
"always" : true ,
"never" : true ,
}
var validArtifactsWhen = map [ string ] bool {
"on_success" : true ,
"on_failure" : true ,
"always" : true ,
}
var validCacheWhen = map [ string ] bool {
"on_success" : true ,
"on_failure" : true ,
"always" : true ,
}
var validCachePolicy = map [ string ] bool {
"pull" : true ,
"push" : true ,
"pull-push" : true ,
}
var validRetryWhen = map [ string ] bool {
"always" : true ,
"unknown_failure" : true ,
"script_failure" : true ,
"api_failure" : true ,
"stuck_or_timeout_failure" : true ,
"runner_system_failure" : true ,
"runner_unsupported" : true ,
"stale_schedule" : true ,
"job_execution_timeout" : true ,
"archived_failure" : true ,
"unmet_prerequisites" : true ,
"scheduler_failure" : true ,
"data_integrity_failure" : true ,
"forward_deployment_failure" : true ,
"insufficient_bridge_permissions" : true ,
"downstream_bridge_project_not_found" : true ,
"invalid_bridge_trigger" : true ,
"upstream_bridge_project_not_found" : true ,
"protected_environment_failure" : true ,
"pipeline_loop_detected" : true ,
"excluded_by_rules" : true ,
}
var validEnvironmentAction = map [ string ] bool {
"start" : true ,
"stop" : true ,
"prepare" : true ,
"verify" : true ,
"access" : true ,
}
func checkJobKeywords ( name string , job model . Job ) [] Finding {
var findings [] Finding
findings = append ( findings , checkWhen ( name , job ) ... )
findings = append ( findings , checkParallel ( name , job ) ... )
findings = append ( findings , checkRetry ( name , job ) ... )
findings = append ( findings , checkAllowFailure ( name , job ) ... )
findings = append ( findings , checkInterruptible ( name , job ) ... )
findings = append ( findings , checkTrigger ( name , job ) ... )
findings = append ( findings , checkCoverage ( name , job ) ... )
findings = append ( findings , checkRelease ( name , job ) ... )
findings = append ( findings , checkEnvironment ( name , job ) ... )
findings = append ( findings , checkArtifacts ( name , job ) ... )
findings = append ( findings , checkCache ( name , job ) ... )
findings = append ( findings , checkRules ( name , job ) ... )
2026-06-14 09:13:19 +02:00
findings = append ( findings , checkDeadRules ( name , job ) ... )
2026-06-05 01:29:07 +02:00
findings = append ( findings , checkImage ( name , job ) ... )
findings = append ( findings , checkInherit ( name , job ) ... )
2026-06-14 10:09:16 +02:00
findings = append ( findings , checkServices ( name , job ) ... )
findings = append ( findings , checkRulesGlobs ( name , job ) ... )
findings = append ( findings , checkTimeout ( name , job ) ... )
findings = append ( findings , checkIDTokens ( name , job ) ... )
findings = append ( findings , checkSecrets ( name , job ) ... )
findings = append ( findings , checkPagesKeyword ( name , job ) ... )
findings = append ( findings , checkCacheKeyFiles ( name , job ) ... )
2026-06-05 01:29:07 +02:00
return findings
}
func checkWhen ( name string , job model . Job ) [] Finding {
var findings [] Finding
if job . When != "" && ! validJobWhen [ job . When ] {
findings = append ( findings , Finding {
Severity : Error ,
2026-06-11 22:56:24 +02:00
Rule : RuleInvalidWhen ,
2026-06-05 01:29:07 +02:00
Job : name ,
Message : fmt . Sprintf ( "'when' has invalid value %q; valid: on_success, on_failure, always, manual, delayed, never" , job . When ),
})
}
if job . When == "delayed" && job . StartIn == "" {
findings = append ( findings , Finding {
Severity : Error ,
2026-06-11 22:56:24 +02:00
Rule : RuleDelayedNoStartIn ,
2026-06-05 01:29:07 +02:00
Job : name ,
Message : "'when: delayed' requires 'start_in' (e.g. 'start_in: 30 minutes')" ,
})
}
if job . When != "delayed" && job . StartIn != "" {
findings = append ( findings , Finding {
Severity : Error ,
2026-06-11 22:56:24 +02:00
Rule : RuleStartInNoDelayed ,
2026-06-05 01:29:07 +02:00
Job : name ,
Message : "'start_in' is only valid when 'when: delayed'" ,
})
}
return findings
}
func checkParallel ( name string , job model . Job ) [] Finding {
if job . Parallel == nil {
return nil
}
switch v := job . Parallel .( type ) {
case int :
if v < 2 || v > 200 {
return [] Finding {{
Severity : Error ,
2026-06-11 22:56:24 +02:00
Rule : RuleInvalidParallel ,
2026-06-05 01:29:07 +02:00
Job : name ,
Message : fmt . Sprintf ( "'parallel' must be between 2 and 200, got %d" , v ),
}}
}
case map [ string ] any :
if _ , ok := v [ "matrix" ]; ! ok {
return [] Finding {{
Severity : Error ,
2026-06-11 22:56:24 +02:00
Rule : RuleInvalidParallel ,
2026-06-05 01:29:07 +02:00
Job : name ,
Message : "'parallel' map form must have a 'matrix' key" ,
}}
}
default :
return [] Finding {{
Severity : Error ,
2026-06-11 22:56:24 +02:00
Rule : RuleInvalidParallel ,
2026-06-05 01:29:07 +02:00
Job : name ,
Message : "'parallel' must be an integer (2– 200) or a map with 'matrix'" ,
}}
}
return nil
}
func checkRetry ( name string , job model . Job ) [] Finding {
if job . Retry == nil {
return nil
}
switch v := job . Retry .( type ) {
case int :
if v < 0 || v > 2 {
return [] Finding {{
Severity : Error ,
2026-06-11 22:56:24 +02:00
Rule : RuleInvalidRetry ,
2026-06-05 01:29:07 +02:00
Job : name ,
Message : fmt . Sprintf ( "'retry' must be 0, 1, or 2; got %d" , v ),
}}
}
case map [ string ] any :
var findings [] Finding
if maxVal , ok := v [ "max" ]; ok {
if n , ok := maxVal .( int ); ok && ( n < 0 || n > 2 ) {
findings = append ( findings , Finding {
Severity : Error ,
2026-06-11 22:56:24 +02:00
Rule : RuleInvalidRetry ,
2026-06-05 01:29:07 +02:00
Job : name ,
Message : fmt . Sprintf ( "'retry.max' must be 0, 1, or 2; got %d" , n ),
})
}
}
if whenVal , ok := v [ "when" ]; ok {
findings = append ( findings , validateRetryWhen ( name , whenVal ) ... )
}
return findings
default :
return [] Finding {{
Severity : Error ,
2026-06-11 22:56:24 +02:00
Rule : RuleInvalidRetry ,
2026-06-05 01:29:07 +02:00
Job : name ,
Message : "'retry' must be an integer (0– 2) or a map with 'max'/'when'" ,
}}
}
return nil
}
func validateRetryWhen ( name string , val any ) [] Finding {
var findings [] Finding
check := func ( s string ) {
if ! validRetryWhen [ s ] {
findings = append ( findings , Finding {
Severity : Error ,
2026-06-11 22:56:24 +02:00
Rule : RuleInvalidRetryWhen ,
2026-06-05 01:29:07 +02:00
Job : name ,
Message : fmt . Sprintf ( "'retry.when' has invalid value %q" , s ),
})
}
}
switch v := val .( type ) {
case string :
check ( v )
case [] any :
for _ , item := range v {
if s , ok := item .( string ); ok {
check ( s )
}
}
}
return findings
}
func checkAllowFailure ( name string , job model . Job ) [] Finding {
if job . Allow == nil {
return nil
}
switch v := job . Allow .( type ) {
case bool :
// valid
case map [ string ] any :
if _ , ok := v [ "exit_codes" ]; ! ok {
return [] Finding {{
Severity : Error ,
2026-06-11 22:56:24 +02:00
Rule : RuleInvalidAllowFailure ,
2026-06-05 01:29:07 +02:00
Job : name ,
Message : "'allow_failure' map form must contain 'exit_codes'" ,
}}
}
default :
_ = v
return [] Finding {{
Severity : Error ,
2026-06-11 22:56:24 +02:00
Rule : RuleInvalidAllowFailure ,
2026-06-05 01:29:07 +02:00
Job : name ,
Message : "'allow_failure' must be a boolean or a map with 'exit_codes'" ,
}}
}
return nil
}
func checkInterruptible ( name string , job model . Job ) [] Finding {
if job . Interruptible == nil {
return nil
}
if _ , ok := job . Interruptible .( bool ); ! ok {
return [] Finding {{
Severity : Error ,
2026-06-11 22:56:24 +02:00
Rule : RuleInvalidInterruptible ,
2026-06-05 01:29:07 +02:00
Job : name ,
Message : "'interruptible' must be a boolean" ,
}}
}
return nil
}
func checkTrigger ( name string , job model . Job ) [] Finding {
if job . Trigger == nil {
return nil
}
var findings [] Finding
2026-06-11 00:27:28 +02:00
if scriptNonEmpty ( job . Script ) {
2026-06-05 01:29:07 +02:00
findings = append ( findings , Finding {
Severity : Error ,
2026-06-11 22:56:24 +02:00
Rule : RuleTriggerWithScript ,
2026-06-05 01:29:07 +02:00
Job : name ,
Message : "jobs with 'trigger' cannot use 'script'" ,
})
}
if m , ok := job . Trigger .( map [ string ] any ); ok {
_ , hasProject := m [ "project" ]
_ , hasInclude := m [ "include" ]
if ! hasProject && ! hasInclude {
findings = append ( findings , Finding {
Severity : Error ,
2026-06-11 22:56:24 +02:00
Rule : RuleInvalidTrigger ,
2026-06-05 01:29:07 +02:00
Job : name ,
Message : "'trigger' map must specify 'project' or 'include'" ,
})
}
}
return findings
}
var coveragePattern = regexp . MustCompile ( `^/.*/$` )
func checkCoverage ( name string , job model . Job ) [] Finding {
if job . Coverage == "" {
return nil
}
if ! coveragePattern . MatchString ( job . Coverage ) {
return [] Finding {{
Severity : Error ,
2026-06-11 22:56:24 +02:00
Rule : RuleInvalidCoverage ,
2026-06-05 01:29:07 +02:00
Job : name ,
Message : fmt . Sprintf ( "'coverage' must be a regex pattern wrapped in '/' (e.g. '/\\d+\\.?\\d*%%/'), got %q" , job . Coverage ),
}}
}
return nil
}
func checkRelease ( name string , job model . Job ) [] Finding {
if job . Release == nil {
return nil
}
m , ok := job . Release .( map [ string ] any )
if ! ok {
return [] Finding {{
Severity : Error ,
2026-06-11 22:56:24 +02:00
Rule : RuleInvalidRelease ,
2026-06-05 01:29:07 +02:00
Job : name ,
Message : "'release' must be a map" ,
}}
}
tagName , exists := m [ "tag_name" ]
if ! exists || tagName == "" || tagName == nil {
return [] Finding {{
Severity : Error ,
2026-06-11 22:56:24 +02:00
Rule : RuleInvalidRelease ,
2026-06-05 01:29:07 +02:00
Job : name ,
Message : "'release' requires 'tag_name'" ,
}}
}
return nil
}
func checkEnvironment ( name string , job model . Job ) [] Finding {
if job . Environment == nil {
return nil
}
m , ok := job . Environment .( map [ string ] any )
if ! ok {
// String form: just the environment name — valid.
return nil
}
var findings [] Finding
2026-06-11 23:56:09 +02:00
envName := m [ "name" ]
2026-06-05 01:29:07 +02:00
_ , hasURL := m [ "url" ]
if ( envName == nil || envName == "" ) && hasURL {
findings = append ( findings , Finding {
Severity : Error ,
2026-06-11 22:56:24 +02:00
Rule : RuleInvalidEnvironment ,
2026-06-05 01:29:07 +02:00
Job : name ,
Message : "'environment.url' requires 'environment.name' to be set" ,
})
}
if action , ok := m [ "action" ].( string ); ok && ! validEnvironmentAction [ action ] {
findings = append ( findings , Finding {
Severity : Error ,
2026-06-11 22:56:24 +02:00
Rule : RuleInvalidEnvironment ,
2026-06-05 01:29:07 +02:00
Job : name ,
Message : fmt . Sprintf ( "'environment.action' has invalid value %q; valid: start, stop, prepare, verify, access" , action ),
})
}
return findings
}
func checkArtifacts ( name string , job model . Job ) [] Finding {
if job . Artifacts == nil {
return nil
}
m , ok := job . Artifacts .( map [ string ] any )
if ! ok {
return nil
}
var findings [] Finding
if w , ok := m [ "when" ].( string ); ok && ! validArtifactsWhen [ w ] {
findings = append ( findings , Finding {
Severity : Error ,
2026-06-11 22:56:24 +02:00
Rule : RuleInvalidArtifacts ,
2026-06-05 01:29:07 +02:00
Job : name ,
Message : fmt . Sprintf ( "'artifacts.when' has invalid value %q; valid: on_success, on_failure, always" , w ),
})
}
if _ , hasExposeAs := m [ "expose_as" ]; hasExposeAs {
2026-06-11 23:56:09 +02:00
paths := m [ "paths" ]
2026-06-05 01:29:07 +02:00
if paths == nil {
findings = append ( findings , Finding {
Severity : Error ,
2026-06-11 22:56:24 +02:00
Rule : RuleInvalidArtifacts ,
2026-06-05 01:29:07 +02:00
Job : name ,
Message : "'artifacts.expose_as' requires 'artifacts.paths'" ,
})
}
}
2026-06-14 10:09:16 +02:00
// Pages job should publish to public/. Skip when pages: keyword is set;
// GL039 (checkPagesKeyword) handles that case with a more precise check.
if name == "pages" && job . Pages == nil {
2026-06-05 01:29:07 +02:00
if paths , ok := m [ "paths" ].([] any ); ok {
found := false
for _ , p := range paths {
if s , ok := p .( string ); ok && ( s == "public" || strings . HasPrefix ( s , "public/" )) {
found = true
break
}
}
if ! found {
findings = append ( findings , Finding {
Severity : Warning ,
2026-06-11 22:56:24 +02:00
Rule : RulePagesPublic ,
2026-06-05 01:29:07 +02:00
Job : name ,
Message : "the 'pages' job should include 'public' in 'artifacts.paths' for GitLab Pages to deploy" ,
})
}
}
}
return findings
}
func checkCache ( name string , job model . Job ) [] Finding {
if job . Cache == nil {
return nil
}
var maps [] map [ string ] any
switch v := job . Cache .( type ) {
case map [ string ] any :
maps = [] map [ string ] any { v }
case [] any :
for _ , item := range v {
if m , ok := item .( map [ string ] any ); ok {
maps = append ( maps , m )
}
}
}
var findings [] Finding
for _ , m := range maps {
if w , ok := m [ "when" ].( string ); ok && ! validCacheWhen [ w ] {
findings = append ( findings , Finding {
Severity : Error ,
2026-06-11 22:56:24 +02:00
Rule : RuleInvalidCache ,
2026-06-05 01:29:07 +02:00
Job : name ,
Message : fmt . Sprintf ( "'cache.when' has invalid value %q; valid: on_success, on_failure, always" , w ),
})
}
if p , ok := m [ "policy" ].( string ); ok && ! validCachePolicy [ p ] {
findings = append ( findings , Finding {
Severity : Error ,
2026-06-11 22:56:24 +02:00
Rule : RuleInvalidCache ,
2026-06-05 01:29:07 +02:00
Job : name ,
Message : fmt . Sprintf ( "'cache.policy' has invalid value %q; valid: pull, push, pull-push" , p ),
})
}
}
return findings
}
func checkRules ( name string , job model . Job ) [] Finding {
var findings [] Finding
for i , rule := range job . Rules {
if rule . When != "" && ! validRuleWhen [ rule . When ] {
findings = append ( findings , Finding {
Severity : Error ,
2026-06-11 22:56:24 +02:00
Rule : RuleInvalidRulesWhen ,
2026-06-05 01:29:07 +02:00
Job : name ,
Message : fmt . Sprintf ( "rules[%d].when has invalid value %q; valid: on_success, on_failure, always, manual, delayed, never" , i , rule . When ),
})
}
}
return findings
}
2026-06-14 09:13:19 +02:00
// checkDeadRules reports when every rule in a job's rules: block has an
// explicit when: never, making the job permanently unreachable. This is a
// provably-correct static claim: no matter which if: condition matches, the
// outcome is always "never"; and if no rule matches, the implicit fallback is
// also skip. No if: evaluation is required.
func checkDeadRules ( name string , job model . Job ) [] Finding {
if len ( job . Rules ) == 0 {
return nil
}
for _ , r := range job . Rules {
if r . When != "never" {
return nil
}
}
return [] Finding {{
Severity : Warning ,
Rule : RuleDeadRules ,
Job : name ,
Message : "rules: block can never activate; every rule has 'when: never' — job is permanently excluded from the pipeline" ,
}}
}
2026-06-05 01:29:07 +02:00
func checkImage ( name string , job model . Job ) [] Finding {
if job . Image == nil {
return nil
}
m , ok := job . Image .( map [ string ] any )
if ! ok {
return nil // String form is valid.
}
2026-06-11 23:56:09 +02:00
imgName := m [ "name" ]
2026-06-05 01:29:07 +02:00
if imgName == nil || imgName == "" {
return [] Finding {{
Severity : Error ,
2026-06-11 22:56:24 +02:00
Rule : RuleInvalidImage ,
2026-06-05 01:29:07 +02:00
Job : name ,
Message : "'image' map form requires a 'name' key" ,
}}
}
return nil
}
func checkInherit ( name string , job model . Job ) [] Finding {
if job . Inherit == nil {
return nil
}
m , ok := job . Inherit .( map [ string ] any )
if ! ok {
return nil
}
var findings [] Finding
for _ , key := range [] string { "default" , "variables" } {
val , exists := m [ key ]
if ! exists {
continue
}
switch val .( type ) {
case bool , [] any :
// valid: false/true or list of names
default :
findings = append ( findings , Finding {
Severity : Error ,
2026-06-11 22:56:24 +02:00
Rule : RuleInvalidInherit ,
2026-06-05 01:29:07 +02:00
Job : name ,
Message : fmt . Sprintf ( "'inherit.%s' must be a boolean or a list of names" , key ),
})
}
}
return findings
}
2026-06-14 10:09:16 +02:00
// GL034: services: map form requires 'name'; 'alias' must be a valid DNS label.
func checkServices ( name string , job model . Job ) [] Finding {
if len ( job . Services ) == 0 {
return nil
}
var findings [] Finding
for i , svc := range job . Services {
m , ok := svc .( map [ string ] any )
if ! ok {
continue // string form is valid
}
n := m [ "name" ]
if n == nil || n == "" {
findings = append ( findings , Finding {
Severity : Error ,
Rule : RuleInvalidService ,
Job : name ,
Message : fmt . Sprintf ( "services[%d]: map form requires a 'name' key" , i ),
})
}
if alias , ok := m [ "alias" ].( string ); ok && alias != "" {
if ! dnsLabelPattern . MatchString ( alias ) {
findings = append ( findings , Finding {
Severity : Error ,
Rule : RuleInvalidService ,
Job : name ,
Message : fmt . Sprintf ( "services[%d]: alias %q is not a valid DNS label (only letters, digits, hyphens, and dots; must start and end with alphanumeric)" , i , alias ),
})
}
}
}
return findings
}
// GL035: rules:changes and rules:exists paths must not be absolute.
func checkRulesGlobs ( name string , job model . Job ) [] Finding {
var findings [] Finding
for i , rule := range job . Rules {
findings = append ( findings , checkAbsolutePaths ( name , fmt . Sprintf ( "rules[%d].changes" , i ), rule . Changes ) ... )
findings = append ( findings , checkAbsolutePaths ( name , fmt . Sprintf ( "rules[%d].exists" , i ), rule . Exists ) ... )
}
return findings
}
func checkAbsolutePaths ( name , field string , val any ) [] Finding {
var paths [] string
switch v := val .( type ) {
case [] any :
for _ , item := range v {
if s , ok := item .( string ); ok {
paths = append ( paths , s )
}
}
case map [ string ] any :
if ps , ok := v [ "paths" ].([] any ); ok {
for _ , item := range ps {
if s , ok := item .( string ); ok {
paths = append ( paths , s )
}
}
}
}
var findings [] Finding
for _ , p := range paths {
if strings . HasPrefix ( p , "/" ) {
findings = append ( findings , Finding {
Severity : Warning ,
Rule : RuleAbsoluteGlobPath ,
Job : name ,
Message : fmt . Sprintf ( "%s: path %q is absolute; GitLab CI paths are relative to the repository root — absolute paths will never match" , field , p ),
})
}
}
return findings
}
// GL036: timeout: must be a valid GitLab CI duration string.
func checkTimeout ( name string , job model . Job ) [] Finding {
if job . Timeout == "" {
return nil
}
if ! timeoutPattern . MatchString ( job . Timeout ) {
return [] Finding {{
Severity : Error ,
Rule : RuleInvalidTimeout ,
Job : name ,
Message : fmt . Sprintf ( "'timeout' has invalid value %q; expected a duration like '1h 30m', '90 minutes', '2 hours'" , job . Timeout ),
}}
}
return nil
}
// CheckDefaultTimeout validates the pipeline-level default: timeout: field.
// Exported so it can be called from linter.go.
func checkDefaultTimeout ( timeout , file string ) [] Finding {
if timeout == "" {
return nil
}
if ! timeoutPattern . MatchString ( timeout ) {
return [] Finding {{
Severity : Error ,
Rule : RuleInvalidTimeout ,
File : file ,
Message : fmt . Sprintf ( "'default.timeout' has invalid value %q; expected a duration like '1h 30m', '90 minutes', '2 hours'" , timeout ),
}}
}
return nil
}
// GL037: id_tokens: each entry must have an 'aud' key.
func checkIDTokens ( name string , job model . Job ) [] Finding {
if job . IDTokens == nil {
return nil
}
m , ok := job . IDTokens .( map [ string ] any )
if ! ok {
return nil
}
var findings [] Finding
for tokenName , tokenVal := range m {
tokenMap , ok := tokenVal .( map [ string ] any )
if ! ok {
findings = append ( findings , Finding {
Severity : Error ,
Rule : RuleInvalidIDToken ,
Job : name ,
Message : fmt . Sprintf ( "id_tokens.%s: must be a map with an 'aud' key" , tokenName ),
})
continue
}
if _ , hasAud := tokenMap [ "aud" ]; ! hasAud {
findings = append ( findings , Finding {
Severity : Error ,
Rule : RuleInvalidIDToken ,
Job : name ,
Message : fmt . Sprintf ( "id_tokens.%s: missing required 'aud' key" , tokenName ),
})
}
}
return findings
}
// GL038: secrets: each entry must have a provider key (vault, gcp_secret_manager, azure_key_vault).
func checkSecrets ( name string , job model . Job ) [] Finding {
if job . Secrets == nil {
return nil
}
m , ok := job . Secrets .( map [ string ] any )
if ! ok {
return nil
}
validProviders := [] string { "vault" , "gcp_secret_manager" , "azure_key_vault" }
var findings [] Finding
for secretName , secretVal := range m {
secretMap , ok := secretVal .( map [ string ] any )
if ! ok {
findings = append ( findings , Finding {
Severity : Error ,
Rule : RuleInvalidSecret ,
Job : name ,
Message : fmt . Sprintf ( "secrets.%s: must be a map with a provider key (vault, gcp_secret_manager, or azure_key_vault)" , secretName ),
})
continue
}
hasProvider := false
for _ , provider := range validProviders {
if _ , ok := secretMap [ provider ]; ok {
hasProvider = true
break
}
}
if ! hasProvider {
findings = append ( findings , Finding {
Severity : Error ,
Rule : RuleInvalidSecret ,
Job : name ,
Message : fmt . Sprintf ( "secrets.%s: missing provider key; must include one of: vault, gcp_secret_manager, azure_key_vault" , secretName ),
})
}
}
return findings
}
// GL039: a job with the pages: keyword must have the publish directory in artifacts.paths.
func checkPagesKeyword ( name string , job model . Job ) [] Finding {
if job . Pages == nil {
return nil
}
publishDir := "public"
if m , ok := job . Pages .( map [ string ] any ); ok {
if p , ok := m [ "publish" ].( string ); ok && p != "" {
publishDir = p
}
}
if job . Artifacts == nil {
return [] Finding {{
Severity : Warning ,
Rule : RulePagesPublish ,
Job : name ,
Message : fmt . Sprintf ( "job uses 'pages:' keyword with publish dir %q but has no 'artifacts:' block — GitLab Pages will not deploy" , publishDir ),
}}
}
m , ok := job . Artifacts .( map [ string ] any )
if ! ok {
return nil
}
paths , ok := m [ "paths" ].([] any )
if ! ok || len ( paths ) == 0 {
return [] Finding {{
Severity : Warning ,
Rule : RulePagesPublish ,
Job : name ,
Message : fmt . Sprintf ( "job uses 'pages:' keyword with publish dir %q but 'artifacts.paths' is missing — GitLab Pages will not deploy" , publishDir ),
}}
}
for _ , p := range paths {
if s , ok := p .( string ); ok && ( s == publishDir || strings . HasPrefix ( s , publishDir + "/" )) {
return nil
}
}
return [] Finding {{
Severity : Warning ,
Rule : RulePagesPublish ,
Job : name ,
Message : fmt . Sprintf ( "'pages.publish' is %q but 'artifacts.paths' does not include it — GitLab Pages will not deploy" , publishDir ),
}}
}
// GL041: cache.key.files must be a list of exact file paths, not glob patterns.
func checkCacheKeyFiles ( name string , job model . Job ) [] Finding {
if job . Cache == nil {
return nil
}
var maps [] map [ string ] any
switch v := job . Cache .( type ) {
case map [ string ] any :
maps = [] map [ string ] any { v }
case [] any :
for _ , item := range v {
if m , ok := item .( map [ string ] any ); ok {
maps = append ( maps , m )
}
}
}
var findings [] Finding
for _ , m := range maps {
keyVal , ok := m [ "key" ]
if ! ok {
continue
}
keyMap , ok := keyVal .( map [ string ] any )
if ! ok {
continue
}
filesVal , ok := keyMap [ "files" ]
if ! ok {
continue
}
filesList , ok := filesVal .([] any )
if ! ok {
findings = append ( findings , Finding {
Severity : Error ,
Rule : RuleInvalidCacheKeyFiles ,
Job : name ,
Message : "'cache.key.files' must be a list of file paths" ,
})
continue
}
for _ , item := range filesList {
s , ok := item .( string )
if ! ok {
continue
}
if strings . ContainsAny ( s , "*?[" ) {
findings = append ( findings , Finding {
Severity : Warning ,
Rule : RuleInvalidCacheKeyFiles ,
Job : name ,
Message : fmt . Sprintf ( "cache.key.files: %q looks like a glob pattern; 'key.files' must be exact file paths, not globs" , s ),
})
}
}
}
return findings
}