From 124a23832f3a0914e5191b3684280d83362fe8fd Mon Sep 17 00:00:00 2001 From: Hammad Tariq Date: Wed, 6 May 2026 10:55:36 +0000 Subject: [PATCH] feat: add fixture validator skeleton --- README.md | 7 + cmd/attach-open-score/main.go | 42 +++++ go.mod | 3 + internal/fixtures/validate.go | 282 +++++++++++++++++++++++++++++ internal/fixtures/validate_test.go | 166 +++++++++++++++++ pkg/reasons/reasons.go | 67 +++++++ pkg/schema/types.go | 85 +++++++++ pkg/score/score.go | 16 ++ pkg/sources/source_ref.go | 15 ++ 9 files changed, 683 insertions(+) create mode 100644 cmd/attach-open-score/main.go create mode 100644 go.mod create mode 100644 internal/fixtures/validate.go create mode 100644 internal/fixtures/validate_test.go create mode 100644 pkg/reasons/reasons.go create mode 100644 pkg/schema/types.go create mode 100644 pkg/score/score.go create mode 100644 pkg/sources/source_ref.go diff --git a/README.md b/README.md index 7c05e42..8ecc609 100644 --- a/README.md +++ b/README.md @@ -13,6 +13,13 @@ Start here: - `docs/REASON_CODES.md` — v0 deterministic reason-code taxonomy. - `docs/LIMITATIONS.md` — what v0 can and cannot guarantee. - `spec/v0/score.schema.json` — machine-readable JSON Schema draft. + +Initial tooling: + +```bash +go test ./... +go run ./cmd/attach-open-score --root . +``` - `fixtures/v0/` — synthetic public-safe example verdicts. Status: draft public spec. Source policy, schema, and fixtures come before networked adapters. diff --git a/cmd/attach-open-score/main.go b/cmd/attach-open-score/main.go new file mode 100644 index 0000000..9b37499 --- /dev/null +++ b/cmd/attach-open-score/main.go @@ -0,0 +1,42 @@ +package main + +import ( + "flag" + "fmt" + "os" + "path/filepath" + + "github.com/attach-dev/attach-open-score/internal/fixtures" +) + +func main() { + if err := run(os.Args[1:]); err != nil { + fmt.Fprintln(os.Stderr, err) + os.Exit(1) + } +} + +func run(args []string) error { + flags := flag.NewFlagSet("attach-open-score", flag.ContinueOnError) + flags.SetOutput(os.Stderr) + root := flags.String("root", ".", "repository root containing fixtures/v0") + if err := flags.Parse(args); err != nil { + return err + } + if flags.NArg() > 0 { + return fmt.Errorf("unexpected arguments: %v", flags.Args()) + } + + reports, err := fixtures.ValidateRepository(*root) + if err != nil { + return err + } + for _, report := range reports { + path, err := filepath.Rel(*root, report.Path) + if err != nil { + path = report.Path + } + fmt.Printf("valid %s %s\n", path, report.Decision) + } + return nil +} diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..93af9b1 --- /dev/null +++ b/go.mod @@ -0,0 +1,3 @@ +module github.com/attach-dev/attach-open-score + +go 1.22 diff --git a/internal/fixtures/validate.go b/internal/fixtures/validate.go new file mode 100644 index 0000000..c536153 --- /dev/null +++ b/internal/fixtures/validate.go @@ -0,0 +1,282 @@ +package fixtures + +import ( + "bytes" + "encoding/json" + "errors" + "fmt" + "io" + "net/url" + "os" + "path/filepath" + "sort" + "strings" + "time" + + "github.com/attach-dev/attach-open-score/pkg/reasons" + "github.com/attach-dev/attach-open-score/pkg/schema" +) + +type Report struct { + Path string + Decision schema.Decision + Reasons []string +} + +func ValidateRepository(root string) ([]Report, error) { + pattern := filepath.Join(root, "fixtures", "v0", "*.json") + paths, err := filepath.Glob(pattern) + if err != nil { + return nil, err + } + if len(paths) == 0 { + return nil, fmt.Errorf("no fixtures matched %s", pattern) + } + sort.Strings(paths) + + reports := make([]Report, 0, len(paths)) + for _, path := range paths { + data, err := os.ReadFile(path) + if err != nil { + return nil, err + } + report, err := ValidateBytes(path, data) + if err != nil { + return nil, err + } + reports = append(reports, report) + } + return reports, nil +} + +func ValidateBytes(path string, data []byte) (Report, error) { + var verdict schema.Verdict + dec := json.NewDecoder(bytes.NewReader(data)) + dec.DisallowUnknownFields() + if err := dec.Decode(&verdict); err != nil { + return Report{}, fmt.Errorf("%s: invalid JSON verdict: %w", path, err) + } + if err := dec.Decode(&struct{}{}); err != io.EOF { + return Report{}, fmt.Errorf("%s: invalid JSON verdict: trailing data after first JSON document", path) + } + if err := validateRequiredKeys(data); err != nil { + return Report{}, fmt.Errorf("%s: %w", path, err) + } + if err := validateVerdict(verdict); err != nil { + return Report{}, fmt.Errorf("%s: %w", path, err) + } + + reasons := make([]string, 0, len(verdict.Reasons)) + for _, reason := range verdict.Reasons { + reasons = append(reasons, reason.Code) + } + return Report{Path: path, Decision: verdict.Decision, Reasons: reasons}, nil +} + +func validateRequiredKeys(data []byte) error { + var raw map[string]json.RawMessage + if err := json.Unmarshal(data, &raw); err != nil { + return err + } + for _, key := range []string{"schema_version", "package", "decision", "score", "confidence", "reasons", "source_refs", "evaluated_at", "ttl_seconds", "limitations"} { + if _, ok := raw[key]; !ok { + return fmt.Errorf("missing required field %q", key) + } + } + if msg, ok := raw["policy_profile"]; ok && isEmptyJSONString(msg) { + return errors.New("policy_profile must be non-empty when present") + } + if msg, ok := raw["engine_version"]; ok && isEmptyJSONString(msg) { + return errors.New("engine_version must be non-empty when present") + } + + var pkg map[string]json.RawMessage + if err := json.Unmarshal(raw["package"], &pkg); err != nil { + return fmt.Errorf("package must be an object: %w", err) + } + for _, key := range []string{"ecosystem", "name", "purl", "resolved"} { + if _, ok := pkg[key]; !ok { + return fmt.Errorf("package missing required field %q", key) + } + } + + var refs []map[string]json.RawMessage + if err := json.Unmarshal(raw["source_refs"], &refs); err != nil { + return fmt.Errorf("source_refs must be an array: %w", err) + } + for i, ref := range refs { + for _, key := range []string{"id", "source", "url", "retrieved_at", "ttl_seconds", "license_or_terms_url", "attribution", "attribution_required", "redistribution", "public_display"} { + if _, ok := ref[key]; !ok { + return fmt.Errorf("source_refs[%d] missing required field %q", i, key) + } + } + } + return nil +} + +func isEmptyJSONString(data json.RawMessage) bool { + var s string + return json.Unmarshal(data, &s) == nil && s == "" +} + +func validateVerdict(v schema.Verdict) error { + if v.SchemaVersion != schema.VersionV0 { + return fmt.Errorf("schema_version must be %q", schema.VersionV0) + } + if !validEcosystem(v.Package.Ecosystem) { + return fmt.Errorf("package.ecosystem is invalid: %q", v.Package.Ecosystem) + } + if v.Package.Name == "" || v.Package.PURL == "" { + return errors.New("package must include name and purl") + } + if v.Package.RepositoryURL != "" && !validURI(v.Package.RepositoryURL) { + return fmt.Errorf("package.repository_url must be a URI: %q", v.Package.RepositoryURL) + } + if v.Package.Resolved && v.Package.Version == "" { + return errors.New("package.version is required when package.resolved is true") + } + if !validDecision(v.Decision) { + return fmt.Errorf("decision must be one of ALLOW, ASK, DENY, UNKNOWN; got %q", v.Decision) + } + if v.Score != nil && (*v.Score < 0 || *v.Score > 100) { + return fmt.Errorf("score must be null or 0-100; got %d", *v.Score) + } + if !validConfidence(v.Confidence) { + return fmt.Errorf("confidence must be LOW, MEDIUM, or HIGH; got %q", v.Confidence) + } + if len(v.Reasons) == 0 { + return errors.New("reasons must contain at least one reason") + } + if _, err := time.Parse(time.RFC3339, v.EvaluatedAt); err != nil { + return fmt.Errorf("evaluated_at must be RFC3339 date-time: %w", err) + } + if v.TTLSeconds < 0 { + return errors.New("ttl_seconds must be non-negative") + } + for _, limitation := range v.Limitations { + if limitation == "" { + return errors.New("limitations entries must be non-empty") + } + } + + sourceIDs := map[string]struct{}{} + for _, ref := range v.SourceRefs { + if ref.ID == "" { + return errors.New("source_refs entries must include id") + } + if _, exists := sourceIDs[ref.ID]; exists { + return fmt.Errorf("source_ref id %q is duplicated", ref.ID) + } + if ref.Source == "" || ref.URL == "" || ref.RetrievedAt == "" || ref.LicenseOrTermsURL == "" || ref.Attribution == "" { + return fmt.Errorf("source_ref %q is missing required provenance fields", ref.ID) + } + if !validURI(ref.URL) { + return fmt.Errorf("source_ref %q url must be a URI: %q", ref.ID, ref.URL) + } + if !validURI(ref.LicenseOrTermsURL) { + return fmt.Errorf("source_ref %q license_or_terms_url must be a URI: %q", ref.ID, ref.LicenseOrTermsURL) + } + if !validRedistribution(ref.Redistribution) { + return fmt.Errorf("source_ref %q redistribution is invalid: %q", ref.ID, ref.Redistribution) + } + if !validPublicDisplay(ref.PublicDisplay) { + return fmt.Errorf("source_ref %q public_display is invalid: %q", ref.ID, ref.PublicDisplay) + } + if _, err := time.Parse(time.RFC3339, ref.RetrievedAt); err != nil { + return fmt.Errorf("source_ref %q retrieved_at must be RFC3339 date-time: %w", ref.ID, err) + } + if ref.TTLSeconds < 0 { + return fmt.Errorf("source_ref %q ttl_seconds must be non-negative", ref.ID) + } + sourceIDs[ref.ID] = struct{}{} + } + + for _, reason := range v.Reasons { + if reason.Code == "" || reason.Severity == "" || reason.Message == "" { + return errors.New("reasons must include code, severity, and message") + } + if !validSeverity(reason.Severity) { + return fmt.Errorf("reason %q severity is invalid: %q", reason.Code, reason.Severity) + } + if !reasons.IsKnown(reason.Code) && !strings.HasPrefix(reason.Code, "X_") { + return fmt.Errorf("reason %q is not in the v0 reason-code taxonomy", reason.Code) + } + if !validDecisionEffect(reason.DecisionEffect) { + return fmt.Errorf("reason %q decision_effect is invalid: %q", reason.Code, reason.DecisionEffect) + } + seenReasonRefs := map[string]struct{}{} + for _, id := range reason.SourceRefIDs { + if _, duplicate := seenReasonRefs[id]; duplicate { + return fmt.Errorf("reason %q source_ref_ids contains duplicate %q", reason.Code, id) + } + seenReasonRefs[id] = struct{}{} + if _, ok := sourceIDs[id]; !ok { + return fmt.Errorf("reason %q source_ref_ids references missing source_ref %q", reason.Code, id) + } + } + } + return nil +} + +func validEcosystem(ecosystem string) bool { + switch ecosystem { + case "npm", "pypi", "crates", "go", "maven", "rubygems", "other": + return true + default: + return false + } +} + +func validSeverity(severity string) bool { + switch severity { + case "INFO", "LOW", "MEDIUM", "HIGH", "CRITICAL": + return true + default: + return false + } +} + +func validRedistribution(value string) bool { + switch value { + case "allowed", "restricted", "unknown": + return true + default: + return false + } +} + +func validPublicDisplay(value string) bool { + return validRedistribution(value) +} + +func validURI(value string) bool { + parsed, err := url.ParseRequestURI(value) + return err == nil && parsed.Scheme != "" && parsed.Host != "" +} + +func validDecision(d schema.Decision) bool { + switch d { + case schema.DecisionAllow, schema.DecisionAsk, schema.DecisionDeny, schema.DecisionUnknown: + return true + default: + return false + } +} + +func validConfidence(c schema.Confidence) bool { + switch c { + case schema.ConfidenceLow, schema.ConfidenceMedium, schema.ConfidenceHigh: + return true + default: + return false + } +} + +func validDecisionEffect(d schema.DecisionEffect) bool { + switch d { + case schema.DecisionEffectAllow, schema.DecisionEffectAsk, schema.DecisionEffectDeny, schema.DecisionEffectUnknown, schema.DecisionEffectNone: + return true + default: + return false + } +} diff --git a/internal/fixtures/validate_test.go b/internal/fixtures/validate_test.go new file mode 100644 index 0000000..4d998f4 --- /dev/null +++ b/internal/fixtures/validate_test.go @@ -0,0 +1,166 @@ +package fixtures + +import ( + "path/filepath" + "strings" + "testing" +) + +func TestValidateRepositoryFixtures(t *testing.T) { + root := filepath.Join("..", "..") + reports, err := ValidateRepository(root) + if err != nil { + t.Fatalf("ValidateRepository returned error: %v", err) + } + if len(reports) < 4 { + t.Fatalf("expected at least 4 fixture reports, got %d", len(reports)) + } + for _, report := range reports { + if report.Path == "" { + t.Fatalf("fixture report missing path: %#v", report) + } + if report.Decision == "" { + t.Fatalf("fixture report missing decision for %s", report.Path) + } + if len(report.Reasons) == 0 { + t.Fatalf("fixture report missing reasons for %s", report.Path) + } + } +} + +func TestValidateBytesRejectsUnknownDecision(t *testing.T) { + fixture := []byte(`{ + "schema_version":"attach-open-score/v0", + "package":{"ecosystem":"npm","name":"synthetic","purl":"pkg:npm/synthetic@1.0.0","resolved":true,"version":"1.0.0"}, + "decision":"MAYBE", + "score":10, + "confidence":"HIGH", + "reasons":[{"code":"NO_KNOWN_VULNERABILITIES","severity":"INFO","decision_effect":"ALLOW","message":"synthetic"}], + "source_refs":[], + "evaluated_at":"2026-05-05T00:00:00Z", + "ttl_seconds":86400, + "limitations":[] + }`) + + _, err := ValidateBytes("bad-decision.json", fixture) + if err == nil { + t.Fatal("expected invalid decision error") + } + if !strings.Contains(err.Error(), "decision") { + t.Fatalf("expected decision error, got %v", err) + } +} + +func TestValidateBytesRequiresSourceRefsForSourceBackedReasons(t *testing.T) { + fixture := []byte(`{ + "schema_version":"attach-open-score/v0", + "package":{"ecosystem":"npm","name":"synthetic","purl":"pkg:npm/synthetic@1.0.0","resolved":true,"version":"1.0.0"}, + "decision":"DENY", + "score":95, + "confidence":"HIGH", + "reasons":[{"code":"KNOWN_MALICIOUS_PACKAGE","severity":"CRITICAL","decision_effect":"DENY","message":"synthetic","source_ref_ids":["missing"]}], + "source_refs":[], + "evaluated_at":"2026-05-05T00:00:00Z", + "ttl_seconds":86400, + "limitations":[] + }`) + + _, err := ValidateBytes("missing-source-ref.json", fixture) + if err == nil { + t.Fatal("expected missing source_ref error") + } + if !strings.Contains(err.Error(), "source_ref_ids") { + t.Fatalf("expected source_ref_ids error, got %v", err) + } +} + +func TestValidateBytesRequiresTopLevelScoreField(t *testing.T) { + fixture := []byte(`{ + "schema_version":"attach-open-score/v0", + "package":{"ecosystem":"npm","name":"synthetic","purl":"pkg:npm/synthetic@1.0.0","resolved":true,"version":"1.0.0"}, + "decision":"ALLOW", + "confidence":"HIGH", + "reasons":[{"code":"NO_KNOWN_VULNERABILITIES","severity":"INFO","decision_effect":"NONE","message":"synthetic"}], + "source_refs":[], + "evaluated_at":"2026-05-05T00:00:00Z", + "ttl_seconds":86400, + "limitations":[] + }`) + + _, err := ValidateBytes("missing-score.json", fixture) + if err == nil { + t.Fatal("expected missing score error") + } + if !strings.Contains(err.Error(), "score") { + t.Fatalf("expected score error, got %v", err) + } +} + +func TestValidateBytesRejectsInvalidSourceRefPosture(t *testing.T) { + fixture := []byte(`{ + "schema_version":"attach-open-score/v0", + "package":{"ecosystem":"npm","name":"synthetic","purl":"pkg:npm/synthetic@1.0.0","resolved":true,"version":"1.0.0"}, + "decision":"ASK", + "score":50, + "confidence":"MEDIUM", + "reasons":[{"code":"SOURCE_STALE","severity":"MEDIUM","decision_effect":"ASK","message":"synthetic","source_ref_ids":["src"]}], + "source_refs":[{"id":"src","source":"synthetic","url":"https://example.invalid/source","retrieved_at":"2026-05-05T00:00:00Z","ttl_seconds":86400,"license_or_terms_url":"https://example.invalid/terms","attribution":"synthetic","attribution_required":false,"redistribution":"maybe","public_display":"allowed"}], + "evaluated_at":"2026-05-05T00:00:00Z", + "ttl_seconds":86400, + "limitations":[] + }`) + + _, err := ValidateBytes("bad-source-posture.json", fixture) + if err == nil { + t.Fatal("expected redistribution error") + } + if !strings.Contains(err.Error(), "redistribution") { + t.Fatalf("expected redistribution error, got %v", err) + } +} + +func TestValidateBytesRejectsTrailingData(t *testing.T) { + fixture := []byte(`{ + "schema_version":"attach-open-score/v0", + "package":{"ecosystem":"npm","name":"synthetic","purl":"pkg:npm/synthetic@1.0.0","resolved":true,"version":"1.0.0"}, + "decision":"UNKNOWN", + "score":null, + "confidence":"LOW", + "reasons":[{"code":"INSUFFICIENT_DATA","severity":"MEDIUM","decision_effect":"UNKNOWN","message":"synthetic"}], + "source_refs":[], + "evaluated_at":"2026-05-05T00:00:00Z", + "ttl_seconds":3600, + "limitations":[] + } {"extra":true}`) + + _, err := ValidateBytes("trailing.json", fixture) + if err == nil { + t.Fatal("expected trailing data error") + } + if !strings.Contains(err.Error(), "trailing") { + t.Fatalf("expected trailing data error, got %v", err) + } +} + +func TestValidateBytesRequiresSourceRefAttributionRequiredField(t *testing.T) { + fixture := []byte(`{ + "schema_version":"attach-open-score/v0", + "package":{"ecosystem":"npm","name":"synthetic","purl":"pkg:npm/synthetic@1.0.0","resolved":true,"version":"1.0.0"}, + "decision":"ASK", + "score":50, + "confidence":"MEDIUM", + "reasons":[{"code":"SOURCE_STALE","severity":"MEDIUM","decision_effect":"ASK","message":"synthetic","source_ref_ids":["src"]}], + "source_refs":[{"id":"src","source":"synthetic","url":"https://example.invalid/source","retrieved_at":"2026-05-05T00:00:00Z","ttl_seconds":86400,"license_or_terms_url":"https://example.invalid/terms","attribution":"synthetic","redistribution":"allowed","public_display":"allowed"}], + "evaluated_at":"2026-05-05T00:00:00Z", + "ttl_seconds":86400, + "limitations":[] + }`) + + _, err := ValidateBytes("missing-attribution-required.json", fixture) + if err == nil { + t.Fatal("expected attribution_required error") + } + if !strings.Contains(err.Error(), "attribution_required") { + t.Fatalf("expected attribution_required error, got %v", err) + } +} diff --git a/pkg/reasons/reasons.go b/pkg/reasons/reasons.go new file mode 100644 index 0000000..baa669f --- /dev/null +++ b/pkg/reasons/reasons.go @@ -0,0 +1,67 @@ +package reasons + +const ( + KnownMaliciousPackage = "KNOWN_MALICIOUS_PACKAGE" + KnownVulnerabilityCritical = "KNOWN_VULNERABILITY_CRITICAL" + KnownVulnerabilityHigh = "KNOWN_VULNERABILITY_HIGH" + KnownVulnerabilityModerate = "KNOWN_VULNERABILITY_MODERATE" + NoKnownVulnerabilities = "NO_KNOWN_VULNERABILITIES" + + PackageTooNew = "PACKAGE_TOO_NEW" + VersionTooNew = "VERSION_TOO_NEW" + PackageUnpublishedOrYanked = "PACKAGE_UNPUBLISHED_OR_YANKED" + DeprecatedPackage = "DEPRECATED_PACKAGE" + + InstallScriptPresent = "INSTALL_SCRIPT_PRESENT" + SuspiciousInstallScript = "SUSPICIOUS_INSTALL_SCRIPT" + SuspiciousBinaryArtifact = "SUSPICIOUS_BINARY_ARTIFACT" + ArtifactDigestMismatch = "ARTIFACT_DIGEST_MISMATCH" + + PossibleTyposquat = "POSSIBLE_TYPOSQUAT" + DependencyConfusionRisk = "DEPENDENCY_CONFUSION_RISK" + UnresolvedPackage = "UNRESOLVED_PACKAGE" + UnsupportedEcosystem = "UNSUPPORTED_ECOSYSTEM" + + LowRepositoryHealth = "LOW_REPOSITORY_HEALTH" + RepositoryMappingUncertain = "REPOSITORY_MAPPING_UNCERTAIN" + MaintainerActivityLow = "MAINTAINER_ACTIVITY_LOW" + + SourceUnavailable = "SOURCE_UNAVAILABLE" + SourceTermsBlocked = "SOURCE_TERMS_BLOCKED" + SourceStale = "SOURCE_STALE" + InsufficientData = "INSUFFICIENT_DATA" + ConflictingSourceData = "CONFLICTING_SOURCE_DATA" +) + +var Known = map[string]struct{}{ + KnownMaliciousPackage: {}, + KnownVulnerabilityCritical: {}, + KnownVulnerabilityHigh: {}, + KnownVulnerabilityModerate: {}, + NoKnownVulnerabilities: {}, + PackageTooNew: {}, + VersionTooNew: {}, + PackageUnpublishedOrYanked: {}, + DeprecatedPackage: {}, + InstallScriptPresent: {}, + SuspiciousInstallScript: {}, + SuspiciousBinaryArtifact: {}, + ArtifactDigestMismatch: {}, + PossibleTyposquat: {}, + DependencyConfusionRisk: {}, + UnresolvedPackage: {}, + UnsupportedEcosystem: {}, + LowRepositoryHealth: {}, + RepositoryMappingUncertain: {}, + MaintainerActivityLow: {}, + SourceUnavailable: {}, + SourceTermsBlocked: {}, + SourceStale: {}, + InsufficientData: {}, + ConflictingSourceData: {}, +} + +func IsKnown(code string) bool { + _, ok := Known[code] + return ok +} diff --git a/pkg/schema/types.go b/pkg/schema/types.go new file mode 100644 index 0000000..5d63422 --- /dev/null +++ b/pkg/schema/types.go @@ -0,0 +1,85 @@ +package schema + +import "time" + +const VersionV0 = "attach-open-score/v0" + +type Decision string + +const ( + DecisionAllow Decision = "ALLOW" + DecisionAsk Decision = "ASK" + DecisionDeny Decision = "DENY" + DecisionUnknown Decision = "UNKNOWN" +) + +type DecisionEffect string + +const ( + DecisionEffectAllow DecisionEffect = "ALLOW" + DecisionEffectAsk DecisionEffect = "ASK" + DecisionEffectDeny DecisionEffect = "DENY" + DecisionEffectUnknown DecisionEffect = "UNKNOWN" + DecisionEffectNone DecisionEffect = "NONE" +) + +type Confidence string + +const ( + ConfidenceLow Confidence = "LOW" + ConfidenceMedium Confidence = "MEDIUM" + ConfidenceHigh Confidence = "HIGH" +) + +type PackageIdentity struct { + Ecosystem string `json:"ecosystem"` + Name string `json:"name"` + Version string `json:"version,omitempty"` + PURL string `json:"purl"` + RequestedSpec string `json:"requested_spec,omitempty"` + Resolved bool `json:"resolved"` + RepositoryURL string `json:"repository_url,omitempty"` +} + +type Reason struct { + Code string `json:"code"` + Severity string `json:"severity"` + DecisionEffect DecisionEffect `json:"decision_effect"` + Message string `json:"message"` + SourceRefIDs []string `json:"source_ref_ids,omitempty"` + Details map[string]any `json:"details,omitempty"` +} + +type SourceRef struct { + ID string `json:"id"` + Source string `json:"source"` + SourceID string `json:"source_id,omitempty"` + URL string `json:"url"` + RetrievedAt string `json:"retrieved_at"` + TTLSeconds int `json:"ttl_seconds"` + LicenseOrTermsURL string `json:"license_or_terms_url"` + Attribution string `json:"attribution"` + AttributionRequired bool `json:"attribution_required"` + Redistribution string `json:"redistribution"` + PublicDisplay string `json:"public_display"` +} + +type Verdict struct { + SchemaVersion string `json:"schema_version"` + PolicyProfile string `json:"policy_profile,omitempty"` + EngineVersion string `json:"engine_version,omitempty"` + Package PackageIdentity `json:"package"` + Decision Decision `json:"decision"` + Score *int `json:"score"` + Confidence Confidence `json:"confidence"` + Reasons []Reason `json:"reasons"` + SourceRefs []SourceRef `json:"source_refs"` + EvaluatedAt string `json:"evaluated_at"` + TTLSeconds int `json:"ttl_seconds"` + Limitations []string `json:"limitations"` + Debug map[string]any `json:"debug,omitempty"` +} + +func (v Verdict) EvaluatedTime() (time.Time, error) { + return time.Parse(time.RFC3339, v.EvaluatedAt) +} diff --git a/pkg/score/score.go b/pkg/score/score.go new file mode 100644 index 0000000..1b5ba23 --- /dev/null +++ b/pkg/score/score.go @@ -0,0 +1,16 @@ +package score + +import "github.com/attach-dev/attach-open-score/pkg/schema" + +type Request struct { + Package schema.PackageIdentity + Evidence []Evidence + Mode string +} + +type Evidence struct { + Reason schema.Reason + SourceRef *schema.SourceRef +} + +type Result = schema.Verdict diff --git a/pkg/sources/source_ref.go b/pkg/sources/source_ref.go new file mode 100644 index 0000000..8cf65f9 --- /dev/null +++ b/pkg/sources/source_ref.go @@ -0,0 +1,15 @@ +package sources + +import "github.com/attach-dev/attach-open-score/pkg/schema" + +type Ref = schema.SourceRef + +const ( + RedistributionAllowed = "allowed" + RedistributionRestricted = "restricted" + RedistributionUnknown = "unknown" + + PublicDisplayAllowed = "allowed" + PublicDisplayRestricted = "restricted" + PublicDisplayUnknown = "unknown" +)