feat(security): security hardening, proxy support, and GL045 HTTP include warning
Security fixes: - Path traversal guard in include: local: — paths with ../ that escape the repo root are rejected instead of reading arbitrary host files - HTTP timeout (30 s) on all fetcher requests to prevent indefinite hangs - Response size cap (10 MiB) via io.LimitReader to prevent memory exhaustion - Cache directory and file permissions tightened to 0700/0600 - LSP Content-Length cap (64 MiB) to guard against DoS from a malicious client New feature: - --proxy flag on check, graph, and lsp subcommands; also proxy: key in .glint.yml; overrides system HTTP_PROXY / HTTPS_PROXY env vars when set; cmdGraph and cmdLSP now also load .glint.yml for proxy/token/url fallbacks New lint rule: - GL045 (Warning): include: remote: using plain http:// instead of https:// Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -853,4 +853,18 @@ test:
|
||||
- if: $CI_COMMIT_BRANCH == "main"
|
||||
needs: [build, lint]`,
|
||||
},
|
||||
|
||||
RuleInsecureRemoteInclude: {
|
||||
Title: "remote include uses plain HTTP",
|
||||
Severity: Warning,
|
||||
Description: "An include: remote: entry uses an http:// URL instead of https://. " +
|
||||
"CI templates fetched over plain HTTP are transmitted in cleartext; an " +
|
||||
"attacker with network access could intercept or modify the template " +
|
||||
"before it is parsed. Use https:// so the connection is encrypted and " +
|
||||
"the server's identity is verified.",
|
||||
Example: `include:
|
||||
- remote: http://ci-templates.example.com/build.yml`,
|
||||
Fix: `include:
|
||||
- remote: https://ci-templates.example.com/build.yml`,
|
||||
},
|
||||
}
|
||||
|
||||
@@ -0,0 +1,31 @@
|
||||
package linter
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"git.k3nny.fr/glint/internal/model"
|
||||
)
|
||||
|
||||
// checkInsecureRemoteInclude warns when an include: remote: entry uses plain
|
||||
// HTTP (GL045). The file is still fetched and linted; the finding is a warning
|
||||
// so pipelines that use HTTP for internal infra are not blocked.
|
||||
func checkInsecureRemoteInclude(p *model.Pipeline) []Finding {
|
||||
var findings []Finding
|
||||
for _, inc := range p.Include {
|
||||
m, ok := inc.(map[string]any)
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
remote, _ := m["remote"].(string)
|
||||
if strings.HasPrefix(remote, "http://") {
|
||||
findings = append(findings, Finding{
|
||||
Severity: Warning,
|
||||
Rule: RuleInsecureRemoteInclude,
|
||||
File: p.SourceFile,
|
||||
Message: fmt.Sprintf("remote include %q uses plain HTTP; CI templates are fetched unencrypted — prefer HTTPS", remote),
|
||||
})
|
||||
}
|
||||
}
|
||||
return findings
|
||||
}
|
||||
@@ -0,0 +1,73 @@
|
||||
package linter
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"git.k3nny.fr/glint/internal/model"
|
||||
)
|
||||
|
||||
func TestCheckInsecureRemoteInclude(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
include []any
|
||||
wantGL string // expected rule ID, empty = no findings
|
||||
}{
|
||||
{
|
||||
name: "http URL triggers GL045",
|
||||
include: []any{map[string]any{"remote": "http://example.com/ci.yml"}},
|
||||
wantGL: RuleInsecureRemoteInclude,
|
||||
},
|
||||
{
|
||||
name: "https URL is clean",
|
||||
include: []any{map[string]any{"remote": "https://example.com/ci.yml"}},
|
||||
},
|
||||
{
|
||||
name: "local include ignored",
|
||||
include: []any{map[string]any{"local": "/templates/ci.yml"}},
|
||||
},
|
||||
{
|
||||
name: "string include ignored",
|
||||
include: []any{"/templates/ci.yml"},
|
||||
},
|
||||
{
|
||||
name: "no includes",
|
||||
include: nil,
|
||||
},
|
||||
{
|
||||
name: "mixed: http fires, https does not",
|
||||
include: []any{
|
||||
map[string]any{"remote": "https://ok.example.com/ci.yml"},
|
||||
map[string]any{"remote": "http://bad.example.com/ci.yml"},
|
||||
},
|
||||
wantGL: RuleInsecureRemoteInclude,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tc := range tests {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
p := &model.Pipeline{
|
||||
Include: tc.include,
|
||||
Jobs: map[string]model.Job{},
|
||||
}
|
||||
findings := checkInsecureRemoteInclude(p)
|
||||
if tc.wantGL == "" {
|
||||
if len(findings) != 0 {
|
||||
t.Errorf("expected no findings, got: %v", findings)
|
||||
}
|
||||
return
|
||||
}
|
||||
found := false
|
||||
for _, f := range findings {
|
||||
if f.Rule == tc.wantGL {
|
||||
found = true
|
||||
if f.Severity != Warning {
|
||||
t.Errorf("severity = %v; want Warning", f.Severity)
|
||||
}
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
t.Errorf("expected finding %s, got: %v", tc.wantGL, findings)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -67,6 +67,7 @@ func Lint(p *model.Pipeline, skipped map[string]bool) []Finding {
|
||||
findings = append(findings, checkVariableRefs(p)...)
|
||||
findings = append(findings, checkRulesIfReachability(p)...)
|
||||
findings = append(findings, checkInheritCompleteness(p)...)
|
||||
findings = append(findings, checkInsecureRemoteInclude(p)...)
|
||||
slices.SortStableFunc(findings, func(a, b Finding) int {
|
||||
if c := cmp.Compare(a.File, b.File); c != 0 {
|
||||
return c
|
||||
|
||||
@@ -158,4 +158,9 @@ const (
|
||||
// GL044: a rules:needs: entry references a job that does not exist in the pipeline.
|
||||
// rules:needs: overrides the top-level needs: when a specific rule matches (GitLab CI 16.4+).
|
||||
RuleRulesNeedsUnknown = "GL044"
|
||||
|
||||
// GL045: an include: remote: entry uses plain HTTP instead of HTTPS.
|
||||
// CI templates fetched over HTTP are transmitted in cleartext and can be
|
||||
// intercepted or modified in transit.
|
||||
RuleInsecureRemoteInclude = "GL045"
|
||||
)
|
||||
|
||||
Reference in New Issue
Block a user