Files
2026-07-13 19:38:35 -05:00

224 lines
7.0 KiB
Go

package main
import (
"fmt"
"regexp"
"sort"
"strings"
)
// ImpactCategory represents the data sensitivity class of a hint.
type ImpactCategory string
const (
ImpactPII ImpactCategory = "pii"
ImpactCredentials ImpactCategory = "credentials"
ImpactInfrastructure ImpactCategory = "infrastructure"
ImpactOperations ImpactCategory = "internal_operations"
ImpactReviewRequired ImpactCategory = "review_required"
)
// Hint describes a detected data sensitivity signal with its evidence basis.
type Hint struct {
Category ImpactCategory `json:"category"`
Basis string `json:"basis"` // "observed", "inferred", "none"
Reason string `json:"reason"`
Signals []string `json:"signals"`
}
// Fixed reason strings for deterministic output.
const (
reasonTablePII = "table name associated with PII"
reasonTableCredentials = "table name associated with credentials"
reasonTableInfrastructure = "table name associated with infrastructure"
reasonTableOperations = "table name associated with internal operations"
reasonFieldPII = "field name associated with PII"
reasonFieldCredentials = "field name associated with credentials"
reasonWidgetOperations = "widget name associated with internal operations"
reasonWidgetPII = "widget name associated with PII"
reasonContentEmail = "email pattern in sample content"
reasonContentCredential = "credential keyword in sample content"
reasonContentInfra = "infrastructure pattern in sample content"
reasonReviewRequired = "records returned, no hint rules matched"
)
var tableReasons = map[ImpactCategory]string{
ImpactPII: reasonTablePII,
ImpactCredentials: reasonTableCredentials,
ImpactInfrastructure: reasonTableInfrastructure,
ImpactOperations: reasonTableOperations,
}
var fieldReasons = map[ImpactCategory]string{
ImpactPII: reasonFieldPII,
ImpactCredentials: reasonFieldCredentials,
}
// Table/field names that imply a category.
var tableHints = map[string]ImpactCategory{
"sys_user": ImpactPII,
"customer_contact": ImpactPII,
"cmn_notif_device": ImpactPII,
"sys_email_attachment": ImpactPII,
"oauth_entity": ImpactCredentials,
"sys_properties": ImpactCredentials,
"cmdb_model": ImpactInfrastructure,
"cmdb_ci": ImpactInfrastructure,
"cmn_company": ImpactInfrastructure,
"cmn_cost_center": ImpactInfrastructure,
"cmn_department": ImpactInfrastructure,
"alm_asset": ImpactInfrastructure,
"sys_portal_page": ImpactInfrastructure,
"incident": ImpactOperations,
"work_order": ImpactOperations,
"task": ImpactOperations,
"sn_customerservice_case": ImpactOperations,
"customer_project": ImpactOperations,
"customer_project_task": ImpactOperations,
"customer_account": ImpactOperations,
}
var fieldHints = map[string]ImpactCategory{
"email": ImpactPII,
"email_address": ImpactPII,
"phone": ImpactPII,
"mobile_phone": ImpactPII,
"home_phone": ImpactPII,
"password": ImpactCredentials,
"api_key": ImpactCredentials,
"token": ImpactCredentials,
"secret": ImpactCredentials,
}
// Content patterns for sample value analysis.
// Phone numbers intentionally excluded: bare digit sequences produce false positives.
var contentPatterns = map[ImpactCategory]contentRule{
ImpactPII: {regexp.MustCompile(`(?i)[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}`), reasonContentEmail},
ImpactCredentials: {regexp.MustCompile(`(?i)(\b(api[_-]?key|token|secret|password|private[_-]?key)\b\s*[:=]\s*\S|\bbearer\s+[a-zA-Z0-9._-]{8,}|-----begin[a-z ]*private key-----)`), reasonContentCredential},
ImpactInfrastructure: {regexp.MustCompile(`(?i)\.(internal|corp|local)([:/\s"']|$)`), reasonContentInfra},
}
type contentRule struct {
pattern *regexp.Regexp
reason string
}
// hintAccumulator collects signals per category, tracking whether any are observed.
type hintAccumulator struct {
signals map[ImpactCategory][]string
observed map[ImpactCategory]bool
reasons map[ImpactCategory]string
}
func newHintAccumulator() *hintAccumulator {
return &hintAccumulator{
signals: map[ImpactCategory][]string{},
observed: map[ImpactCategory]bool{},
reasons: map[ImpactCategory]string{},
}
}
func (h *hintAccumulator) addInferred(cat ImpactCategory, signal, reason string) {
h.signals[cat] = append(h.signals[cat], signal)
if h.reasons[cat] == "" {
h.reasons[cat] = reason
}
}
func (h *hintAccumulator) addObserved(cat ImpactCategory, signal, reason string) {
h.signals[cat] = append(h.signals[cat], signal)
h.observed[cat] = true
h.reasons[cat] = reason // observed reason takes precedence
}
func (h *hintAccumulator) build() []Hint {
var out []Hint
for _, cat := range []ImpactCategory{ImpactPII, ImpactCredentials, ImpactInfrastructure, ImpactOperations} {
sigs, ok := h.signals[cat]
if !ok {
continue
}
basis := "inferred"
if h.observed[cat] {
basis = "observed"
}
sort.Strings(sigs)
out = append(out, Hint{
Category: cat,
Basis: basis,
Reason: h.reasons[cat],
Signals: sigs,
})
}
return out
}
// ClassifyWidget produces hints for widget findings.
func ClassifyWidget(widgetName string, samples []RecordSample) []Hint {
acc := newHintAccumulator()
lower := strings.ToLower(widgetName)
if strings.Contains(lower, "kb") || strings.Contains(lower, "knowledge") {
acc.addInferred(ImpactOperations, "widget:"+widgetName, reasonWidgetOperations)
}
if strings.Contains(lower, "attachment") {
acc.addInferred(ImpactOperations, "widget:"+widgetName, reasonWidgetOperations)
}
if strings.Contains(lower, "user") {
acc.addInferred(ImpactPII, "widget:"+widgetName, reasonWidgetPII)
}
for _, s := range samples {
classifyContent(acc, s.Title)
classifyContent(acc, s.Snippet)
classifyContent(acc, s.ID)
}
return acc.build()
}
// ClassifyTable produces hints for table findings.
func ClassifyTable(table, field string, samples []TableSample) []Hint {
acc := newHintAccumulator()
if cat, ok := tableHints[table]; ok {
reason := tableReasons[cat]
acc.addInferred(cat, "table:"+table, reason)
}
if cat, ok := fieldHints[field]; ok {
reason := fieldReasons[cat]
if reason == "" {
reason = tableReasons[cat]
}
acc.addInferred(cat, "field:"+field, reason)
}
for _, s := range samples {
classifyContent(acc, s.DisplayValue)
}
return acc.build()
}
// ReviewRequiredHint returns a review_required hint for exposed data with no matches.
func ReviewRequiredHint(recordCount int) []Hint {
return []Hint{{
Category: ImpactReviewRequired,
Basis: "none",
Reason: reasonReviewRequired,
Signals: []string{fmt.Sprintf("records_returned:%d", recordCount)},
}}
}
func classifyContent(acc *hintAccumulator, text string) {
if text == "" {
return
}
for cat, rule := range contentPatterns {
if match := rule.pattern.FindString(text); match != "" {
acc.addObserved(cat, "content:"+match, rule.reason)
}
}
}