Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Implemented the REST API #1452

Merged
merged 11 commits into from
Nov 15, 2023
Merged
Show file tree
Hide file tree
Changes from 10 commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
16 changes: 16 additions & 0 deletions .goreleaser.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -141,6 +141,22 @@ builds:
goarch: arm64
- goos: windows
goarch: arm
- main: ./cmd/guacrest
id: guacrest
binary: guacrest-{{ .Os }}-{{ .Arch }}
ldflags:
- -X {{.Env.PKG}}.Commit={{.FullCommit}}
- -X {{.Env.PKG}}.Date={{.Date}}
- -X {{.Env.PKG}}.Version={{.Summary}}
goarch:
- amd64
- arm64
- arm
ignore:
- goos: windows
goarch: arm64
- goos: windows
goarch: arm

universal_binaries:
- replace: true
Expand Down
9 changes: 9 additions & 0 deletions cmd/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -61,6 +61,15 @@ services:
- polling options
- flag to toggle retrieving deps

**guacrest**

**The guacrest is currently an EXPERIMENTAL Feature!**

- what it does: runs a REST API server
- options:
- listening port
- gql endpoint

## Collectors and Certifiers

These appear both in `guacone` and in `guaccollect`. The difference is that
Expand Down
25 changes: 25 additions & 0 deletions cmd/guacrest/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
# REST API Documentation

## The guacrest is currently an EXPERIMENTAL Feature!

## Implementation:

* Using gin-gonic gin framework for building REST API

## Available HTTP Methods:

* **GET** pURL - Fetches a known item using a given pURL. The pURL is a mandatory parameter.
* **Success Response**:
* If the pURL is valid and the known item is found, the server responds with HTTP status code `200` and includes the known item in the response body.
* **Error Responses**:
* If the pURL is invalid, the server responds with HTTP status code `400` (Bad Request).
* If the known item is not found for the provided pURL, the server responds with HTTP status code `404` (Not Found).
* For any other server errors, the server responds with HTTP status code `500` (Internal Server Error).

## Endpoints:

- `/known/package/*hash`
- `/known/source/*vcs`
- `/known/artifact/*artifact`
- `/vuln/*purl`
- `/bad`
236 changes: 236 additions & 0 deletions cmd/guacrest/bad.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,236 @@
//
// Copyright 2023 The GUAC Authors.
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.

package main

import (
"context"
"errors"
"fmt"
"net/http"
"strconv"
"strings"

"github.com/Khan/genqlient/graphql"
"github.com/gin-gonic/gin"
model "github.com/guacsec/guac/pkg/assembler/clients/generated"
)

// badHandler is a function that returns a gin.HandlerFunc. It handles requests to the /bad endpoint.
// This comment is for Swagger documentation
// @Summary Vulnerability handler
// @Description Handles the vulnerability based on the context
// @Tags Vulnerabilities
// @Accept json
// @Produce json
// @Param purl path string true "PURL"
// @Success 200 {object} Response
// @Failure 400 {object} HTTPError
// @Failure 404 {object} HTTPError
// @Failure 500 {object} HTTPError
// @Router /vuln/{purl} [get]
func badHandler(ctx context.Context) func(c *gin.Context) {
return func(c *gin.Context) {
graphqlEndpoint, searchDepth, err := parseBadQueryParameters(c)

if err != nil {
c.JSON(http.StatusBadRequest, HTTPError{http.StatusBadRequest, fmt.Sprintf("error parsing query parameters: %v", err)})
return
}

httpClient := &http.Client{Timeout: httpTimeout}
gqlclient := graphql.NewClient(graphqlEndpoint, httpClient)

certifyBadResponse, err := model.CertifyBads(ctx, gqlclient, model.CertifyBadSpec{})
if err != nil {
c.JSON(http.StatusInternalServerError, HTTPError{http.StatusInternalServerError, fmt.Sprintf("error querying for package: %v", err)})
return
}

// Iterate over the bad certifications.
for _, certifyBad := range certifyBadResponse.CertifyBad {
// Handle the different types of subjects.
switch subject := certifyBad.Subject.(type) {
case *model.AllCertifyBadSubjectPackage:
var path []string

var pkgVersions []model.AllPkgTreeNamespacesPackageNamespaceNamesPackageNameVersionsPackageVersion
if len(subject.Namespaces[0].Names[0].Versions) == 0 {
pkgFilter := &model.PkgSpec{
Type: &subject.Type,
Namespace: &subject.Namespaces[0].Namespace,
Name: &subject.Namespaces[0].Names[0].Name,
}
pkgResponse, err := model.Packages(ctx, gqlclient, *pkgFilter)
if err != nil {
c.JSON(http.StatusInternalServerError, HTTPError{http.StatusInternalServerError, fmt.Sprintf("error querying for package: %v", err)})
return
}
if len(pkgResponse.Packages) != 1 {
c.JSON(http.StatusInternalServerError, HTTPError{http.StatusInternalServerError, "failed to located package based on package from certifyBad"})
return
}
pkgVersions = pkgResponse.Packages[0].Namespaces[0].Names[0].Versions
} else {
pkgVersions = subject.Namespaces[0].Names[0].Versions
}

pkgPath, err := searchDependencyPackagesReverse(ctx, gqlclient, "", pkgVersions[0].Id, searchDepth)
if err != nil {
c.JSON(http.StatusInternalServerError, HTTPError{http.StatusInternalServerError, fmt.Sprintf("error searching dependency packages match: %v", err)})
return
}

if len(pkgPath) > 0 {
for _, version := range pkgVersions {
path = append([]string{certifyBad.Id,
version.Id,
subject.Namespaces[0].Names[0].Id, subject.Namespaces[0].Id,
subject.Id}, pkgPath...)
}

response := Response{
VisualizerURL: fmt.Sprintf("http://localhost:3000/?path=%v", strings.Join(removeDuplicateValuesFromPath(path), `,`)),
}
c.IndentedJSON(http.StatusOK, response)
} else {
c.JSON(http.StatusNotFound, HTTPError{http.StatusNotFound, "No paths to bad package found!\n"})
}
case *model.AllCertifyBadSubjectSource:
var path []string
srcFilter := &model.SourceSpec{
Type: &subject.Type,
Namespace: &subject.Namespaces[0].Namespace,
Name: &subject.Namespaces[0].Names[0].Name,
Tag: subject.Namespaces[0].Names[0].Tag,
Commit: subject.Namespaces[0].Names[0].Commit,
}
srcResponse, err := model.Sources(ctx, gqlclient, *srcFilter)
if err != nil {
c.JSON(http.StatusInternalServerError, HTTPError{http.StatusInternalServerError, fmt.Sprintf("error querying for sources: %v", err)})
return
}
if len(srcResponse.Sources) != 1 {
c.JSON(http.StatusInternalServerError, HTTPError{http.StatusInternalServerError, "failed to located sources based on vcs"})
return
}

neighborResponse, err := model.Neighbors(ctx, gqlclient, srcResponse.Sources[0].Namespaces[0].Names[0].Id, []model.Edge{model.EdgeSourceHasSourceAt, model.EdgeSourceIsOccurrence})
if err != nil {
c.JSON(http.StatusInternalServerError, HTTPError{http.StatusInternalServerError, fmt.Sprintf("error querying neighbors: %v", err)})
return
}
for _, neighbor := range neighborResponse.Neighbors {
switch v := neighbor.(type) {
case *model.NeighborsNeighborsHasSourceAt:
if len(v.Package.Namespaces[0].Names[0].Versions) > 0 {
path = append(path, v.Id, v.Package.Namespaces[0].Names[0].Versions[0].Id, v.Package.Namespaces[0].Names[0].Id, v.Package.Namespaces[0].Id, v.Package.Id)
} else {
path = append(path, v.Id, v.Package.Namespaces[0].Names[0].Id, v.Package.Namespaces[0].Id, v.Package.Id)
}
case *model.NeighborsNeighborsIsOccurrence:
path = append(path, v.Id, v.Artifact.Id)
default:
continue
}
}

if len(path) > 0 {
fullCertifyBadPath := append([]string{certifyBad.Id,
subject.Namespaces[0].Names[0].Id,
subject.Namespaces[0].Id, subject.Id}, path...)
path = append(path, fullCertifyBadPath...)
response := Response{
VisualizerURL: fmt.Sprintf("http://localhost:3000/?path=%v", strings.Join(removeDuplicateValuesFromPath(path), `,`)),
}
c.IndentedJSON(http.StatusOK, response)
} else {
c.JSON(http.StatusNotFound, HTTPError{http.StatusNotFound, "No paths to bad source found!\n"})
}

case *model.AllCertifyBadSubjectArtifact:
var path []string
artifactFilter := &model.ArtifactSpec{
Algorithm: &subject.Algorithm,
Digest: &subject.Digest,
}

artifactResponse, err := model.Artifacts(ctx, gqlclient, *artifactFilter)
if err != nil {
c.JSON(http.StatusInternalServerError, HTTPError{http.StatusInternalServerError, fmt.Sprintf("error querying for artifacts: %v", err)})
return
}
if len(artifactResponse.Artifacts) != 1 {
c.JSON(http.StatusInternalServerError, HTTPError{http.StatusInternalServerError, "failed to located artifacts based on (algorithm:digest)"})
return
}
neighborResponse, err := model.Neighbors(ctx, gqlclient, artifactResponse.Artifacts[0].Id, []model.Edge{model.EdgeArtifactHashEqual, model.EdgeArtifactIsOccurrence})
if err != nil {
c.JSON(http.StatusInternalServerError, HTTPError{http.StatusInternalServerError, fmt.Sprintf("error querying neighbors: %v", err)})
return
}
for _, neighbor := range neighborResponse.Neighbors {
switch v := neighbor.(type) {
case *model.NeighborsNeighborsHashEqual:
path = append(path, v.Id)
case *model.NeighborsNeighborsIsOccurrence:
switch occurrenceSubject := v.Subject.(type) {
case *model.AllIsOccurrencesTreeSubjectPackage:
path = append(path, v.Id, occurrenceSubject.Namespaces[0].Names[0].Versions[0].Id, occurrenceSubject.Namespaces[0].Names[0].Id, occurrenceSubject.Namespaces[0].Id, occurrenceSubject.Id)
case *model.AllIsOccurrencesTreeSubjectSource:
path = append(path, v.Id, occurrenceSubject.Namespaces[0].Names[0].Id, occurrenceSubject.Namespaces[0].Id, occurrenceSubject.Id)
}
default:
continue
}
}

if len(path) > 0 {
path = append(path, append([]string{certifyBad.Id, subject.Id}, path...)...)
response := Response{
VisualizerURL: fmt.Sprintf("http://localhost:3000/?path=%v", strings.Join(removeDuplicateValuesFromPath(path), `,`)),
}
c.IndentedJSON(http.StatusOK, response)
} else {
c.JSON(http.StatusNotFound, HTTPError{http.StatusNotFound, "No paths to bad artifact found!\n"})
}
}
}
}
}

// parseBadQueryParameters is a helper function that parses the query parameters from a request.
func parseBadQueryParameters(c *gin.Context) (string, int, error) {
graphqlEndpoint := c.Query("gql_addr")

if graphqlEndpoint == "" {
graphqlEndpoint = gqlDefaultServerURL
}

var searchDepth int
var err error

// Parse the search depth from the query parameters.
searchDepthString := c.Query("search_depth")
if searchDepthString != "" {
searchDepth, err = strconv.Atoi(searchDepthString)
if err != nil && searchDepthString != "" {
// If the search depth is not an integer, return an error.
return "", 0, errors.New("invalid search depth")
}
}

return graphqlEndpoint, searchDepth, nil
}
95 changes: 95 additions & 0 deletions cmd/guacrest/bad_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,95 @@
//
// Copyright 2023 The GUAC Authors.
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.

//go:build e2e

package main

import (
"context"
"io"
"net/http"
"net/http/httptest"
"testing"

"github.com/gin-gonic/gin"
"github.com/google/go-cmp/cmp"
"github.com/google/go-cmp/cmp/cmpopts"
)

func TestBadHandler(t *testing.T) {
type args struct {
gqlAddr string
searchDepth string
}
tests := []struct {
name string
args args
wantStatusCode int
wantBody string
}{
{
name: "default",
args: args{
gqlAddr: "http://localhost:8080/query",
searchDepth: "1",
},
wantStatusCode: 200,
},
{
name: "invalid search depth",
args: args{
gqlAddr: "http://localhost:8080/query",
searchDepth: "invalid",
},
wantStatusCode: 400,
},
}

r := gin.Default()
ctx := context.Background()

r.GET("/bad", badHandler(ctx))

ts := httptest.NewServer(r)
defer ts.Close()

for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
req, _ := http.NewRequest("GET", "/bad?gql_addr="+tt.args.gqlAddr+"&search_depth="+tt.args.searchDepth, nil)
w := httptest.NewRecorder()

r.ServeHTTP(w, req)

resp, err := http.Get(ts.URL + "/bad?gql_addr=" + tt.args.gqlAddr + "&search_depth=" + tt.args.searchDepth)
if err != nil {
t.Fatal(err)
}
defer resp.Body.Close()
body, err := io.ReadAll(resp.Body)
if err != nil {
t.Fatal(err)
}
tt.wantBody = string(body)

if diff := cmp.Diff(tt.wantStatusCode, w.Code); diff != "" {
t.Errorf("code mismatch (-want +got):\n%s", diff)
}
if diff := cmp.Diff(tt.wantBody, w.Body.String(), cmpopts.SortSlices(func(x, y string) bool { return x < y })); diff != "" {
t.Errorf("body mismatch (-want +got):\n%s", diff)
}
})
}
}
Loading
Loading