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:
@@ -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 {
|
||||
|
||||
@@ -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`,
|
||||
|
||||
Reference in New Issue
Block a user