mirror of
https://github.com/BishopFox/snowpick.git
synced 2026-11-07 10:26:01 -10:00
224 lines
7.0 KiB
Go
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)
|
|
}
|
|
}
|
|
}
|