The source headers, LICENSE files, and license metadata had drifted apart. Align the entire project to MIT: - Convert every source-file header to the MIT text across all comment styles (Go, TS, TSX, JS, MJS, SQL, CSS, GraphQL, shell), including SPDX-License-Identifier tags - Set the root and cookie-banner LICENSE files to the MIT text with a "MIT License" title line - Switch the package.json license fields, Docker image label, and cookie-banner README to MIT - Update docs and the genmodels header generator accordingly - Normalize copyright lines to a single format (Copyright (c) <year(s)> Probo Inc <hello@probo.com>.): unify the hello@getprobo.com and hello@probo.inc emails to hello@probo.com and the comma-separated years to a hyphenated range Genuine third-party references are intentionally left untouched: the Lucide icon attributions (Lucide is ISC) and the trivy dependency license allowlist. Signed-off-by: Sacha Al Himdani <sacha@probo.com>
394 lines
14 KiB
Go
394 lines
14 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 thirdparty
|
|
|
|
import (
|
|
"encoding/json"
|
|
"strings"
|
|
"time"
|
|
|
|
"go.probo.inc/probo/pkg/coredata"
|
|
)
|
|
|
|
// Provenance sources and per-field statuses recorded in the enrichment
|
|
// JSON. The "source" distinguishes values written by this enricher from
|
|
// values owned externally (curated seed data or a human edit), which the
|
|
// enricher must never overwrite.
|
|
const (
|
|
enrichmentSourceEnrichment = "enrichment"
|
|
enrichmentSourceExternal = "external"
|
|
|
|
enrichmentFieldStatusFound = "found"
|
|
enrichmentFieldStatusNotFound = "not_found"
|
|
enrichmentFieldStatusLowConfidence = "low_confidence"
|
|
enrichmentFieldStatusExternal = "exists_external"
|
|
enrichmentFieldStatusFallbackDisplayName = "fallback_display_name"
|
|
|
|
// Run-level status recorded at the top of the enrichment payload.
|
|
enrichmentStatusDone = "done"
|
|
enrichmentStatusPartial = "partial"
|
|
enrichmentStatusFailed = "failed"
|
|
)
|
|
|
|
type (
|
|
// EnrichedField is the per-field unit the enrichment agents return:
|
|
// the resolved value plus a self-assessed confidence and the source
|
|
// URL where it was verified. The worker applies a confidence
|
|
// threshold before writing the value to its column.
|
|
EnrichedField struct {
|
|
Value string `json:"value" jsonschema:"The resolved value, or an empty string when not confidently found. Never guess."`
|
|
Confidence float64 `json:"confidence" jsonschema:"Confidence from 0.0 to 1.0 that the value is correct. Use 0 when the value was not found."`
|
|
SourceURL string `json:"source_url" jsonschema:"The URL where this value was verified, or an empty string."`
|
|
}
|
|
|
|
// CertificationsField is the list-valued counterpart of EnrichedField
|
|
// used for the certifications array.
|
|
CertificationsField struct {
|
|
Values []string `json:"values" jsonschema:"Certification or compliance framework names the vendor publicly claims (e.g. 'SOC 2 Type II', 'ISO 27001', 'HIPAA'). Empty when none are found."`
|
|
Confidence float64 `json:"confidence" jsonschema:"Confidence from 0.0 to 1.0 in the certifications list. Use 0 when none are found."`
|
|
SourceURL string `json:"source_url" jsonschema:"The URL where the certifications were found (trust or security page), or an empty string."`
|
|
}
|
|
|
|
// EnrichmentFieldMeta is the per-field provenance recorded in the
|
|
// common_third_parties.enrichment JSON column.
|
|
EnrichmentFieldMeta struct {
|
|
Confidence float64 `json:"confidence"`
|
|
SourceURL string `json:"source_url,omitempty"`
|
|
Status string `json:"status"`
|
|
Source string `json:"source"`
|
|
UpdatedAt time.Time `json:"updated_at"`
|
|
}
|
|
|
|
// EnrichmentDomainMeta is the provenance of one domain written to
|
|
// common_third_party_domains during a run, recorded in the enrichment
|
|
// payload so the discovered domain set is auditable.
|
|
EnrichmentDomainMeta struct {
|
|
Domain string `json:"domain"`
|
|
Confidence float64 `json:"confidence"`
|
|
SourceURL string `json:"source_url,omitempty"`
|
|
UpdatedAt time.Time `json:"updated_at"`
|
|
}
|
|
|
|
// EnrichmentMetadata is the full payload stored in the enrichment
|
|
// JSON column: run-level bookkeeping plus per-field provenance keyed
|
|
// by the column name.
|
|
EnrichmentMetadata struct {
|
|
Model string `json:"model,omitempty"`
|
|
AttemptedAt time.Time `json:"attempted_at"`
|
|
Status string `json:"status"`
|
|
Error string `json:"error,omitempty"`
|
|
Fields map[string]EnrichmentFieldMeta `json:"fields"`
|
|
Domains []EnrichmentDomainMeta `json:"domains,omitempty"`
|
|
}
|
|
)
|
|
|
|
// scalarField describes one *string column the enricher can fill,
|
|
// pairing the agent's resolved value with accessors into the receiver.
|
|
type scalarField struct {
|
|
name string
|
|
get func(*coredata.CommonThirdParty) *string
|
|
set func(*coredata.CommonThirdParty, *string)
|
|
result EnrichedField
|
|
}
|
|
|
|
// parseEnrichmentFields extracts the prior per-field provenance from a
|
|
// row's enrichment payload. A missing or malformed payload yields an
|
|
// empty map, which the merge treats as "no field is enrichment-owned".
|
|
func parseEnrichmentFields(raw json.RawMessage) map[string]EnrichmentFieldMeta {
|
|
if len(raw) == 0 {
|
|
return map[string]EnrichmentFieldMeta{}
|
|
}
|
|
|
|
var meta EnrichmentMetadata
|
|
if err := json.Unmarshal(raw, &meta); err != nil {
|
|
return map[string]EnrichmentFieldMeta{}
|
|
}
|
|
|
|
if meta.Fields == nil {
|
|
return map[string]EnrichmentFieldMeta{}
|
|
}
|
|
|
|
return meta.Fields
|
|
}
|
|
|
|
// applyScalarField merges one resolved scalar field into party, honoring
|
|
// the confidence threshold and prior provenance, and records the
|
|
// resulting per-field metadata. A value already present that this
|
|
// enricher did not write (curated seed data or a human edit) is left
|
|
// untouched. The column is written only when the value clears the
|
|
// threshold; below it the column keeps its prior value and the metadata
|
|
// records why nothing was written.
|
|
func applyScalarField(
|
|
party *coredata.CommonThirdParty,
|
|
meta map[string]EnrichmentFieldMeta,
|
|
prior map[string]EnrichmentFieldMeta,
|
|
field scalarField,
|
|
threshold float64,
|
|
now time.Time,
|
|
) {
|
|
value := strings.TrimSpace(field.result.Value)
|
|
sourceURL := strings.TrimSpace(field.result.SourceURL)
|
|
|
|
existing := field.get(party)
|
|
hasExisting := existing != nil && strings.TrimSpace(*existing) != ""
|
|
|
|
priorMeta, hadPrior := prior[field.name]
|
|
enrichmentOwned := hadPrior && priorMeta.Source == enrichmentSourceEnrichment
|
|
|
|
if hasExisting && !enrichmentOwned {
|
|
meta[field.name] = EnrichmentFieldMeta{
|
|
Status: enrichmentFieldStatusExternal,
|
|
Source: enrichmentSourceExternal,
|
|
UpdatedAt: now,
|
|
}
|
|
|
|
return
|
|
}
|
|
|
|
if value != "" && field.result.Confidence >= threshold {
|
|
v := value
|
|
field.set(party, &v)
|
|
meta[field.name] = EnrichmentFieldMeta{
|
|
Confidence: field.result.Confidence,
|
|
SourceURL: sourceURL,
|
|
Status: enrichmentFieldStatusFound,
|
|
Source: enrichmentSourceEnrichment,
|
|
UpdatedAt: now,
|
|
}
|
|
|
|
return
|
|
}
|
|
|
|
status := enrichmentFieldStatusNotFound
|
|
if value != "" {
|
|
status = enrichmentFieldStatusLowConfidence
|
|
}
|
|
|
|
meta[field.name] = EnrichmentFieldMeta{
|
|
Confidence: field.result.Confidence,
|
|
SourceURL: sourceURL,
|
|
Status: status,
|
|
Source: enrichmentSourceEnrichment,
|
|
UpdatedAt: now,
|
|
}
|
|
}
|
|
|
|
// applyCertifications is the list-valued counterpart of
|
|
// applyScalarField for the certifications column.
|
|
func applyCertifications(
|
|
party *coredata.CommonThirdParty,
|
|
meta map[string]EnrichmentFieldMeta,
|
|
prior map[string]EnrichmentFieldMeta,
|
|
result CertificationsField,
|
|
threshold float64,
|
|
now time.Time,
|
|
) {
|
|
const name = "certifications"
|
|
|
|
values := normalizeCertifications(result.Values)
|
|
sourceURL := strings.TrimSpace(result.SourceURL)
|
|
|
|
hasExisting := len(party.Certifications) > 0
|
|
|
|
priorMeta, hadPrior := prior[name]
|
|
enrichmentOwned := hadPrior && priorMeta.Source == enrichmentSourceEnrichment
|
|
|
|
if hasExisting && !enrichmentOwned {
|
|
meta[name] = EnrichmentFieldMeta{
|
|
Status: enrichmentFieldStatusExternal,
|
|
Source: enrichmentSourceExternal,
|
|
UpdatedAt: now,
|
|
}
|
|
|
|
return
|
|
}
|
|
|
|
if len(values) > 0 && result.Confidence >= threshold {
|
|
party.Certifications = values
|
|
meta[name] = EnrichmentFieldMeta{
|
|
Confidence: result.Confidence,
|
|
SourceURL: sourceURL,
|
|
Status: enrichmentFieldStatusFound,
|
|
Source: enrichmentSourceEnrichment,
|
|
UpdatedAt: now,
|
|
}
|
|
|
|
return
|
|
}
|
|
|
|
status := enrichmentFieldStatusNotFound
|
|
if len(values) > 0 {
|
|
status = enrichmentFieldStatusLowConfidence
|
|
}
|
|
|
|
meta[name] = EnrichmentFieldMeta{
|
|
Confidence: result.Confidence,
|
|
SourceURL: sourceURL,
|
|
Status: status,
|
|
Source: enrichmentSourceEnrichment,
|
|
UpdatedAt: now,
|
|
}
|
|
}
|
|
|
|
// applyLegalNameFallback fills legal_name with the catalog display name
|
|
// when the merge left the column empty, so it is never blank. A real
|
|
// value (agent-resolved or external) already on the row is left
|
|
// untouched. The fallback is recorded as enrichment-owned so a later run
|
|
// that resolves the real legal entity overwrites it.
|
|
func applyLegalNameFallback(
|
|
party *coredata.CommonThirdParty,
|
|
meta map[string]EnrichmentFieldMeta,
|
|
now time.Time,
|
|
) {
|
|
const name = "legal_name"
|
|
|
|
if party.LegalName != nil && strings.TrimSpace(*party.LegalName) != "" {
|
|
return
|
|
}
|
|
|
|
displayName := strings.TrimSpace(party.Name)
|
|
if displayName == "" {
|
|
return
|
|
}
|
|
|
|
party.LegalName = &displayName
|
|
meta[name] = EnrichmentFieldMeta{
|
|
Status: enrichmentFieldStatusFallbackDisplayName,
|
|
Source: enrichmentSourceEnrichment,
|
|
UpdatedAt: now,
|
|
}
|
|
}
|
|
|
|
// normalizeCertifications trims, drops blanks, and de-duplicates the
|
|
// certification names returned by the agent, preserving order.
|
|
func normalizeCertifications(values []string) []string {
|
|
seen := make(map[string]struct{}, len(values))
|
|
out := make([]string, 0, len(values))
|
|
|
|
for _, v := range values {
|
|
v = strings.TrimSpace(v)
|
|
if v == "" {
|
|
continue
|
|
}
|
|
|
|
key := strings.ToLower(v)
|
|
if _, ok := seen[key]; ok {
|
|
continue
|
|
}
|
|
|
|
seen[key] = struct{}{}
|
|
|
|
out = append(out, v)
|
|
}
|
|
|
|
return out
|
|
}
|
|
|
|
// scalarFields returns the descriptor list pairing each *string column
|
|
// with the resolved value from the two agents. website_url comes from
|
|
// the company-profile agent; the document and page URLs come from the
|
|
// compliance-docs agent.
|
|
func scalarFields(
|
|
company CompanyProfileResult,
|
|
compliance ComplianceDocsResult,
|
|
) []scalarField {
|
|
return []scalarField{
|
|
{
|
|
name: "legal_name",
|
|
get: func(p *coredata.CommonThirdParty) *string { return p.LegalName },
|
|
set: func(p *coredata.CommonThirdParty, v *string) { p.LegalName = v },
|
|
result: company.LegalName,
|
|
},
|
|
{
|
|
name: "headquarter_address",
|
|
get: func(p *coredata.CommonThirdParty) *string { return p.HeadquarterAddress },
|
|
set: func(p *coredata.CommonThirdParty, v *string) { p.HeadquarterAddress = v },
|
|
result: company.HeadquarterAddress,
|
|
},
|
|
{
|
|
name: "website_url",
|
|
get: func(p *coredata.CommonThirdParty) *string { return p.WebsiteURL },
|
|
set: func(p *coredata.CommonThirdParty, v *string) { p.WebsiteURL = v },
|
|
result: company.WebsiteURL,
|
|
},
|
|
{
|
|
name: "privacy_policy_url",
|
|
get: func(p *coredata.CommonThirdParty) *string { return p.PrivacyPolicyURL },
|
|
set: func(p *coredata.CommonThirdParty, v *string) { p.PrivacyPolicyURL = v },
|
|
result: compliance.PrivacyPolicyURL,
|
|
},
|
|
{
|
|
name: "terms_of_service_url",
|
|
get: func(p *coredata.CommonThirdParty) *string { return p.TermsOfServiceURL },
|
|
set: func(p *coredata.CommonThirdParty, v *string) { p.TermsOfServiceURL = v },
|
|
result: compliance.TermsOfServiceURL,
|
|
},
|
|
{
|
|
name: "service_level_agreement_url",
|
|
get: func(p *coredata.CommonThirdParty) *string { return p.ServiceLevelAgreementURL },
|
|
set: func(p *coredata.CommonThirdParty, v *string) { p.ServiceLevelAgreementURL = v },
|
|
result: compliance.ServiceLevelAgreementURL,
|
|
},
|
|
{
|
|
name: "service_software_agreement_url",
|
|
get: func(p *coredata.CommonThirdParty) *string { return p.ServiceSoftwareAgreementURL },
|
|
set: func(p *coredata.CommonThirdParty, v *string) { p.ServiceSoftwareAgreementURL = v },
|
|
result: compliance.ServiceSoftwareAgreementURL,
|
|
},
|
|
{
|
|
name: "data_processing_agreement_url",
|
|
get: func(p *coredata.CommonThirdParty) *string { return p.DataProcessingAgreementURL },
|
|
set: func(p *coredata.CommonThirdParty, v *string) { p.DataProcessingAgreementURL = v },
|
|
result: compliance.DataProcessingAgreementURL,
|
|
},
|
|
{
|
|
name: "business_associate_agreement_url",
|
|
get: func(p *coredata.CommonThirdParty) *string { return p.BusinessAssociateAgreementURL },
|
|
set: func(p *coredata.CommonThirdParty, v *string) { p.BusinessAssociateAgreementURL = v },
|
|
result: compliance.BusinessAssociateAgreementURL,
|
|
},
|
|
{
|
|
name: "subprocessors_list_url",
|
|
get: func(p *coredata.CommonThirdParty) *string { return p.SubprocessorsListURL },
|
|
set: func(p *coredata.CommonThirdParty, v *string) { p.SubprocessorsListURL = v },
|
|
result: compliance.SubprocessorsListURL,
|
|
},
|
|
{
|
|
name: "status_page_url",
|
|
get: func(p *coredata.CommonThirdParty) *string { return p.StatusPageURL },
|
|
set: func(p *coredata.CommonThirdParty, v *string) { p.StatusPageURL = v },
|
|
result: compliance.StatusPageURL,
|
|
},
|
|
{
|
|
name: "security_page_url",
|
|
get: func(p *coredata.CommonThirdParty) *string { return p.SecurityPageURL },
|
|
set: func(p *coredata.CommonThirdParty, v *string) { p.SecurityPageURL = v },
|
|
result: compliance.SecurityPageURL,
|
|
},
|
|
{
|
|
name: "trust_page_url",
|
|
get: func(p *coredata.CommonThirdParty) *string { return p.TrustPageURL },
|
|
set: func(p *coredata.CommonThirdParty, v *string) { p.TrustPageURL = v },
|
|
result: compliance.TrustPageURL,
|
|
},
|
|
}
|
|
}
|