diff --git a/apps/console/src/pages/organizations/third-parties/risks/_components/ThirdPartyRiskAssessmentRow.tsx b/apps/console/src/pages/organizations/third-parties/risks/_components/ThirdPartyRiskAssessmentRow.tsx index f95f42ec2..a63d043b3 100644 --- a/apps/console/src/pages/organizations/third-parties/risks/_components/ThirdPartyRiskAssessmentRow.tsx +++ b/apps/console/src/pages/organizations/third-parties/risks/_components/ThirdPartyRiskAssessmentRow.tsx @@ -22,6 +22,7 @@ import { formatDate } from "@probo/helpers"; import { useTranslate } from "@probo/i18n"; import { Badge, + Markdown, RiskBadge, Td, Tr, @@ -84,15 +85,21 @@ export function ThirdPartyRiskAssessmentRow(props: ThirdPartyRiskAssessmentRowPr {props.isExpanded && ( - -
+ +
{__("Notes")} :
-

- {assessment.notes} -

+ {assessment.notes + ? ( +
+
+ +
+
+ ) + : null}
diff --git a/pkg/vetting/persist.go b/pkg/vetting/persist.go index a041a57d7..367cde64f 100644 --- a/pkg/vetting/persist.go +++ b/pkg/vetting/persist.go @@ -136,7 +136,7 @@ func persistVettingRiskAssessment( } now := time.Now() - notes := buildRiskAssessmentNotes(result.Info) + notes := buildRiskAssessmentNotesFromResult(result) assessment := &coredata.ThirdPartyRiskAssessment{ ID: gid.New(scope.GetTenantID(), coredata.ThirdPartyRiskAssessmentEntityType), @@ -157,6 +157,14 @@ func persistVettingRiskAssessment( return nil } +func buildRiskAssessmentNotesFromResult(result Result) string { + if notes := filterVettingDocumentNotes(result.Document); notes != "" { + return notes + } + + return buildRiskAssessmentNotes(result.Info) +} + func buildRiskAssessmentNotes(info ThirdPartyInfo) string { sections := []string{"Automated vetting"} @@ -180,6 +188,150 @@ func buildRiskAssessmentNotes(info ThirdPartyInfo) string { return strings.Join(sections, "\n\n") } +// filterVettingDocumentNotes drops profile-duplicate sections from the report. +func filterVettingDocumentNotes(document string) string { + document = strings.TrimSpace(document) + if document == "" { + return "" + } + + lines := strings.Split(document, "\n") + out := make([]string, 0, len(lines)) + skipUntilLevel := 0 + inFence := false + fenceMarker := byte(0) + fenceLen := 0 + + for _, line := range lines { + if marker, length, isFence := parseMarkdownFence(line); isFence { + if !inFence { + inFence = true + fenceMarker = marker + fenceLen = length + } else if marker == fenceMarker && length >= fenceLen { + inFence = false + fenceMarker = 0 + fenceLen = 0 + } + + if skipUntilLevel == 0 { + out = append(out, line) + } + + continue + } + + if !inFence { + level, title, isHeading := parseMarkdownHeading(line) + if isHeading { + if skipUntilLevel > 0 && level <= skipUntilLevel { + skipUntilLevel = 0 + } + + if skipUntilLevel == 0 && shouldDropVettingNotesSection(title) { + skipUntilLevel = level + continue + } + } + } + + if skipUntilLevel > 0 { + continue + } + + out = append(out, line) + } + + return strings.TrimSpace(strings.Join(out, "\n")) +} + +func parseMarkdownFence(line string) (marker byte, length int, ok bool) { + rest := line + + indent := 0 + for indent < len(rest) && indent < 3 && rest[indent] == ' ' { + indent++ + } + + rest = rest[indent:] + + if len(rest) == 0 || (rest[0] != '`' && rest[0] != '~') { + return 0, 0, false + } + + marker = rest[0] + for length < len(rest) && rest[length] == marker { + length++ + } + + if length < 3 { + return 0, 0, false + } + + return marker, length, true +} + +func parseMarkdownHeading(line string) (level int, title string, ok bool) { + rest := line + + indent := 0 + for indent < len(rest) && indent < 3 && rest[indent] == ' ' { + indent++ + } + + rest = rest[indent:] + rest = strings.TrimRight(rest, " \t") + + if rest == "" || rest[0] != '#' { + return 0, "", false + } + + level = 0 + for level < len(rest) && rest[level] == '#' { + level++ + if level > 6 { + return 0, "", false + } + } + + if level == 0 || level >= len(rest) { + return 0, "", false + } + + if rest[level] != ' ' && rest[level] != '\t' { + return 0, "", false + } + + title = strings.TrimSpace(rest[level+1:]) + title = strings.TrimRight(title, "#") + title = strings.TrimSpace(title) + + if title == "" { + return 0, "", false + } + + return level, title, true +} + +func shouldDropVettingNotesSection(title string) bool { + normalized := strings.ToLower(strings.TrimSpace(title)) + normalized = strings.Trim(normalized, "*_`") + normalized = strings.Join(strings.Fields(strings.ReplaceAll(normalized, "-", " ")), " ") + + switch { + case strings.Contains(normalized, "third party classification"), + strings.Contains(normalized, "vendor classification"): + return true + case strings.Contains(normalized, "compliance") && strings.Contains(normalized, "certification"): + return true + case strings.Contains(normalized, "sub processor"), + strings.Contains(normalized, "subprocessor"): + return true + default: + return false + } +} + func vettingBulletSection(title string, lines []string) string { if len(lines) == 0 { return "" diff --git a/pkg/vetting/persist_test.go b/pkg/vetting/persist_test.go index 738c2e5b7..fcbfaba03 100644 --- a/pkg/vetting/persist_test.go +++ b/pkg/vetting/persist_test.go @@ -28,6 +28,185 @@ import ( "go.probo.inc/probo/pkg/coredata" ) +func TestBuildRiskAssessmentNotesFromResult_FiltersDocument(t *testing.T) { + t.Parallel() + + document := `# Third party Assessment: Acme + +## Executive Summary +Approve with conditions. + +## Third party Classification +- Name: Acme +- Description: SaaS analytics + +## Overall Risk Score +Score 70/100. + +## Compliance & Certifications +- SOC 2 Type II + +## Privacy & Data Processing +Retention is 30 days. + +### Data Classification & Handling +PII is encrypted at rest. + +### Sub-Processors +| Name | Country | Purpose | +|------|---------|---------| +| AWS | United States | Hosting | + +## Security Posture +TLS looks good. + +## Market Presence +Strong brand. +` + + notes := buildRiskAssessmentNotesFromResult( + Result{ + Document: document, + Info: ThirdPartyInfo{ + OverallRiskScore: 70, + Recommendation: "APPROVE_WITH_CONDITIONS", + }, + }, + ) + + assert.Contains(t, notes, "Executive Summary") + assert.Contains(t, notes, "Approve with conditions.") + assert.Contains(t, notes, "Overall Risk Score") + assert.Contains(t, notes, "Score 70/100.") + assert.Contains(t, notes, "Privacy & Data Processing") + assert.Contains(t, notes, "Retention is 30 days.") + assert.Contains(t, notes, "Data Classification & Handling") + assert.Contains(t, notes, "PII is encrypted at rest.") + assert.Contains(t, notes, "Security Posture") + assert.Contains(t, notes, "Market Presence") + + assert.NotContains(t, notes, "Third party Classification") + assert.NotContains(t, notes, "Name: Acme") + assert.NotContains(t, notes, "Compliance & Certifications") + assert.NotContains(t, notes, "SOC 2 Type II") + assert.NotContains(t, notes, "Sub-Processors") + assert.NotContains(t, notes, "AWS") + assert.NotContains(t, notes, "Automated vetting") +} + +func TestBuildRiskAssessmentNotesFromResult_FallsBackWhenDocumentEmpty(t *testing.T) { + t.Parallel() + + info := ThirdPartyInfo{ + OverallRiskRating: "Medium", + OverallRiskScore: 62, + Recommendation: "APPROVE_WITH_CONDITIONS", + } + + notes := buildRiskAssessmentNotesFromResult(Result{Info: info}) + + assert.Equal(t, buildRiskAssessmentNotes(info), notes) + assert.Contains(t, notes, "Automated vetting") +} + +func TestShouldDropVettingNotesSection(t *testing.T) { + t.Parallel() + + assert.True(t, shouldDropVettingNotesSection("Third party Classification")) + assert.True(t, shouldDropVettingNotesSection("Third-Party Classification")) + assert.True(t, shouldDropVettingNotesSection("Vendor Classification")) + assert.True(t, shouldDropVettingNotesSection("Vendor-Classification")) + assert.True(t, shouldDropVettingNotesSection("Compliance & Certifications")) + assert.True(t, shouldDropVettingNotesSection("Sub-Processors")) + assert.True(t, shouldDropVettingNotesSection("Subprocessors")) + assert.False(t, shouldDropVettingNotesSection("Executive Summary")) + assert.False(t, shouldDropVettingNotesSection("Security Posture")) + assert.False(t, shouldDropVettingNotesSection("Three-Pillar Risk Assessment")) + assert.False(t, shouldDropVettingNotesSection("Data Classification & Handling")) + assert.False(t, shouldDropVettingNotesSection("AI risk classifications")) +} + +func TestFilterVettingDocumentNotes_IgnoresHeadingsInFences(t *testing.T) { + t.Parallel() + + document := `# Assessment + +## Security Posture +Looks good. + +` + "```" + ` +## Third party Classification +This is an example heading inside a fence. +` + "```" + ` + +## Market Presence +Strong. +` + + notes := filterVettingDocumentNotes(document) + + assert.Contains(t, notes, "Security Posture") + assert.Contains(t, notes, "Looks good.") + assert.Contains(t, notes, "Third party Classification") + assert.Contains(t, notes, "This is an example heading inside a fence.") + assert.Contains(t, notes, "Market Presence") + assert.Contains(t, notes, "Strong.") +} + +func TestFilterVettingDocumentNotes_IndentedAndTabHeadings(t *testing.T) { + t.Parallel() + + document := `# Assessment + + ## Third party Classification +- Name: Acme + +##` + "\t" + `Security Posture +TLS looks good. + +## Market Presence +Strong. +` + + notes := filterVettingDocumentNotes(document) + + assert.NotContains(t, notes, "Third party Classification") + assert.NotContains(t, notes, "Name: Acme") + assert.Contains(t, notes, "Security Posture") + assert.Contains(t, notes, "TLS looks good.") + assert.Contains(t, notes, "Market Presence") +} + +func TestParseMarkdownHeading(t *testing.T) { + t.Parallel() + + level, title, ok := parseMarkdownHeading("## Executive Summary") + assert.True(t, ok) + assert.Equal(t, 2, level) + assert.Equal(t, "Executive Summary", title) + + level, title, ok = parseMarkdownHeading(" ## Indented") + assert.True(t, ok) + assert.Equal(t, 2, level) + assert.Equal(t, "Indented", title) + + level, title, ok = parseMarkdownHeading("##\tTabbed") + assert.True(t, ok) + assert.Equal(t, 2, level) + assert.Equal(t, "Tabbed", title) + + level, title, ok = parseMarkdownHeading("## Trailing ##") + assert.True(t, ok) + assert.Equal(t, 2, level) + assert.Equal(t, "Trailing", title) + + _, _, ok = parseMarkdownHeading(" ## Too indented") + assert.False(t, ok) + + _, _, ok = parseMarkdownHeading("##NoSpace") + assert.False(t, ok) +} + func TestBuildRiskAssessmentNotes(t *testing.T) { t.Parallel()