diff --git a/apps/console/src/pages/organizations/vendors/VendorView.tsx b/apps/console/src/pages/organizations/vendors/VendorView.tsx index 6650ffa2e..cb082a0de 100644 --- a/apps/console/src/pages/organizations/vendors/VendorView.tsx +++ b/apps/console/src/pages/organizations/vendors/VendorView.tsx @@ -20,6 +20,7 @@ import type { VendorViewDeleteComplianceReportMutation as DeleteComplianceReport import type { VendorViewUploadComplianceReportMutation as UploadComplianceReportMutationType } from "./__generated__/VendorViewUploadComplianceReportMutation.graphql"; import type { VendorViewUpdateVendorMutation } from "./__generated__/VendorViewUpdateVendorMutation.graphql"; import type { VendorViewCreateRiskAssessmentMutation } from "./__generated__/VendorViewCreateRiskAssessmentMutation.graphql"; +import type { VendorViewAssessVendorMutation } from "./__generated__/VendorViewAssessVendorMutation.graphql"; import type { BusinessImpact, DataSensitivity, @@ -31,6 +32,7 @@ import { PageTemplate } from "@/components/PageTemplate"; import { VendorViewSkeleton } from "./VendorPage"; import PeopleSelector from "@/components/PeopleSelector"; import { formatDistanceToNow, format, isPast } from "date-fns"; +import { Dialog, DialogContent, DialogHeader, DialogTitle } from "@/components/ui/dialog"; const vendorViewQuery = graphql` query VendorViewQuery($vendorId: ID!, $organizationId: ID!) { @@ -196,6 +198,38 @@ const createRiskAssessmentMutation = graphql` } `; +const assessVendorMutation = graphql` + mutation VendorViewAssessVendorMutation($input: AssessVendorInput!) { + assessVendor(input: $input) { + vendor { + id + name + description + statusPageUrl + termsOfServiceUrl + privacyPolicyUrl + serviceLevelAgreementUrl + dataProcessingAgreementUrl + securityPageUrl + trustPageUrl + certifications + headquarterAddress + legalName + websiteUrl + businessOwner { + id + fullName + } + securityOwner { + id + fullName + } + updatedAt + } + } + } +`; + // Format date for input field (YYYY-MM-DDTHH:mm) function formatDateForInput(date: string | null | undefined): string { if (!date) return ""; @@ -1070,9 +1104,9 @@ function RiskAssessmentDetailsModal({

Risk Assessment Details

-
- +

Valid Until

@@ -1140,7 +1174,7 @@ function RiskAssessmentDetailsModal({ )}

- + } + >
+ +
+ + ); } export default function VendorView() { const { vendorId, organizationId } = useParams(); + const [queryRef, loadQuery] = useQueryLoader(vendorViewQuery); diff --git a/apps/console/src/pages/organizations/vendors/__generated__/VendorViewAssessVendorMutation.graphql.ts b/apps/console/src/pages/organizations/vendors/__generated__/VendorViewAssessVendorMutation.graphql.ts new file mode 100644 index 000000000..f43775b69 --- /dev/null +++ b/apps/console/src/pages/organizations/vendors/__generated__/VendorViewAssessVendorMutation.graphql.ts @@ -0,0 +1,257 @@ +/** + * @generated SignedSource<<71c7ed1be8cf0477074d6cf130143626>> + * @lightSyntaxTransform + * @nogrep + */ + +/* tslint:disable */ +/* eslint-disable */ +// @ts-nocheck + +import { ConcreteRequest } from 'relay-runtime'; +export type AssessVendorInput = { + id: string; + websiteUrl: string; +}; +export type VendorViewAssessVendorMutation$variables = { + input: AssessVendorInput; +}; +export type VendorViewAssessVendorMutation$data = { + readonly assessVendor: { + readonly vendor: { + readonly businessOwner: { + readonly fullName: string; + readonly id: string; + } | null | undefined; + readonly certifications: ReadonlyArray; + readonly dataProcessingAgreementUrl: string | null | undefined; + readonly description: string | null | undefined; + readonly headquarterAddress: string | null | undefined; + readonly id: string; + readonly legalName: string | null | undefined; + readonly name: string; + readonly privacyPolicyUrl: string | null | undefined; + readonly securityOwner: { + readonly fullName: string; + readonly id: string; + } | null | undefined; + readonly securityPageUrl: string | null | undefined; + readonly serviceLevelAgreementUrl: string | null | undefined; + readonly statusPageUrl: string | null | undefined; + readonly termsOfServiceUrl: string | null | undefined; + readonly trustPageUrl: string | null | undefined; + readonly updatedAt: string; + readonly websiteUrl: string | null | undefined; + }; + }; +}; +export type VendorViewAssessVendorMutation = { + response: VendorViewAssessVendorMutation$data; + variables: VendorViewAssessVendorMutation$variables; +}; + +const node: ConcreteRequest = (function(){ +var v0 = [ + { + "defaultValue": null, + "kind": "LocalArgument", + "name": "input" + } +], +v1 = { + "alias": null, + "args": null, + "kind": "ScalarField", + "name": "id", + "storageKey": null +}, +v2 = [ + (v1/*: any*/), + { + "alias": null, + "args": null, + "kind": "ScalarField", + "name": "fullName", + "storageKey": null + } +], +v3 = [ + { + "alias": null, + "args": [ + { + "kind": "Variable", + "name": "input", + "variableName": "input" + } + ], + "concreteType": "AssessVendorPayload", + "kind": "LinkedField", + "name": "assessVendor", + "plural": false, + "selections": [ + { + "alias": null, + "args": null, + "concreteType": "Vendor", + "kind": "LinkedField", + "name": "vendor", + "plural": false, + "selections": [ + (v1/*: any*/), + { + "alias": null, + "args": null, + "kind": "ScalarField", + "name": "name", + "storageKey": null + }, + { + "alias": null, + "args": null, + "kind": "ScalarField", + "name": "description", + "storageKey": null + }, + { + "alias": null, + "args": null, + "kind": "ScalarField", + "name": "statusPageUrl", + "storageKey": null + }, + { + "alias": null, + "args": null, + "kind": "ScalarField", + "name": "termsOfServiceUrl", + "storageKey": null + }, + { + "alias": null, + "args": null, + "kind": "ScalarField", + "name": "privacyPolicyUrl", + "storageKey": null + }, + { + "alias": null, + "args": null, + "kind": "ScalarField", + "name": "serviceLevelAgreementUrl", + "storageKey": null + }, + { + "alias": null, + "args": null, + "kind": "ScalarField", + "name": "dataProcessingAgreementUrl", + "storageKey": null + }, + { + "alias": null, + "args": null, + "kind": "ScalarField", + "name": "securityPageUrl", + "storageKey": null + }, + { + "alias": null, + "args": null, + "kind": "ScalarField", + "name": "trustPageUrl", + "storageKey": null + }, + { + "alias": null, + "args": null, + "kind": "ScalarField", + "name": "certifications", + "storageKey": null + }, + { + "alias": null, + "args": null, + "kind": "ScalarField", + "name": "headquarterAddress", + "storageKey": null + }, + { + "alias": null, + "args": null, + "kind": "ScalarField", + "name": "legalName", + "storageKey": null + }, + { + "alias": null, + "args": null, + "kind": "ScalarField", + "name": "websiteUrl", + "storageKey": null + }, + { + "alias": null, + "args": null, + "concreteType": "People", + "kind": "LinkedField", + "name": "businessOwner", + "plural": false, + "selections": (v2/*: any*/), + "storageKey": null + }, + { + "alias": null, + "args": null, + "concreteType": "People", + "kind": "LinkedField", + "name": "securityOwner", + "plural": false, + "selections": (v2/*: any*/), + "storageKey": null + }, + { + "alias": null, + "args": null, + "kind": "ScalarField", + "name": "updatedAt", + "storageKey": null + } + ], + "storageKey": null + } + ], + "storageKey": null + } +]; +return { + "fragment": { + "argumentDefinitions": (v0/*: any*/), + "kind": "Fragment", + "metadata": null, + "name": "VendorViewAssessVendorMutation", + "selections": (v3/*: any*/), + "type": "Mutation", + "abstractKey": null + }, + "kind": "Request", + "operation": { + "argumentDefinitions": (v0/*: any*/), + "kind": "Operation", + "name": "VendorViewAssessVendorMutation", + "selections": (v3/*: any*/) + }, + "params": { + "cacheID": "ccc16b2de8f7f3936458cd4823a14992", + "id": null, + "metadata": {}, + "name": "VendorViewAssessVendorMutation", + "operationKind": "mutation", + "text": "mutation VendorViewAssessVendorMutation(\n $input: AssessVendorInput!\n) {\n assessVendor(input: $input) {\n vendor {\n id\n name\n description\n statusPageUrl\n termsOfServiceUrl\n privacyPolicyUrl\n serviceLevelAgreementUrl\n dataProcessingAgreementUrl\n securityPageUrl\n trustPageUrl\n certifications\n headquarterAddress\n legalName\n websiteUrl\n businessOwner {\n id\n fullName\n }\n securityOwner {\n id\n fullName\n }\n updatedAt\n }\n }\n}\n" + } +}; +})(); + +(node as any).hash = "ed23d00ce5a44aa6bfd43a072552ed1c"; + +export default node; diff --git a/cfg/dev.yaml b/cfg/dev.yaml index ae2f586de..6cb96885e 100644 --- a/cfg/dev.yaml +++ b/cfg/dev.yaml @@ -6,3 +6,7 @@ probod: access-key-id: "probod" secret-access-key: "thisisnotasecret" endpoint: "http://127.0.0.1:9000" + openai: + api-key: "thisisnotasecret" + temperature: 0.1 + model-name: "gpt-4o" diff --git a/go.mod b/go.mod index 289e0e1f7..a3e173136 100644 --- a/go.mod +++ b/go.mod @@ -16,6 +16,7 @@ require ( github.com/go-chi/cors v1.2.1 github.com/jackc/pgx/v5 v5.7.4 github.com/jhillyerd/enmime v1.3.0 + github.com/openai/openai-go v1.1.0 github.com/prometheus/client_golang v1.22.0 github.com/vektah/gqlparser/v2 v2.5.27 go.gearno.de/crypto/uuid v0.1.0 @@ -64,6 +65,10 @@ require ( github.com/russross/blackfriday/v2 v2.1.0 // indirect github.com/sosodev/duration v1.3.1 // indirect github.com/ssor/bom v0.0.0-20170718123548-6386211fdfcf // indirect + github.com/tidwall/gjson v1.18.0 // indirect + github.com/tidwall/match v1.1.1 // indirect + github.com/tidwall/pretty v1.2.1 // indirect + github.com/tidwall/sjson v1.2.5 // indirect github.com/urfave/cli/v2 v2.27.6 // indirect github.com/xrash/smetrics v0.0.0-20240521201337-686a1a2994c1 // indirect go.gearno.de/x/panicf v0.1.1 // indirect diff --git a/go.sum b/go.sum index 8bd7debc2..ebaddc1a7 100644 --- a/go.sum +++ b/go.sum @@ -108,6 +108,8 @@ github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 h1:C3w9PqII01/Oq github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822/go.mod h1:+n7T8mK8HuQTcFwEeznm/DIxMOiR9yIdICNftLE1DvQ= github.com/olekukonko/tablewriter v0.0.5 h1:P2Ga83D34wi1o9J6Wh1mRuqd4mF/x/lgBS7N7AbDhec= github.com/olekukonko/tablewriter v0.0.5/go.mod h1:hPp6KlRPjbx+hW8ykQs1w3UBbZlj6HuIJcUGPhkA7kY= +github.com/openai/openai-go v1.1.0 h1:daSn+y+3QJUmLV1xfh7B8QtgJYRw1hg3yWxKtQDfROE= +github.com/openai/openai-go v1.1.0/go.mod h1:g461MYGXEXBVdV5SaR/5tNzNbSfwTBBefwc+LlDCK0Y= github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4= github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= @@ -140,6 +142,16 @@ github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UV github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA= github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= +github.com/tidwall/gjson v1.14.2/go.mod h1:/wbyibRr2FHMks5tjHJ5F8dMZh3AcwJEMf5vlfC0lxk= +github.com/tidwall/gjson v1.18.0 h1:FIDeeyB800efLX89e5a8Y0BNH+LOngJyGrIWxG2FKQY= +github.com/tidwall/gjson v1.18.0/go.mod h1:/wbyibRr2FHMks5tjHJ5F8dMZh3AcwJEMf5vlfC0lxk= +github.com/tidwall/match v1.1.1 h1:+Ho715JplO36QYgwN9PGYNhgZvoUSc9X2c80KVTi+GA= +github.com/tidwall/match v1.1.1/go.mod h1:eRSPERbgtNPcGhD8UCthc6PmLEQXEWd3PRB5JTxsfmM= +github.com/tidwall/pretty v1.2.0/go.mod h1:ITEVvHYasfjBbM0u2Pg8T2nJnzm8xPwvNhhsoaGGjNU= +github.com/tidwall/pretty v1.2.1 h1:qjsOFOWWQl+N3RsoF5/ssm1pHmJJwhjlSbZ51I6wMl4= +github.com/tidwall/pretty v1.2.1/go.mod h1:ITEVvHYasfjBbM0u2Pg8T2nJnzm8xPwvNhhsoaGGjNU= +github.com/tidwall/sjson v1.2.5 h1:kLy8mja+1c9jlljvWTlSazM7cKDRfJuR/bOJhcY5NcY= +github.com/tidwall/sjson v1.2.5/go.mod h1:Fvgq9kS/6ociJEDnK0Fk1cpYF4FIW6ZF7LAe+6jwd28= github.com/urfave/cli/v2 v2.27.6 h1:VdRdS98FNhKZ8/Az8B7MTyGQmpIr36O1EHybx/LaZ4g= github.com/urfave/cli/v2 v2.27.6/go.mod h1:3Sevf16NykTbInEnD0yKkjDAeZDS0A6bzhBH5hrMvTQ= github.com/vektah/gqlparser/v2 v2.5.27 h1:RHPD3JOplpk5mP5JGX8RKZkt2/Vwj/PZv0HxTdwFp0s= diff --git a/pkg/agents/vendor_assessment.go b/pkg/agents/vendor_assessment.go new file mode 100644 index 000000000..08d07b430 --- /dev/null +++ b/pkg/agents/vendor_assessment.go @@ -0,0 +1,137 @@ +// Copyright (c) 2025 Probo Inc . +// +// Permission to use, copy, modify, and/or distribute this software for any +// purpose with or without fee is hereby granted, provided that the above +// copyright notice and this permission notice appear in all copies. +// +// THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH +// REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY +// AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT, +// INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM +// LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR +// OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR +// PERFORMANCE OF THIS SOFTWARE. + +package agents + +import ( + "context" + "encoding/json" + "fmt" + + "github.com/openai/openai-go" + "github.com/openai/openai-go/option" + "github.com/openai/openai-go/packages/param" + "go.gearno.de/kit/log" +) + +type ( + VendorAssessment struct { + l *log.Logger + cfg Config + client *openai.Client + } + + Config struct { + OpenAIAPIKey string + Temperature float64 + ModelName string + } + + vendorInfo struct { + Name string `json:"name"` + Description string `json:"description"` + Category string `json:"category"` + HeadquarterAddress string `json:"headquarter_address"` + LegalName string `json:"legal_name"` + PrivacyPolicyURL string `json:"privacy_policy_url"` + ServiceLevelAgreementURL string `json:"service_level_agreement_url"` + DataProcessingAgreementURL string `json:"data_processing_agreement_url"` + SecurityPageURL string `json:"security_page_url"` + TrustPageURL string `json:"trust_page_url"` + TermsOfServiceURL string `json:"terms_of_service_url"` + StatusPageURL string `json:"status_page_url"` + Certifications []string `json:"certifications"` + } +) + +const ( + systemPrompt = ` + # Role: You are a compliance assistant. + + # Objective + Your task is to fetch the provided company URL and to return comprehensive company information. + + # For the company url, return the following fields in structured JSON format: + - name: The company's commonly used name + - description: One-sentence summary of the company's core offering + - headquarter_address: Company's main headquarter full address + - legal_name: Official registered company name + - privacy_policy_url: URL to privacy policy page + - service_level_agreement_url: URL to SLA page + - data_processing_agreement_url: URL to DPA page + - security_page_url: URL to security information page + - trust_page_url: URL to trust/compliance page + - terms_of_service_url: URL to terms of service page + - status_page_url: URL to system status page + - certifications: Array of security/compliance certifications (e.g., ["SOC2", "ISO27001"]) + + # SOP + - Please ensure the output is clean, standardized JSON. + - Use web search to gather info, if you cannot find what you are looking for, just return an empty string instead + - For URLs, return the full URL if found, otherwise an empty string + - For certifications, return an empty array if none found + + # **Example output format:** + Respond ONLY with a JSON object. No explanation, no markdown, no preamble. Like this: + { + "name": "Stripe", + "description": "Online payment processing platform that enables businesses to accept and manage digital payments, supporting various payment methods and currencies with integrated fraud protection and compliance features", + "headquarter_address": "San Francisco, CA", + "legal_name": "Stripe, Inc.", + "privacy_policy_url": "https://stripe.com/privacy", + "service_level_agreement_url": "https://stripe.com/sla", + "data_processing_agreement_url": "https://stripe.com/dpa", + "security_page_url": "https://stripe.com/security", + "trust_page_url": "https://stripe.com/trust", + "terms_of_service_url": "https://stripe.com/terms", + "status_page_url": "https://status.stripe.com", + "certifications": ["SOC1", "SOC2", "PCI DSS Level 1", "ISO 27001"] + } + + ### Company url: + ` +) + +func NewVendorAssessment(l *log.Logger, cfg Config) *VendorAssessment { + client := openai.NewClient(option.WithAPIKey(cfg.OpenAIAPIKey)) + + return &VendorAssessment{l: l, cfg: cfg, client: &client} +} + +func (va *VendorAssessment) Fetch(ctx context.Context, websiteURL string) (*vendorInfo, error) { + model := openai.ChatModel(va.cfg.ModelName) + chatCompletion, err := va.client.Chat.Completions.New(ctx, openai.ChatCompletionNewParams{ + Messages: []openai.ChatCompletionMessageParamUnion{ + openai.SystemMessage(systemPrompt), + openai.UserMessage(websiteURL), + }, + Model: model, + Temperature: param.NewOpt(va.cfg.Temperature), + }) + if err != nil { + return nil, fmt.Errorf("failed to parse vendor info: %w", err) + } + + if len(chatCompletion.Choices) == 0 { + return nil, fmt.Errorf("no completion choices returned from API") + } + + var vendorInfo vendorInfo + err = json.Unmarshal([]byte(chatCompletion.Choices[0].Message.Content), &vendorInfo) + if err != nil { + return nil, fmt.Errorf("failed to parse vendor info: %w", err) + } + + return &vendorInfo, nil +} diff --git a/pkg/probo/service.go b/pkg/probo/service.go index aa3f209af..5d36ac5c3 100644 --- a/pkg/probo/service.go +++ b/pkg/probo/service.go @@ -19,6 +19,7 @@ import ( "fmt" "github.com/aws/aws-sdk-go-v2/service/s3" + "github.com/getprobo/probo/pkg/agents" "github.com/getprobo/probo/pkg/coredata" "github.com/getprobo/probo/pkg/crypto/cipher" "github.com/getprobo/probo/pkg/filevalidation" @@ -28,12 +29,13 @@ import ( type ( Service struct { - pg *pg.Client - s3 *s3.Client - bucket string - encryptionKey cipher.EncryptionKey - hostname string - tokenSecret string + pg *pg.Client + s3 *s3.Client + bucket string + encryptionKey cipher.EncryptionKey + hostname string + tokenSecret string + vendorAssessment agents.Config } TenantService struct { @@ -44,6 +46,7 @@ type ( scope coredata.Scoper hostname string tokenSecret string + vendorAssessment *agents.VendorAssessment Frameworks *FrameworkService Measures *MeasureService Tasks *TaskService @@ -67,18 +70,20 @@ func NewService( bucket string, hostname string, tokenSecret string, + vendorAssessment agents.Config, ) (*Service, error) { if bucket == "" { return nil, fmt.Errorf("bucket is required") } svc := &Service{ - pg: pgClient, - s3: s3Client, - bucket: bucket, - encryptionKey: encryptionKey, - hostname: hostname, - tokenSecret: tokenSecret, + pg: pgClient, + s3: s3Client, + bucket: bucket, + encryptionKey: encryptionKey, + hostname: hostname, + tokenSecret: tokenSecret, + vendorAssessment: vendorAssessment, } return svc, nil @@ -86,13 +91,14 @@ func NewService( func (s *Service) WithTenant(tenantID gid.TenantID) *TenantService { tenantService := &TenantService{ - pg: s.pg, - s3: s.s3, - bucket: s.bucket, - encryptionKey: s.encryptionKey, - hostname: s.hostname, - scope: coredata.NewScope(tenantID), - tokenSecret: s.tokenSecret, + pg: s.pg, + s3: s.s3, + bucket: s.bucket, + encryptionKey: s.encryptionKey, + hostname: s.hostname, + scope: coredata.NewScope(tenantID), + tokenSecret: s.tokenSecret, + vendorAssessment: agents.NewVendorAssessment(nil, s.vendorAssessment), } tenantService.Frameworks = &FrameworkService{svc: tenantService} diff --git a/pkg/probo/vendor_service.go b/pkg/probo/vendor_service.go index 657d4c142..290b881c4 100644 --- a/pkg/probo/vendor_service.go +++ b/pkg/probo/vendor_service.go @@ -70,6 +70,11 @@ type ( SecurityOwnerID *gid.GID } + AssessVendorRequest struct { + ID gid.GID + WebsiteURL string + } + CreateVendorRiskAssessmentRequest struct { VendorID gid.GID AssessedByID gid.GID @@ -421,3 +426,34 @@ func (s VendorService) GetRiskAssessment( return vendorRiskAssessment, nil } + +func (s VendorService) Assess( + ctx context.Context, + req AssessVendorRequest, +) (*coredata.Vendor, error) { + vendorInfo, err := s.svc.vendorAssessment.Fetch(ctx, req.WebsiteURL) + if err != nil { + return nil, fmt.Errorf("failed to assess vendor info: %w", err) + } + + vendor := &coredata.Vendor{ + ID: req.ID, + Name: vendorInfo.Name, + WebsiteURL: &req.WebsiteURL, + Description: &vendorInfo.Description, + Category: vendorInfo.Category, + HeadquarterAddress: &vendorInfo.HeadquarterAddress, + LegalName: &vendorInfo.LegalName, + PrivacyPolicyURL: &vendorInfo.PrivacyPolicyURL, + ServiceLevelAgreementURL: &vendorInfo.ServiceLevelAgreementURL, + DataProcessingAgreementURL: &vendorInfo.DataProcessingAgreementURL, + SecurityPageURL: &vendorInfo.SecurityPageURL, + TrustPageURL: &vendorInfo.TrustPageURL, + TermsOfServiceURL: &vendorInfo.TermsOfServiceURL, + StatusPageURL: &vendorInfo.StatusPageURL, + Certifications: vendorInfo.Certifications, + UpdatedAt: time.Now(), + } + + return vendor, nil +} diff --git a/pkg/probod/openai_config.go b/pkg/probod/openai_config.go new file mode 100644 index 000000000..0acea872c --- /dev/null +++ b/pkg/probod/openai_config.go @@ -0,0 +1,21 @@ +// Copyright (c) 2025 Probo Inc . +// +// Permission to use, copy, modify, and/or distribute this software for any +// purpose with or without fee is hereby granted, provided that the above +// copyright notice and this permission notice appear in all copies. +// +// THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH +// REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY +// AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT, +// INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM +// LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR +// OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR +// PERFORMANCE OF THIS SOFTWARE. + +package probod + +type openaiConfig struct { + APIKey string `json:"api-key"` + Temperature float64 `json:"temperature"` + ModelName string `json:"model-name"` +} diff --git a/pkg/probod/probod.go b/pkg/probod/probod.go index 0e1a098c5..29610f356 100644 --- a/pkg/probod/probod.go +++ b/pkg/probod/probod.go @@ -24,6 +24,7 @@ import ( "time" "github.com/aws/aws-sdk-go-v2/service/s3" + "github.com/getprobo/probo/pkg/agents" "github.com/getprobo/probo/pkg/awsconfig" "github.com/getprobo/probo/pkg/connector" "github.com/getprobo/probo/pkg/coredata" @@ -59,6 +60,7 @@ type ( AWS awsConfig `json:"aws"` Mailer mailerConfig `json:"mailer"` Connectors []connectorConfig `json:"connectors"` + OpenAI openaiConfig `json:"openai"` } ) @@ -185,6 +187,14 @@ func (impl *Implm) Run( } } + vendorAssessmentConfig := agents.Config{ + OpenAIAPIKey: impl.cfg.OpenAI.APIKey, + Temperature: impl.cfg.OpenAI.Temperature, + ModelName: impl.cfg.OpenAI.ModelName, + } + + vendorAssessment := agents.NewVendorAssessment(l.Named("vendor-assessment"), vendorAssessmentConfig) + usrmgrService, err := usrmgr.NewService( ctx, pgClient, @@ -205,6 +215,7 @@ func (impl *Implm) Run( impl.cfg.AWS.Bucket, impl.cfg.Hostname, impl.cfg.Auth.Cookie.Secret, + vendorAssessmentConfig, ) if err != nil { return fmt.Errorf("cannot create probo service: %w", err) @@ -216,6 +227,7 @@ func (impl *Implm) Run( Probo: proboService, Usrmgr: usrmgrService, ConnectorRegistry: defaultConnectorRegistry, + VendorAssessment: vendorAssessment, SafeRedirect: &saferedirect.SafeRedirect{AllowedHost: impl.cfg.Hostname}, Logger: l.Named("http.server"), Auth: console_v1.AuthConfig{ diff --git a/pkg/server/api/console/v1/schema.graphql b/pkg/server/api/console/v1/schema.graphql index 29ccc1810..8e6273276 100644 --- a/pkg/server/api/console/v1/schema.graphql +++ b/pkg/server/api/console/v1/schema.graphql @@ -1088,6 +1088,8 @@ type Mutation { ): CreateVendorRiskAssessmentPayload! exportAudit(input: ExportAuditInput!): ExportAuditPayload! + + assessVendor(input: AssessVendorInput!): AssessVendorPayload! } # Input Types @@ -1779,3 +1781,12 @@ input ExportAuditInput { type ExportAuditPayload { url: String! } + +input AssessVendorInput { + id: ID! + websiteUrl: String! +} + +type AssessVendorPayload { + vendor: Vendor! +} diff --git a/pkg/server/api/console/v1/schema/schema.go b/pkg/server/api/console/v1/schema/schema.go index 66f47b73c..c226f0782 100644 --- a/pkg/server/api/console/v1/schema/schema.go +++ b/pkg/server/api/console/v1/schema/schema.go @@ -65,6 +65,10 @@ type DirectiveRoot struct { } type ComplexityRoot struct { + AssessVendorPayload struct { + Vendor func(childComplexity int) int + } + AssignTaskPayload struct { Task func(childComplexity int) int } @@ -329,6 +333,7 @@ type ComplexityRoot struct { } Mutation struct { + AssessVendor func(childComplexity int, input types.AssessVendorInput) int AssignTask func(childComplexity int, input types.AssignTaskInput) int ConfirmEmail func(childComplexity int, input types.ConfirmEmailInput) int CreateControlMeasureMapping func(childComplexity int, input types.CreateControlMeasureMappingInput) int @@ -827,6 +832,7 @@ type MutationResolver interface { SendSigningNotifications(ctx context.Context, input types.SendSigningNotificationsInput) (*types.SendSigningNotificationsPayload, error) CreateVendorRiskAssessment(ctx context.Context, input types.CreateVendorRiskAssessmentInput) (*types.CreateVendorRiskAssessmentPayload, error) ExportAudit(ctx context.Context, input types.ExportAuditInput) (*types.ExportAuditPayload, error) + AssessVendor(ctx context.Context, input types.AssessVendorInput) (*types.AssessVendorPayload, error) } type OrganizationResolver interface { LogoURL(ctx context.Context, obj *types.Organization) (*string, error) @@ -919,6 +925,13 @@ func (e *executableSchema) Complexity(ctx context.Context, typeName, field strin _ = ec switch typeName + "." + field { + case "AssessVendorPayload.vendor": + if e.complexity.AssessVendorPayload.Vendor == nil { + break + } + + return e.complexity.AssessVendorPayload.Vendor(childComplexity), true + case "AssignTaskPayload.task": if e.complexity.AssignTaskPayload.Task == nil { break @@ -1724,6 +1737,18 @@ func (e *executableSchema) Complexity(ctx context.Context, typeName, field strin return e.complexity.MeasureEdge.Node(childComplexity), true + case "Mutation.assessVendor": + if e.complexity.Mutation.AssessVendor == nil { + break + } + + args, err := ec.field_Mutation_assessVendor_args(ctx, rawArgs) + if err != nil { + return 0, false + } + + return e.complexity.Mutation.AssessVendor(childComplexity, args["input"].(types.AssessVendorInput)), true + case "Mutation.assignTask": if e.complexity.Mutation.AssignTask == nil { break @@ -3889,6 +3914,7 @@ func (e *executableSchema) Exec(ctx context.Context) graphql.ResponseHandler { opCtx := graphql.GetOperationContext(ctx) ec := executionContext{opCtx, e, 0, 0, make(chan graphql.DeferredResult)} inputUnmarshalMap := graphql.BuildUnmarshalerMap( + ec.unmarshalInputAssessVendorInput, ec.unmarshalInputAssignTaskInput, ec.unmarshalInputConfirmEmailInput, ec.unmarshalInputConnectorOrder, @@ -5147,6 +5173,8 @@ type Mutation { ): CreateVendorRiskAssessmentPayload! exportAudit(input: ExportAuditInput!): ExportAuditPayload! + + assessVendor(input: AssessVendorInput!): AssessVendorPayload! } # Input Types @@ -5838,6 +5866,15 @@ input ExportAuditInput { type ExportAuditPayload { url: String! } + +input AssessVendorInput { + id: ID! + websiteUrl: String! +} + +type AssessVendorPayload { + vendor: Vendor! +} `, BuiltIn: false}, } var parsedSchema = gqlparser.MustLoadSchema(sources...) @@ -6511,6 +6548,29 @@ func (ec *executionContext) field_Measure_tasks_argsOrderBy( return zeroVal, nil } +func (ec *executionContext) field_Mutation_assessVendor_args(ctx context.Context, rawArgs map[string]any) (map[string]any, error) { + var err error + args := map[string]any{} + arg0, err := ec.field_Mutation_assessVendor_argsInput(ctx, rawArgs) + if err != nil { + return nil, err + } + args["input"] = arg0 + return args, nil +} +func (ec *executionContext) field_Mutation_assessVendor_argsInput( + ctx context.Context, + rawArgs map[string]any, +) (types.AssessVendorInput, error) { + ctx = graphql.WithPathContext(ctx, graphql.NewPathWithField("input")) + if tmp, ok := rawArgs["input"]; ok { + return ec.unmarshalNAssessVendorInput2githubᚗcomᚋgetproboᚋproboᚋpkgᚋserverᚋapiᚋconsoleᚋv1ᚋtypesᚐAssessVendorInput(ctx, tmp) + } + + var zeroVal types.AssessVendorInput + return zeroVal, nil +} + func (ec *executionContext) field_Mutation_assignTask_args(ctx context.Context, rawArgs map[string]any) (map[string]any, error) { var err error args := map[string]any{} @@ -9699,6 +9759,94 @@ func (ec *executionContext) field___Type_fields_argsIncludeDeprecated( // region **************************** field.gotpl ***************************** +func (ec *executionContext) _AssessVendorPayload_vendor(ctx context.Context, field graphql.CollectedField, obj *types.AssessVendorPayload) (ret graphql.Marshaler) { + fc, err := ec.fieldContext_AssessVendorPayload_vendor(ctx, field) + if err != nil { + return graphql.Null + } + ctx = graphql.WithFieldContext(ctx, fc) + defer func() { + if r := recover(); r != nil { + ec.Error(ctx, ec.Recover(ctx, r)) + ret = graphql.Null + } + }() + resTmp, err := ec.ResolverMiddleware(ctx, func(rctx context.Context) (any, error) { + ctx = rctx // use context from middleware stack in children + return obj.Vendor, nil + }) + if err != nil { + ec.Error(ctx, err) + return graphql.Null + } + if resTmp == nil { + if !graphql.HasFieldError(ctx, fc) { + ec.Errorf(ctx, "must not be null") + } + return graphql.Null + } + res := resTmp.(*types.Vendor) + fc.Result = res + return ec.marshalNVendor2ᚖgithubᚗcomᚋgetproboᚋproboᚋpkgᚋserverᚋapiᚋconsoleᚋv1ᚋtypesᚐVendor(ctx, field.Selections, res) +} + +func (ec *executionContext) fieldContext_AssessVendorPayload_vendor(_ context.Context, field graphql.CollectedField) (fc *graphql.FieldContext, err error) { + fc = &graphql.FieldContext{ + Object: "AssessVendorPayload", + Field: field, + IsMethod: false, + IsResolver: false, + Child: func(ctx context.Context, field graphql.CollectedField) (*graphql.FieldContext, error) { + switch field.Name { + case "id": + return ec.fieldContext_Vendor_id(ctx, field) + case "name": + return ec.fieldContext_Vendor_name(ctx, field) + case "description": + return ec.fieldContext_Vendor_description(ctx, field) + case "organization": + return ec.fieldContext_Vendor_organization(ctx, field) + case "complianceReports": + return ec.fieldContext_Vendor_complianceReports(ctx, field) + case "riskAssessments": + return ec.fieldContext_Vendor_riskAssessments(ctx, field) + case "businessOwner": + return ec.fieldContext_Vendor_businessOwner(ctx, field) + case "securityOwner": + return ec.fieldContext_Vendor_securityOwner(ctx, field) + case "statusPageUrl": + return ec.fieldContext_Vendor_statusPageUrl(ctx, field) + case "termsOfServiceUrl": + return ec.fieldContext_Vendor_termsOfServiceUrl(ctx, field) + case "privacyPolicyUrl": + return ec.fieldContext_Vendor_privacyPolicyUrl(ctx, field) + case "serviceLevelAgreementUrl": + return ec.fieldContext_Vendor_serviceLevelAgreementUrl(ctx, field) + case "dataProcessingAgreementUrl": + return ec.fieldContext_Vendor_dataProcessingAgreementUrl(ctx, field) + case "certifications": + return ec.fieldContext_Vendor_certifications(ctx, field) + case "securityPageUrl": + return ec.fieldContext_Vendor_securityPageUrl(ctx, field) + case "trustPageUrl": + return ec.fieldContext_Vendor_trustPageUrl(ctx, field) + case "headquarterAddress": + return ec.fieldContext_Vendor_headquarterAddress(ctx, field) + case "legalName": + return ec.fieldContext_Vendor_legalName(ctx, field) + case "websiteUrl": + return ec.fieldContext_Vendor_websiteUrl(ctx, field) + case "createdAt": + return ec.fieldContext_Vendor_createdAt(ctx, field) + case "updatedAt": + return ec.fieldContext_Vendor_updatedAt(ctx, field) + } + return nil, fmt.Errorf("no field named %q was found under type Vendor", field.Name) + }, + } + return fc, nil +} + func (ec *executionContext) _AssignTaskPayload_task(ctx context.Context, field graphql.CollectedField, obj *types.AssignTaskPayload) (ret graphql.Marshaler) { fc, err := ec.fieldContext_AssignTaskPayload_task(ctx, field) if err != nil { @@ -18173,6 +18321,65 @@ func (ec *executionContext) fieldContext_Mutation_exportAudit(ctx context.Contex return fc, nil } +func (ec *executionContext) _Mutation_assessVendor(ctx context.Context, field graphql.CollectedField) (ret graphql.Marshaler) { + fc, err := ec.fieldContext_Mutation_assessVendor(ctx, field) + if err != nil { + return graphql.Null + } + ctx = graphql.WithFieldContext(ctx, fc) + defer func() { + if r := recover(); r != nil { + ec.Error(ctx, ec.Recover(ctx, r)) + ret = graphql.Null + } + }() + resTmp, err := ec.ResolverMiddleware(ctx, func(rctx context.Context) (any, error) { + ctx = rctx // use context from middleware stack in children + return ec.resolvers.Mutation().AssessVendor(rctx, fc.Args["input"].(types.AssessVendorInput)) + }) + if err != nil { + ec.Error(ctx, err) + return graphql.Null + } + if resTmp == nil { + if !graphql.HasFieldError(ctx, fc) { + ec.Errorf(ctx, "must not be null") + } + return graphql.Null + } + res := resTmp.(*types.AssessVendorPayload) + fc.Result = res + return ec.marshalNAssessVendorPayload2ᚖgithubᚗcomᚋgetproboᚋproboᚋpkgᚋserverᚋapiᚋconsoleᚋv1ᚋtypesᚐAssessVendorPayload(ctx, field.Selections, res) +} + +func (ec *executionContext) fieldContext_Mutation_assessVendor(ctx context.Context, field graphql.CollectedField) (fc *graphql.FieldContext, err error) { + fc = &graphql.FieldContext{ + Object: "Mutation", + Field: field, + IsMethod: true, + IsResolver: true, + Child: func(ctx context.Context, field graphql.CollectedField) (*graphql.FieldContext, error) { + switch field.Name { + case "vendor": + return ec.fieldContext_AssessVendorPayload_vendor(ctx, field) + } + return nil, fmt.Errorf("no field named %q was found under type AssessVendorPayload", field.Name) + }, + } + defer func() { + if r := recover(); r != nil { + err = ec.Recover(ctx, r) + ec.Error(ctx, err) + } + }() + ctx = graphql.WithFieldContext(ctx, fc) + if fc.Args, err = ec.field_Mutation_assessVendor_args(ctx, field.ArgumentMap(ec.Variables)); err != nil { + ec.Error(ctx, err) + return fc, err + } + return fc, nil +} + func (ec *executionContext) _Organization_id(ctx context.Context, field graphql.CollectedField, obj *types.Organization) (ret graphql.Marshaler) { fc, err := ec.fieldContext_Organization_id(ctx, field) if err != nil { @@ -30810,6 +31017,40 @@ func (ec *executionContext) fieldContext___Type_isOneOf(_ context.Context, field // region **************************** input.gotpl ***************************** +func (ec *executionContext) unmarshalInputAssessVendorInput(ctx context.Context, obj any) (types.AssessVendorInput, error) { + var it types.AssessVendorInput + asMap := map[string]any{} + for k, v := range obj.(map[string]any) { + asMap[k] = v + } + + fieldsInOrder := [...]string{"id", "websiteUrl"} + for _, k := range fieldsInOrder { + v, ok := asMap[k] + if !ok { + continue + } + switch k { + case "id": + ctx := graphql.WithPathContext(ctx, graphql.NewPathWithField("id")) + data, err := ec.unmarshalNID2githubᚗcomᚋgetproboᚋproboᚋpkgᚋgidᚐGID(ctx, v) + if err != nil { + return it, err + } + it.ID = data + case "websiteUrl": + ctx := graphql.WithPathContext(ctx, graphql.NewPathWithField("websiteUrl")) + data, err := ec.unmarshalNString2string(ctx, v) + if err != nil { + return it, err + } + it.WebsiteURL = data + } + } + + return it, nil +} + func (ec *executionContext) unmarshalInputAssignTaskInput(ctx context.Context, obj any) (types.AssignTaskInput, error) { var it types.AssignTaskInput asMap := map[string]any{} @@ -33899,6 +34140,45 @@ func (ec *executionContext) _Node(ctx context.Context, sel ast.SelectionSet, obj // region **************************** object.gotpl **************************** +var assessVendorPayloadImplementors = []string{"AssessVendorPayload"} + +func (ec *executionContext) _AssessVendorPayload(ctx context.Context, sel ast.SelectionSet, obj *types.AssessVendorPayload) graphql.Marshaler { + fields := graphql.CollectFields(ec.OperationContext, sel, assessVendorPayloadImplementors) + + out := graphql.NewFieldSet(fields) + deferred := make(map[string]*graphql.FieldSet) + for i, field := range fields { + switch field.Name { + case "__typename": + out.Values[i] = graphql.MarshalString("AssessVendorPayload") + case "vendor": + out.Values[i] = ec._AssessVendorPayload_vendor(ctx, field, obj) + if out.Values[i] == graphql.Null { + out.Invalids++ + } + default: + panic("unknown field " + strconv.Quote(field.Name)) + } + } + out.Dispatch(ctx) + if out.Invalids > 0 { + return graphql.Null + } + + atomic.AddInt32(&ec.deferred, int32(len(deferred))) + + for label, dfs := range deferred { + ec.processDeferredGroup(graphql.DeferredGroup{ + Label: label, + Path: graphql.GetPath(ctx), + FieldSet: dfs, + Context: ctx, + }) + } + + return out +} + var assignTaskPayloadImplementors = []string{"AssignTaskPayload"} func (ec *executionContext) _AssignTaskPayload(ctx context.Context, sel ast.SelectionSet, obj *types.AssignTaskPayload) graphql.Marshaler { @@ -36929,6 +37209,13 @@ func (ec *executionContext) _Mutation(ctx context.Context, sel ast.SelectionSet) if out.Values[i] == graphql.Null { out.Invalids++ } + case "assessVendor": + out.Values[i] = ec.OperationContext.RootResolverMiddleware(innerCtx, func(ctx context.Context) (res graphql.Marshaler) { + return ec._Mutation_assessVendor(ctx, field) + }) + if out.Values[i] == graphql.Null { + out.Invalids++ + } default: panic("unknown field " + strconv.Quote(field.Name)) } @@ -41377,6 +41664,25 @@ func (ec *executionContext) ___Type(ctx context.Context, sel ast.SelectionSet, o // region ***************************** type.gotpl ***************************** +func (ec *executionContext) unmarshalNAssessVendorInput2githubᚗcomᚋgetproboᚋproboᚋpkgᚋserverᚋapiᚋconsoleᚋv1ᚋtypesᚐAssessVendorInput(ctx context.Context, v any) (types.AssessVendorInput, error) { + res, err := ec.unmarshalInputAssessVendorInput(ctx, v) + return res, graphql.ErrorOnPath(ctx, err) +} + +func (ec *executionContext) marshalNAssessVendorPayload2githubᚗcomᚋgetproboᚋproboᚋpkgᚋserverᚋapiᚋconsoleᚋv1ᚋtypesᚐAssessVendorPayload(ctx context.Context, sel ast.SelectionSet, v types.AssessVendorPayload) graphql.Marshaler { + return ec._AssessVendorPayload(ctx, sel, &v) +} + +func (ec *executionContext) marshalNAssessVendorPayload2ᚖgithubᚗcomᚋgetproboᚋproboᚋpkgᚋserverᚋapiᚋconsoleᚋv1ᚋtypesᚐAssessVendorPayload(ctx context.Context, sel ast.SelectionSet, v *types.AssessVendorPayload) graphql.Marshaler { + if v == nil { + if !graphql.HasFieldError(ctx, graphql.GetFieldContext(ctx)) { + ec.Errorf(ctx, "the requested element is null which the schema does not allow") + } + return graphql.Null + } + return ec._AssessVendorPayload(ctx, sel, v) +} + func (ec *executionContext) unmarshalNAssignTaskInput2githubᚗcomᚋgetproboᚋproboᚋpkgᚋserverᚋapiᚋconsoleᚋv1ᚋtypesᚐAssignTaskInput(ctx context.Context, v any) (types.AssignTaskInput, error) { res, err := ec.unmarshalInputAssignTaskInput(ctx, v) return res, graphql.ErrorOnPath(ctx, err) diff --git a/pkg/server/api/console/v1/types/types.go b/pkg/server/api/console/v1/types/types.go index 79f164cfb..63b4ba05a 100644 --- a/pkg/server/api/console/v1/types/types.go +++ b/pkg/server/api/console/v1/types/types.go @@ -20,6 +20,15 @@ type Node interface { GetID() gid.GID } +type AssessVendorInput struct { + ID gid.GID `json:"id"` + WebsiteURL string `json:"websiteUrl"` +} + +type AssessVendorPayload struct { + Vendor *Vendor `json:"vendor"` +} + type AssignTaskInput struct { TaskID gid.GID `json:"taskId"` AssignedToID gid.GID `json:"assignedToId"` diff --git a/pkg/server/api/console/v1/v1_resolver.go b/pkg/server/api/console/v1/v1_resolver.go index b02f329d1..bfe2e47da 100644 --- a/pkg/server/api/console/v1/v1_resolver.go +++ b/pkg/server/api/console/v1/v1_resolver.go @@ -1316,6 +1316,23 @@ func (r *mutationResolver) ExportAudit(ctx context.Context, input types.ExportAu }, nil } +// AssessVendor is the resolver for the assessVendor field. +func (r *mutationResolver) AssessVendor(ctx context.Context, input types.AssessVendorInput) (*types.AssessVendorPayload, error) { + svc := GetTenantService(ctx, r.proboSvc, input.ID.TenantID()) + + vendor, err := svc.Vendors.Assess(ctx, probo.AssessVendorRequest{ + ID: input.ID, + WebsiteURL: input.WebsiteURL, + }) + if err != nil { + return nil, fmt.Errorf("cannot assess vendor: %w", err) + } + + return &types.AssessVendorPayload{ + Vendor: types.NewVendor(vendor), + }, nil +} + // LogoURL is the resolver for the logoUrl field. func (r *organizationResolver) LogoURL(ctx context.Context, obj *types.Organization) (*string, error) { svc := GetTenantService(ctx, r.proboSvc, obj.ID.TenantID()) diff --git a/pkg/server/server.go b/pkg/server/server.go index dba756728..f3ef81d72 100644 --- a/pkg/server/server.go +++ b/pkg/server/server.go @@ -19,6 +19,7 @@ import ( "net/http" "strings" + "github.com/getprobo/probo/pkg/agents" "github.com/getprobo/probo/pkg/connector" "github.com/getprobo/probo/pkg/probo" "github.com/getprobo/probo/pkg/saferedirect" @@ -37,6 +38,7 @@ type Config struct { Usrmgr *usrmgr.Service Auth console_v1.AuthConfig ConnectorRegistry *connector.ConnectorRegistry + VendorAssessment *agents.VendorAssessment SafeRedirect *saferedirect.SafeRedirect Logger *log.Logger }