Write longer third-party risk assessment vetting notes

Expand buildRiskAssessmentNotes to persist most of the extracted
assessment (classification, per-category risk breakdown, privacy and
data processing practices, AI governance, contractual clauses,
professional standing, and baseline failures) instead of only a short
summary. Fields already stored as structured columns on the third party
(certifications, data locations, document URLs) are omitted to avoid
duplication.

Signed-off-by: Sacha Al Himdani <sacha@probo.com>
This commit is contained in:
Sacha Al Himdani
2026-06-30 12:41:18 +02:00
parent 888fa4d63a
commit b77ced364b
2 changed files with 282 additions and 33 deletions

View File

@@ -152,62 +152,274 @@ func persistVettingRiskAssessment(
}
func buildRiskAssessmentNotes(info ThirdPartyInfo) string {
sections := []string{"Automated vetting"}
appendSection := func(section string) {
if strings.TrimSpace(section) != "" {
sections = append(sections, section)
}
}
appendSection(vettingOverviewSection(info))
appendSection(vettingPillarSection(info))
appendSection(vettingClassificationSection(info))
appendSection(vettingRiskBreakdownSection(info))
appendSection(vettingPrivacySection(info))
appendSection(vettingAIGovernanceSection(info))
appendSection(vettingClausesSection(info))
appendSection(vettingProfessionalStandingSection(info))
appendSection(vettingBaselineSection(info))
appendSection(vettingGapsSection(info))
return strings.Join(sections, "\n\n")
}
func vettingBulletSection(title string, lines []string) string {
if len(lines) == 0 {
return ""
}
var b strings.Builder
b.WriteString("Automated vetting\n\n")
b.WriteString(title)
for _, line := range lines {
line = strings.TrimSpace(line)
if line == "" {
continue
}
b.WriteString("\n· ")
b.WriteString(line)
}
return b.String()
}
func vettingOverviewSection(info ThirdPartyInfo) string {
var lines []string
switch {
case info.OverallRiskRating != "" && info.OverallRiskScore > 0:
fmt.Fprintf(
&b,
"Overall risk: %d/100 (%s)\n",
info.OverallRiskScore,
info.OverallRiskRating,
)
lines = append(lines, fmt.Sprintf("Overall risk: %d/100 (%s)", info.OverallRiskScore, info.OverallRiskRating))
case info.OverallRiskScore > 0:
fmt.Fprintf(&b, "Overall risk: %d/100\n", info.OverallRiskScore)
lines = append(lines, fmt.Sprintf("Overall risk: %d/100", info.OverallRiskScore))
case info.OverallRiskRating != "":
fmt.Fprintf(&b, "Overall risk: %s\n", info.OverallRiskRating)
lines = append(lines, fmt.Sprintf("Overall risk: %s", info.OverallRiskRating))
}
if info.Recommendation != "" {
fmt.Fprintf(&b, "Recommendation: %s\n", formatVettingRecommendation(info.Recommendation))
lines = append(lines, fmt.Sprintf("Recommendation: %s", formatVettingRecommendation(info.Recommendation)))
}
var scoreParts []string
return strings.Join(lines, "\n")
}
func vettingPillarSection(info ThirdPartyInfo) string {
var parts []string
if info.SecurityRiskScore > 0 {
scoreParts = append(scoreParts, fmt.Sprintf("Security %d/100", info.SecurityRiskScore))
parts = append(parts, fmt.Sprintf("Security %d/100", info.SecurityRiskScore))
}
if info.PrivacyRiskScore > 0 {
scoreParts = append(scoreParts, fmt.Sprintf("Privacy %d/100", info.PrivacyRiskScore))
parts = append(parts, fmt.Sprintf("Privacy %d/100", info.PrivacyRiskScore))
}
if info.InvolvesAI || info.AIRiskScore > 0 {
scoreParts = append(scoreParts, fmt.Sprintf("AI %d/100", info.AIRiskScore))
parts = append(parts, fmt.Sprintf("AI %d/100", info.AIRiskScore))
}
if len(scoreParts) > 0 {
b.WriteByte('\n')
b.WriteString(strings.Join(scoreParts, " · "))
b.WriteByte('\n')
return strings.Join(parts, " · ")
}
func vettingClassificationSection(info ThirdPartyInfo) string {
var lines []string
if info.ThirdPartyType != "" {
lines = append(lines, "Type: "+info.ThirdPartyType)
}
if len(info.InformationGaps) > 0 {
b.WriteString("\nGaps\n")
if info.PrivacyRole != "" {
lines = append(lines, "Privacy role: "+info.PrivacyRole)
}
gaps := info.InformationGaps
if len(gaps) > maxVettingNotesGaps {
gaps = gaps[:maxVettingNotesGaps]
lines = append(lines, "Processes PII: "+vettingYesNo(info.ProcessesPII))
if info.CrossBorderTransfer {
lines = append(lines, "Cross-border transfers: yes")
}
if info.InvolvesAI {
ai := "AI involvement: yes"
if useCases := nonEmptyStrings(info.AIUseCases); len(useCases) > 0 {
ai += " (" + strings.Join(useCases, ", ") + ")"
}
for _, gap := range gaps {
fmt.Fprintf(&b, "· %s\n", strings.TrimSpace(gap))
lines = append(lines, ai)
}
return vettingBulletSection("Classification", lines)
}
func vettingRiskBreakdownSection(info ThirdPartyInfo) string {
var lines []string
for _, score := range info.RiskScores {
if score.Category == "" {
continue
}
line := score.Category
if score.Rating != "" {
line += " — " + score.Rating
}
if score.Notes != "" {
line += ": " + score.Notes
}
lines = append(lines, line)
}
return vettingBulletSection("Risk breakdown", lines)
}
func vettingPrivacySection(info ThirdPartyInfo) string {
var lines []string
if info.DPAStatus != "" {
lines = append(lines, "DPA: "+info.DPAStatus)
}
if info.DSARCapability != "" {
lines = append(lines, "DSAR: "+info.DSARCapability)
}
if info.RetentionPolicy != "" {
lines = append(lines, "Retention: "+info.RetentionPolicy)
}
if info.DeletionPolicy != "" {
lines = append(lines, "Deletion: "+info.DeletionPolicy)
}
if info.DataMinimization != "" {
lines = append(lines, "Data minimization: "+info.DataMinimization)
}
if info.PurposeLimitation != "" {
lines = append(lines, "Purpose limitation: "+info.PurposeLimitation)
}
return vettingBulletSection("Privacy & data processing", lines)
}
func vettingAIGovernanceSection(info ThirdPartyInfo) string {
if !info.InvolvesAI {
return ""
}
var lines []string
if info.AITransparency != "" {
lines = append(lines, "Transparency: "+info.AITransparency)
}
if info.BiasControls != "" {
lines = append(lines, "Bias controls: "+info.BiasControls)
}
if info.HumanOversight != "" {
lines = append(lines, "Human oversight: "+info.HumanOversight)
}
if info.TrainingDataGovernance != "" {
lines = append(lines, "Training data governance: "+info.TrainingDataGovernance)
}
if info.AIGovernanceDocURL != "" {
lines = append(lines, "Governance doc: "+info.AIGovernanceDocURL)
}
return vettingBulletSection("AI governance", lines)
}
func vettingClausesSection(info ThirdPartyInfo) string {
var lines []string
for _, clause := range info.PrivacyClauses {
if strings.TrimSpace(clause) != "" {
lines = append(lines, "Privacy: "+clause)
}
}
return strings.TrimSpace(b.String())
for _, clause := range info.AIClauses {
if strings.TrimSpace(clause) != "" {
lines = append(lines, "AI: "+clause)
}
}
return vettingBulletSection("Contractual clauses", lines)
}
func vettingProfessionalStandingSection(info ThirdPartyInfo) string {
var lines []string
for _, license := range info.ProfessionalLicenses {
if strings.TrimSpace(license) != "" {
lines = append(lines, "License: "+license)
}
}
for _, membership := range info.IndustryMemberships {
if strings.TrimSpace(membership) != "" {
lines = append(lines, "Membership: "+membership)
}
}
if info.InsuranceCoverage != "" {
lines = append(lines, "Insurance: "+info.InsuranceCoverage)
}
return vettingBulletSection("Professional standing", lines)
}
func vettingBaselineSection(info ThirdPartyInfo) string {
failures := nonEmptyStrings(info.BaselineFailures)
if len(failures) == 0 {
return ""
}
return vettingBulletSection("Minimum baseline not met", failures)
}
func vettingGapsSection(info ThirdPartyInfo) string {
gaps := nonEmptyStrings(info.InformationGaps)
if len(gaps) > maxVettingNotesGaps {
gaps = gaps[:maxVettingNotesGaps]
}
return vettingBulletSection("Gaps", gaps)
}
func vettingYesNo(value bool) string {
if value {
return "yes"
}
return "no"
}
func nonEmptyStrings(values []string) []string {
out := make([]string, 0, len(values))
for _, value := range values {
if strings.TrimSpace(value) != "" {
out = append(out, strings.TrimSpace(value))
}
}
return out
}
func formatVettingRecommendation(recommendation string) string {

View File

@@ -26,13 +26,26 @@ func TestBuildRiskAssessmentNotes(t *testing.T) {
t.Parallel()
info := ThirdPartyInfo{
OverallRiskRating: "Medium",
OverallRiskScore: 62,
Recommendation: "APPROVE_WITH_CONDITIONS",
SecurityRiskScore: 45,
PrivacyRiskScore: 70,
AIRiskScore: 10,
InvolvesAI: true,
OverallRiskRating: "Medium",
OverallRiskScore: 62,
Recommendation: "APPROVE_WITH_CONDITIONS",
SecurityRiskScore: 45,
PrivacyRiskScore: 70,
AIRiskScore: 10,
ThirdPartyType: "SAAS",
PrivacyRole: "PROCESSOR",
ProcessesPII: true,
CrossBorderTransfer: true,
InvolvesAI: true,
AIUseCases: []string{"content generation", "fraud detection"},
DPAStatus: "AVAILABLE",
DSARCapability: "Self-service portal",
DataLocations: []string{"United States", "EU"},
HumanOversight: "Human review on flagged decisions",
PrivacyClauses: []string{"72-hour breach notification"},
AIClauses: []string{"Customer data not used for training"},
Certifications: []string{"SOC 2 Type II", "ISO 27001"},
BaselineFailures: []string{"No public DPA"},
RiskScores: []RiskScore{
{Category: "Security", Rating: "Medium", Notes: "Missing SOC 2"},
},
@@ -50,6 +63,30 @@ Recommendation: Approve with conditions
Security 45/100 · Privacy 70/100 · AI 10/100
Classification
· Type: SAAS
· Privacy role: PROCESSOR
· Processes PII: yes
· Cross-border transfers: yes
· AI involvement: yes (content generation, fraud detection)
Risk breakdown
· Security — Medium: Missing SOC 2
Privacy & data processing
· DPA: AVAILABLE
· DSAR: Self-service portal
AI governance
· Human oversight: Human review on flagged decisions
Contractual clauses
· Privacy: 72-hour breach notification
· AI: Customer data not used for training
Minimum baseline not met
· No public DPA
Gaps
· No public DPA
· Sub-processor list inaccessible`,