Files
probo/pkg/vetting/assessment.go
Bryan Frimin 0f0f6643ad Rename backend services for portal
Point domain services, bootstrap, and related
packages at Compliance Portal types so callers
stop depending on the old Trust Center names.

Signed-off-by: Bryan Frimin <bryan@probo.com>
2026-07-21 15:44:24 +02:00

360 lines
16 KiB
Go

// Copyright (c) 2026 Probo Inc <hello@probo.com>.
//
// Permission is hereby granted, free of charge, to any person obtaining a copy
// of this software and associated documentation files (the "Software"), to deal
// in the Software without restriction, including without limitation the rights
// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
// copies of the Software, and to permit persons to whom the Software is
// furnished to do so, subject to the following conditions:
//
// The above copyright notice and this permission notice shall be included in
// all copies or substantial portions of the Software.
//
// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
// SOFTWARE.
package vetting
import (
"context"
_ "embed"
"encoding/json"
"fmt"
"net/url"
"time"
"go.gearno.de/kit/log"
"go.probo.inc/probo/pkg/agent"
"go.probo.inc/probo/pkg/agent/tools/browser"
"go.probo.inc/probo/pkg/llm"
)
const (
// DefaultMaxTokens is the fallback max-tokens budget used when the
// third-party-vetter agent config does not specify a value. Sized to
// leave headroom above the orchestrator's thinking budget on
// Anthropic models.
DefaultMaxTokens = 16384
// AssessmentTimeout is the hard upper bound on a single assessment
// run. This is also the timeout the CLI client should use.
AssessmentTimeout = 20 * time.Minute
// extractionTimeout is the dedicated budget for the final
// third_party_info_extractor turn. It runs outside the orchestrator's
// budget so a slow orchestrator can't starve the extractor.
extractionTimeout = 5 * time.Minute
)
// thirdPartyCategoryEnum is the canonical list of allowed values for
// ThirdPartyInfo.Category. It is duplicated into the jsonschema struct tag
// because Go struct tags must be compile-time string literals.
var thirdPartyCategoryEnum = []string{
"ANALYTICS", "ACCOUNTING", "CLOUD_MONITORING", "CLOUD_PROVIDER",
"COLLABORATION", "CONSULTING", "CUSTOMER_SUPPORT",
"DATA_STORAGE_AND_PROCESSING", "DOCUMENT_MANAGEMENT",
"EMPLOYEE_MANAGEMENT", "ENGINEERING", "FINANCE", "IDENTITY_PROVIDER",
"IT", "LEGAL", "MARKETING", "OFFICE_OPERATIONS", "OTHER",
"PASSWORD_MANAGEMENT", "PRODUCT_AND_DESIGN", "PROFESSIONAL_SERVICES",
"RECRUITING", "SALES", "SECURITY", "STAFFING", "VERSION_CONTROL",
}
// thirdPartyTypeEnum is the canonical list of allowed values for
// ThirdPartyInfo.ThirdPartyType.
var thirdPartyTypeEnum = []string{
"SAAS", "INFRASTRUCTURE", "PROFESSIONAL_SERVICES", "STAFFING", "OTHER",
}
var (
//go:embed prompts/extraction.txt
extractionPrompt string
)
type (
Config struct {
Client *llm.Client
Model string
MaxTokens int
ChromeAddr string
FirecrawlAPIKey string
Logger *log.Logger
}
Assessor struct {
cfg Config
}
Subprocessor struct {
Name string `json:"name"`
Country string `json:"country"`
Purpose string `json:"purpose"`
}
RiskScore struct {
Category string `json:"category"`
Rating string `json:"rating"`
Notes string `json:"notes"`
}
ThirdPartyInfo struct {
Name string `json:"name" jsonschema:"Third party display name as shown on the website"`
Description string `json:"description" jsonschema:"One-sentence description of what the third party does"`
Category string `json:"category" jsonschema:"Third party category; one of thirdPartyCategoryEnum"`
ThirdPartyType string `json:"third_party_type" jsonschema:"Third party type; one of thirdPartyTypeEnum"`
HeadquarterAddress string `json:"headquarter_address" jsonschema:"Third party headquarters address (city, country) if mentioned"`
LegalName string `json:"legal_name" jsonschema:"Legal entity name if different from display name (e.g. 'Datadog, Inc.')"`
PrivacyPolicyURL string `json:"privacy_policy_url" jsonschema:"URL to the third_party's privacy policy page"`
ServiceLevelAgreementURL string `json:"service_level_agreement_url" jsonschema:"URL to the SLA page"`
DataProcessingAgreementURL string `json:"data_processing_agreement_url" jsonschema:"URL to the DPA page"`
BusinessAssociateAgreementURL string `json:"business_associate_agreement_url" jsonschema:"URL to the BAA page if HIPAA-eligible"`
SubprocessorsListURL string `json:"subprocessors_list_url" jsonschema:"URL to the public subprocessors list"`
SecurityPageURL string `json:"security_page_url" jsonschema:"URL to the third_party's security page"`
TrustPageURL string `json:"trust_page_url" jsonschema:"URL to the compliance portal"`
TermsOfServiceURL string `json:"terms_of_service_url" jsonschema:"URL to the terms of service"`
StatusPageURL string `json:"status_page_url" jsonschema:"URL to the third_party's status / uptime page"`
BugBountyURL string `json:"bug_bounty_url" jsonschema:"URL to the bug bounty or responsible disclosure program"`
IncidentResponseURL string `json:"incident_response_url" jsonschema:"URL to incident response or post-mortem documentation"`
DataLocations []string `json:"data_locations" jsonschema:"Countries or regions where data is processed or stored (e.g. 'United States', 'EU', 'Germany')"`
Certifications []string `json:"certifications" jsonschema:"Compliance certifications found (e.g. 'SOC 2 Type II', 'ISO 27001')"`
Subprocessors []Subprocessor `json:"subprocessors" jsonschema:"Sub-processors discovered with name, country, purpose"`
// Privacy classification (ISO 27701).
PrivacyRole string `json:"privacy_role" jsonschema:"Privacy role under ISO 27701: CONTROLLER, PROCESSOR, SUBPROCESSOR, NONE"`
ProcessesPII bool `json:"processes_pii" jsonschema:"Whether the third_party processes personal data"`
CrossBorderTransfer bool `json:"cross_border_transfer" jsonschema:"Whether cross-border data transfers occur"`
// Privacy risk fields.
DPAStatus string `json:"dpa_status" jsonschema:"DPA accessibility: AVAILABLE, AVAILABLE_ON_REQUEST, NOT_FOUND, BEHIND_LOGIN"`
DSARCapability string `json:"dsar_capability" jsonschema:"Brief summary of how the third_party handles Data Subject Access Requests"`
DataMinimization string `json:"data_minimization" jsonschema:"Brief summary of data minimization practices"`
PurposeLimitation string `json:"purpose_limitation" jsonschema:"Brief summary of purpose limitation commitments"`
RetentionPolicy string `json:"retention_policy" jsonschema:"Brief summary of data retention policy"`
DeletionPolicy string `json:"deletion_policy" jsonschema:"Brief summary of data deletion policy"`
// AI classification (ISO 42001).
InvolvesAI bool `json:"involves_ai" jsonschema:"Whether the third_party uses AI/ML in their product or service"`
AIUseCases []string `json:"ai_use_cases" jsonschema:"Array of AI use case descriptions (e.g. 'content generation', 'fraud detection')"`
// AI risk fields.
AIGovernanceDocURL string `json:"ai_governance_doc_url" jsonschema:"URL to AI governance or responsible AI documentation"`
AITransparency string `json:"ai_transparency" jsonschema:"Brief summary of model transparency findings"`
BiasControls string `json:"bias_controls" jsonschema:"Brief summary of bias detection and fairness measures"`
HumanOversight string `json:"human_oversight" jsonschema:"Brief summary of human oversight mechanisms for AI decisions"`
TrainingDataGovernance string `json:"training_data_governance" jsonschema:"Brief summary of training data governance"`
// Contractual clause analysis.
PrivacyClauses []string `json:"privacy_clauses" jsonschema:"Notable privacy contractual clauses found (e.g. '72-hour breach notification', 'SCCs included')"`
AIClauses []string `json:"ai_clauses" jsonschema:"Notable AI contractual clauses found (e.g. 'Customer data not used for training')"`
// Minimum acceptance baseline.
MinimumBaselineMet bool `json:"minimum_baseline_met" jsonschema:"Whether all hard-reject baseline criteria are met"`
BaselineFailures []string `json:"baseline_failures" jsonschema:"List of failed baseline criteria descriptions"`
// Risk scoring.
OverallRiskRating string `json:"overall_risk_rating" jsonschema:"Overall risk rating: Low, Medium, High"`
OverallRiskScore int `json:"overall_risk_score" jsonschema:"Overall risk score from the report (0-100)"`
Recommendation string `json:"recommendation" jsonschema:"Recommendation: APPROVE, APPROVE_WITH_CONDITIONS, ESCALATE, REJECT"`
RiskScores []RiskScore `json:"risk_scores" jsonschema:"Per-category risk scores from the Risk Summary table"`
SecurityRiskScore int `json:"security_risk_score" jsonschema:"Security pillar risk score (0-100)"`
PrivacyRiskScore int `json:"privacy_risk_score" jsonschema:"Privacy pillar risk score (0-100)"`
AIRiskScore int `json:"ai_risk_score" jsonschema:"AI pillar risk score (0-100), 0 if no AI"`
InformationGaps []string `json:"information_gaps" jsonschema:"Concise descriptions of information gaps from the report"`
ProfessionalLicenses []string `json:"professional_licenses" jsonschema:"Professional license descriptions for services firms (e.g. 'New York State Bar')"`
IndustryMemberships []string `json:"industry_memberships" jsonschema:"Industry body memberships (e.g. 'AICPA', 'American Bar Association')"`
InsuranceCoverage string `json:"insurance_coverage" jsonschema:"Description of professional liability or E&O insurance"`
}
Result struct {
Document string
Info ThirdPartyInfo
}
)
func NewAssessor(cfg Config) *Assessor {
return &Assessor{cfg: cfg}
}
func (a *Assessor) Assess(ctx context.Context, websiteURL string, procedure string, reporter agent.ProgressReporter, extraTools []agent.Tool) (*Result, error) {
u, err := url.Parse(websiteURL)
if err != nil {
return nil, fmt.Errorf("cannot parse website URL %q: %w", websiteURL, err)
}
if u.Scheme != "http" && u.Scheme != "https" {
return nil, fmt.Errorf("website URL must use http or https, got %q", u.Scheme)
}
if u.Hostname() == "" {
return nil, fmt.Errorf("website URL %q has no host", websiteURL)
}
// Detach from the caller's context (typically the HTTP request) so
// that the assessment is not cancelled when the client disconnects.
// A dedicated timeout prevents the assessment from running forever.
ctx, cancel := context.WithTimeout(context.WithoutCancel(ctx), AssessmentTimeout)
defer cancel()
// One shared remote Chrome allocator for all sub-agents. Sub-agents
// that need external links (subprocessor hosts, research) share it
// with vendor-site crawlers. Navigation is still gated by public-IP
// checks; we do not pin an allowed domain so external follows work.
webBrowser := browser.NewBrowser(ctx, a.cfg.ChromeAddr)
defer webBrowser.Close()
orchestrator, err := newOrchestratorAgent(
a.cfg.Client,
a.cfg.Model,
a.cfg.MaxTokens,
procedure,
a.cfg.Logger,
webBrowser,
a.cfg.FirecrawlAPIKey,
reporter,
extraTools,
)
if err != nil {
return nil, fmt.Errorf("cannot create orchestrator agent: %w", err)
}
orchestratorResult, err := orchestrator.Run(
ctx,
[]llm.Message{
{
Role: llm.RoleUser,
Parts: []llm.Part{llm.TextPart{Text: websiteURL}},
},
},
)
if err != nil {
return nil, fmt.Errorf("cannot assess thirdParty: %w", err)
}
document := orchestratorResult.FinalMessage().Text()
// Extraction is LLM-only; release Chrome before it runs.
webBrowser.Close()
reportProgress(ctx, reporter, "extract_third_party_info", agent.ProgressEventStepStarted)
info, err := a.extractThirdPartyInfo(ctx, document)
if err != nil {
reportProgress(ctx, reporter, "extract_third_party_info", agent.ProgressEventStepFailed)
return nil, fmt.Errorf("cannot extract thirdParty info: %w", err)
}
toolSubprocessors := subprocessorsFromOrchestratorMessages(orchestratorResult.Messages)
info.Subprocessors = mergeSubprocessors(toolSubprocessors, info.Subprocessors)
if info.SubprocessorsListURL == "" {
info.SubprocessorsListURL = subprocessorListURLFromOrchestratorMessages(orchestratorResult.Messages)
}
reportProgress(ctx, reporter, "extract_third_party_info", agent.ProgressEventStepCompleted)
return &Result{
Document: document,
Info: *info,
}, nil
}
func (a *Assessor) extractThirdPartyInfo(ctx context.Context, document string) (*ThirdPartyInfo, error) {
outputType, err := thirdPartyInfoOutputType()
if err != nil {
return nil, fmt.Errorf("cannot build thirdParty info output type: %w", err)
}
// Run the extractor on its own timeout so a slow orchestrator
// cannot starve the final JSON conversion step. The extractor has
// no tools and produces one structured JSON output; a few minutes
// is more than enough even when streaming is forced.
extractCtx, cancel := context.WithTimeout(
context.WithoutCancel(ctx),
extractionTimeout,
)
defer cancel()
extractor := agent.New(
"third_party_info_extractor",
a.cfg.Client,
agent.WithInstructions(extractionPrompt),
agent.WithModel(a.cfg.Model),
agent.WithMaxTokens(a.cfg.MaxTokens),
agent.WithLogger(a.cfg.Logger),
agent.WithOutputType(outputType),
)
result, err := extractor.Run(
extractCtx,
[]llm.Message{
{
Role: llm.RoleUser,
Parts: []llm.Part{llm.TextPart{Text: document}},
},
},
)
if err != nil {
return nil, fmt.Errorf("cannot extract thirdParty info: %w", err)
}
var info ThirdPartyInfo
if err := json.Unmarshal([]byte(result.FinalMessage().Text()), &info); err != nil {
return nil, fmt.Errorf("cannot parse thirdParty info output: %w", err)
}
return &info, nil
}
// thirdPartyInfoOutputType builds the ThirdPartyInfo structured output type and
// decorates its JSON Schema with explicit enum constraints on fields
// whose allowed values live in package-level slices. jsonschema-go only
// reads struct tags as free-form descriptions, so the enum list cannot
// be encoded in the tag itself.
func thirdPartyInfoOutputType() (*agent.OutputType, error) {
outputType, err := agent.NewOutputType[ThirdPartyInfo]("third_party_info")
if err != nil {
return nil, fmt.Errorf("cannot create thirdParty info output type: %w", err)
}
var schema map[string]any
if err := json.Unmarshal(outputType.Schema, &schema); err != nil {
return nil, fmt.Errorf("cannot unmarshal thirdParty info schema: %w", err)
}
properties, ok := schema["properties"].(map[string]any)
if !ok {
return nil, fmt.Errorf("thirdParty info schema has no properties")
}
enums := map[string][]string{
"category": thirdPartyCategoryEnum,
"third_party_type": thirdPartyTypeEnum,
}
for field, values := range enums {
prop, ok := properties[field].(map[string]any)
if !ok {
return nil, fmt.Errorf("thirdParty info schema has no %q property", field)
}
prop["enum"] = values
}
decorated, err := json.Marshal(schema)
if err != nil {
return nil, fmt.Errorf("cannot marshal decorated thirdParty info schema: %w", err)
}
strict, err := enforceStrictJSONSchema(decorated)
if err != nil {
return nil, fmt.Errorf("cannot enforce strict thirdParty info schema: %w", err)
}
outputType.Schema = strict
return outputType, nil
}