Skip to content

Commit c0c56d0

Browse files
authored
Add Poutine MCP Server (#346)
1 parent d180637 commit c0c56d0

File tree

8 files changed

+1076
-0
lines changed

8 files changed

+1076
-0
lines changed

analyze/analyze.go

Lines changed: 114 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,9 @@ package analyze
33

44
import (
55
"context"
6+
"errors"
67
"fmt"
8+
"io"
79
"os"
810
"path/filepath"
911
"regexp"
@@ -478,6 +480,118 @@ func (a *Analyzer) AnalyzeLocalRepo(ctx context.Context, repoPath string) (*mode
478480
return scannedPackage, nil
479481
}
480482

483+
func (a *Analyzer) AnalyzeManifest(ctx context.Context, manifestReader io.Reader, manifestType string) (*models.PackageInsights, error) {
484+
provider := "manifest"
485+
providerVersion := "unknown"
486+
487+
pkgSupplyClient := pkgsupply.NewStaticClient()
488+
inventory := scanner.NewInventory(a.Opa, pkgSupplyClient, provider, providerVersion)
489+
490+
log.Debug().Msg("Starting manifest analysis")
491+
492+
manifestData, err := io.ReadAll(manifestReader)
493+
if err != nil {
494+
return nil, fmt.Errorf("failed to read manifest: %w", err)
495+
}
496+
497+
if manifestType == "" {
498+
return nil, errors.New("invalid manifest type")
499+
}
500+
501+
filename := a.getManifestFilename(manifestType)
502+
503+
pkg := a.createManifestPackageInsights(manifestType)
504+
505+
inventoryScanner := scanner.InventoryScannerMem{
506+
Files: map[string][]byte{
507+
filename: manifestData,
508+
},
509+
Parsers: []scanner.MemParser{
510+
scanner.NewGithubActionWorkflowParser(),
511+
scanner.NewGithubActionsMetadataParser(),
512+
scanner.NewGitlabCiParser(),
513+
scanner.NewAzurePipelinesParser(),
514+
scanner.NewPipelineAsCodeTektonParser(),
515+
},
516+
}
517+
518+
scannedPackage, err := inventory.ScanPackageScanner(ctx, *pkg, &inventoryScanner)
519+
if err != nil {
520+
return nil, fmt.Errorf("failed to scan manifest: %w", err)
521+
}
522+
523+
err = a.finalizeAnalysis(ctx, []*models.PackageInsights{scannedPackage})
524+
if err != nil {
525+
return nil, err
526+
}
527+
528+
return scannedPackage, nil
529+
}
530+
531+
func (a *Analyzer) getManifestFilename(manifestType string) string {
532+
switch manifestType {
533+
case "github-actions":
534+
return ".github/workflows/manifest.yml"
535+
case "gitlab-ci":
536+
return ".gitlab-ci.yml"
537+
case "azure-pipelines":
538+
return "azure-pipelines.yml"
539+
case "tekton":
540+
return ".tekton/manifest.yml"
541+
default:
542+
return ".github/workflows/manifest.yml"
543+
}
544+
}
545+
546+
func (a *Analyzer) createManifestPackageInsights(manifestType string) *models.PackageInsights {
547+
var purlString string
548+
switch manifestType {
549+
case "github-actions":
550+
purlString = "pkg:generic/github-actions-workflow"
551+
case "gitlab-ci":
552+
purlString = "pkg:generic/gitlab-ci-config"
553+
case "azure-pipelines":
554+
purlString = "pkg:generic/azure-pipelines-config"
555+
case "tekton":
556+
purlString = "pkg:generic/tekton-pipeline"
557+
default:
558+
purlString = "pkg:generic/ci-workflow"
559+
}
560+
561+
purl, _ := models.NewPurl(purlString)
562+
563+
pkg := &models.PackageInsights{
564+
LastCommitedAt: time.Now().Format(time.RFC3339),
565+
Purl: purl.String(),
566+
SourceScmType: "manifest",
567+
SourceGitRepo: "workflow/" + manifestType,
568+
SourceGitRef: "HEAD",
569+
SourceGitCommitSha: "unknown",
570+
OrgID: 0,
571+
RepoID: 0,
572+
RepoSize: 0,
573+
DefaultBranch: "main",
574+
IsFork: false,
575+
IsEmpty: false,
576+
ForksCount: 0,
577+
StarsCount: 0,
578+
IsTemplate: false,
579+
HasIssues: false,
580+
OpenIssuesCount: 0,
581+
HasWiki: false,
582+
HasDiscussions: false,
583+
PrimaryLanguage: "YAML",
584+
License: "",
585+
}
586+
587+
err := pkg.NormalizePurl()
588+
if err != nil {
589+
log.Warn().Err(err).Msg("failed to normalize purl for manifest")
590+
}
591+
592+
return pkg
593+
}
594+
481595
type Formatter interface {
482596
Format(ctx context.Context, packages []*models.PackageInsights) error
483597
FormatWithPath(ctx context.Context, packages []*models.PackageInsights, pathAssociation map[string][]*models.RepoInfo) error

analyze/analyze_test.go

Lines changed: 190 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,190 @@
1+
package analyze
2+
3+
import (
4+
"context"
5+
"fmt"
6+
"strings"
7+
"testing"
8+
9+
"github.com/boostsecurityio/poutine/formatters/noop"
10+
"github.com/boostsecurityio/poutine/models"
11+
"github.com/boostsecurityio/poutine/opa"
12+
"github.com/stretchr/testify/assert"
13+
"github.com/stretchr/testify/require"
14+
)
15+
16+
// newTestOpa creates an OPA client for testing
17+
func newTestOpa(ctx context.Context) (*opa.Opa, error) {
18+
config := models.DefaultConfig()
19+
opaClient, err := opa.NewOpa(ctx, config)
20+
if err != nil {
21+
return nil, fmt.Errorf("failed to create opa client: %w", err)
22+
}
23+
return opaClient, nil
24+
}
25+
26+
func TestAnalyzeManifestDirectly(t *testing.T) {
27+
ctx := context.Background()
28+
29+
tests := []struct {
30+
name string
31+
content string
32+
manifestType string
33+
expectedType string
34+
validateResult func(t *testing.T, insights *models.PackageInsights)
35+
}{
36+
{
37+
name: "valid github actions workflow",
38+
content: `name: Test Workflow
39+
on: [push, pull_request]
40+
41+
jobs:
42+
test:
43+
runs-on: ubuntu-latest
44+
steps:
45+
- uses: actions/checkout@v4
46+
- name: Run tests
47+
run: echo "Running tests"`,
48+
manifestType: "github-actions",
49+
expectedType: "github-actions",
50+
validateResult: func(t *testing.T, insights *models.PackageInsights) {
51+
assert.Equal(t, "manifest", insights.SourceScmType)
52+
assert.Contains(t, insights.Purl, "pkg:generic/github-actions-workflow")
53+
assert.Equal(t, "YAML", insights.PrimaryLanguage)
54+
assert.Len(t, insights.GithubActionsWorkflows, 1, "Should detect GitHub Actions workflow")
55+
},
56+
},
57+
{
58+
name: "valid gitlab ci config",
59+
content: `stages:
60+
- build
61+
- test
62+
63+
variables:
64+
DOCKER_DRIVER: overlay2
65+
66+
build_job:
67+
stage: build
68+
script:
69+
- echo "Building application"
70+
71+
test_job:
72+
stage: test
73+
script:
74+
- echo "Running tests"`,
75+
manifestType: "gitlab-ci",
76+
expectedType: "gitlab-ci",
77+
validateResult: func(t *testing.T, insights *models.PackageInsights) {
78+
assert.Equal(t, "manifest", insights.SourceScmType)
79+
assert.Contains(t, insights.Purl, "pkg:generic/gitlab-ci-config")
80+
assert.Len(t, insights.GitlabciConfigs, 1, "Should detect GitLab CI config")
81+
},
82+
},
83+
{
84+
name: "vulnerable github actions workflow",
85+
content: `name: Vulnerable Workflow
86+
on: pull_request_target
87+
88+
jobs:
89+
test:
90+
runs-on: ubuntu-latest
91+
steps:
92+
- uses: actions/checkout@main
93+
- name: Vulnerable command
94+
run: |
95+
curl -fsSL https://example.com/script.sh | bash
96+
echo "${{ github.event.pull_request.title }}" | bash`,
97+
manifestType: "github-actions",
98+
expectedType: "github-actions",
99+
validateResult: func(t *testing.T, insights *models.PackageInsights) {
100+
assert.Contains(t, insights.Purl, "pkg:generic/github-actions-workflow")
101+
assert.Len(t, insights.GithubActionsWorkflows, 1, "Should detect workflow")
102+
103+
workflow := insights.GithubActionsWorkflows[0]
104+
assert.Equal(t, "Vulnerable Workflow", workflow.Name)
105+
106+
assert.Len(t, insights.FindingsResults.Findings, 3, "May have security findings for vulnerable workflow")
107+
},
108+
},
109+
{
110+
name: "azure pipelines config",
111+
content: `trigger:
112+
- main
113+
114+
pool:
115+
vmImage: ubuntu-latest
116+
117+
steps:
118+
- task: UseDotNet@2
119+
displayName: 'Install .NET'
120+
inputs:
121+
version: '6.0.x'
122+
123+
- script: dotnet build
124+
displayName: 'Build application'`,
125+
manifestType: "azure-pipelines",
126+
expectedType: "azure-pipelines",
127+
validateResult: func(t *testing.T, insights *models.PackageInsights) {
128+
assert.Contains(t, insights.Purl, "pkg:generic/azure-pipelines-config")
129+
assert.Len(t, insights.AzurePipelines, 1, "Should detect Azure Pipeline")
130+
},
131+
},
132+
}
133+
134+
for _, tt := range tests {
135+
t.Run(tt.name, func(t *testing.T) {
136+
opaClient, err := newTestOpa(ctx)
137+
require.NoError(t, err, "Failed to create OPA client")
138+
139+
formatter := &noop.Format{}
140+
analyzer := NewAnalyzer(nil, nil, formatter, models.DefaultConfig(), opaClient)
141+
142+
manifestReader := strings.NewReader(tt.content)
143+
result, err := analyzer.AnalyzeManifest(ctx, manifestReader, tt.manifestType)
144+
145+
require.NoError(t, err, "AnalyzeManifest should not return an error")
146+
require.NotNil(t, result, "Result should not be nil")
147+
148+
if tt.validateResult != nil {
149+
tt.validateResult(t, result)
150+
}
151+
})
152+
}
153+
}
154+
155+
func TestAnalyzeManifestErrorHandling(t *testing.T) {
156+
ctx := context.Background()
157+
158+
opaClient, err := newTestOpa(ctx)
159+
require.NoError(t, err)
160+
161+
formatter := &noop.Format{}
162+
analyzer := NewAnalyzer(nil, nil, formatter, models.DefaultConfig(), opaClient)
163+
164+
t.Run("empty content", func(t *testing.T) {
165+
manifestReader := strings.NewReader("")
166+
result, err := analyzer.AnalyzeManifest(ctx, manifestReader, "github-actions")
167+
168+
require.NoError(t, err)
169+
require.NotNil(t, result)
170+
assert.Equal(t, "manifest", result.SourceScmType)
171+
})
172+
173+
t.Run("invalid yaml", func(t *testing.T) {
174+
invalidYaml := `name: Test
175+
on: push
176+
jobs:
177+
test:
178+
runs-on: ubuntu-latest
179+
steps:
180+
- uses: actions/checkout@v4
181+
- name: "Unclosed quote
182+
run: echo "test"`
183+
184+
manifestReader := strings.NewReader(invalidYaml)
185+
result, err := analyzer.AnalyzeManifest(ctx, manifestReader, "github-actions")
186+
187+
require.NoError(t, err)
188+
require.NotNil(t, result)
189+
})
190+
}

0 commit comments

Comments
 (0)