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 {
|
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
|
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 {
|
switch {
|
||||||
case info.OverallRiskRating != "" && info.OverallRiskScore > 0:
|
case info.OverallRiskRating != "" && info.OverallRiskScore > 0:
|
||||||
fmt.Fprintf(
|
lines = append(lines, fmt.Sprintf("Overall risk: %d/100 (%s)", info.OverallRiskScore, info.OverallRiskRating))
|
||||||
&b,
|
|
||||||
"Overall risk: %d/100 (%s)\n",
|
|
||||||
info.OverallRiskScore,
|
|
||||||
info.OverallRiskRating,
|
|
||||||
)
|
|
||||||
case info.OverallRiskScore > 0:
|
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 != "":
|
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 != "" {
|
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 {
|
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 {
|
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 {
|
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 {
|
return strings.Join(parts, " · ")
|
||||||
b.WriteByte('\n')
|
}
|
||||||
b.WriteString(strings.Join(scoreParts, " · "))
|
|
||||||
b.WriteByte('\n')
|
func vettingClassificationSection(info ThirdPartyInfo) string {
|
||||||
|
var lines []string
|
||||||
|
|
||||||
|
if info.ThirdPartyType != "" {
|
||||||
|
lines = append(lines, "Type: "+info.ThirdPartyType)
|
||||||
}
|
}
|
||||||
|
|
||||||
if len(info.InformationGaps) > 0 {
|
if info.PrivacyRole != "" {
|
||||||
b.WriteString("\nGaps\n")
|
lines = append(lines, "Privacy role: "+info.PrivacyRole)
|
||||||
|
}
|
||||||
|
|
||||||
gaps := info.InformationGaps
|
lines = append(lines, "Processes PII: "+vettingYesNo(info.ProcessesPII))
|
||||||
if len(gaps) > maxVettingNotesGaps {
|
|
||||||
gaps = gaps[:maxVettingNotesGaps]
|
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 {
|
lines = append(lines, ai)
|
||||||
fmt.Fprintf(&b, "· %s\n", strings.TrimSpace(gap))
|
}
|
||||||
|
|
||||||
|
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 {
|
func formatVettingRecommendation(recommendation string) string {
|
||||||
|
|||||||
@@ -26,13 +26,26 @@ func TestBuildRiskAssessmentNotes(t *testing.T) {
|
|||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
info := ThirdPartyInfo{
|
info := ThirdPartyInfo{
|
||||||
OverallRiskRating: "Medium",
|
OverallRiskRating: "Medium",
|
||||||
OverallRiskScore: 62,
|
OverallRiskScore: 62,
|
||||||
Recommendation: "APPROVE_WITH_CONDITIONS",
|
Recommendation: "APPROVE_WITH_CONDITIONS",
|
||||||
SecurityRiskScore: 45,
|
SecurityRiskScore: 45,
|
||||||
PrivacyRiskScore: 70,
|
PrivacyRiskScore: 70,
|
||||||
AIRiskScore: 10,
|
AIRiskScore: 10,
|
||||||
InvolvesAI: true,
|
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{
|
RiskScores: []RiskScore{
|
||||||
{Category: "Security", Rating: "Medium", Notes: "Missing SOC 2"},
|
{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
|
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
|
Gaps
|
||||||
· No public DPA
|
· No public DPA
|
||||||
· Sub-processor list inaccessible`,
|
· Sub-processor list inaccessible`,
|
||||||
|
|||||||
Reference in New Issue
Block a user