From 7df292f9cb724fab8ef0696f00ec60c6b25de308 Mon Sep 17 00:00:00 2001 From: SoundMatt Date: Tue, 28 Jul 2026 11:47:55 -0700 Subject: [PATCH] feat: bring FuSaOps' own hara/fmea/tara/safetycase/sas/sci into x-FuSa spec v1.14.0 conformance Dogfoods the schema work from spec v1.13.0/v1.14.0 against FuSaOps' own evidence artifacts. Also fixes a real, pre-existing bug: every Hash field across fmea/tara/safetycase/sas/sci/qualify emitted bare hex instead of the sha256:-prefixed form spec section 2.7 requires for a field named hash. - New shared Canonicalize (RFC 8785) in the root package, replacing each package's fixed-struct-order computeHash. - hara: SafetyGoal.fssrRef (space-delimited string) promoted to fssrRefs []string (MUST, >=1 entry) per section 1.2.5. - fmea: entries/failureMode/item/actionPriority/mitigations/ requirementIds/ratingScale/summary per section 9.2. - tara: threats/threat/attackFeasibility/mitigations renamed; impact is now an SFOP object (safety/financial/operational/privacy) per ISO 21434 Clause 15.7 instead of one generic severity. - safetycase: adds a GSN (Goal Structuring Notation v3) nodes/edges/ completeness projection alongside the existing claims/evidence model. - sas: adds checklist/summary (section 9.3) alongside activities. - sci: adds artifacts[] (file/hash/version, section 9.3) alongside items[]. - Regenerated fmea.json/tara.json/safety-case.json against the new code (all three were badly stale). fmea.csv/tara.md/safety-case.md/ .mermaid have no current code path producing them; left untouched. - 7 new requirements registered. Signed-off-by: SoundMatt --- .fusa-hara.json | 10 +- .fusa-reqs.json | 43 ++ CHANGELOG.md | 51 ++ canonjson.go | 107 ++++ canonjson_test.go | 112 ++++ cmd/fusaops/cmd_hara.go | 38 +- cmd/fusaops/cmd_v147_v150_test.go | 4 +- docs/commands/fmea.md | 9 + docs/commands/safety-case.md | 8 +- docs/commands/sas.md | 8 + docs/commands/tara.md | 8 + fmea.json | 987 ++++++------------------------ fmea/fmea.go | 183 ++++-- fmea/fmea_test.go | 54 +- fusaops.go | 2 +- hara/hara.go | 14 +- hara/hara_test.go | 21 +- qualify/qualify.go | 10 +- safety-case.json | 334 +++++++--- safetycase/safetycase.go | 133 +++- safetycase/safetycase_test.go | 80 +++ sas/sas.go | 81 ++- sas/sas_test.go | 45 ++ sci/sci.go | 45 +- sci/sci_test.go | 27 + tara.json | 211 ++++++- tara/tara.go | 139 +++-- tara/tara_test.go | 49 +- 28 files changed, 1709 insertions(+), 1104 deletions(-) create mode 100644 canonjson.go create mode 100644 canonjson_test.go diff --git a/.fusa-hara.json b/.fusa-hara.json index b2bed98..ab99aee 100644 --- a/.fusa-hara.json +++ b/.fusa-hara.json @@ -65,7 +65,7 @@ "hazards": ["H-001"], "asil": "ASIL-C", "safeState": "Surface a decode error as a failed/skipped component rather than an empty pass", - "fssrRef": "REQ-FO-ADP006 REQ-FO-ADP005" + "fssrRefs": ["REQ-FO-ADP006", "REQ-FO-ADP005"] }, { "id": "SG-002", @@ -73,7 +73,7 @@ "hazards": ["H-002"], "asil": "ASIL-C", "safeState": "Emit a skipped component with reason; never omit an applicable language silently", - "fssrRef": "REQ-FO-ORC003 REQ-FO-RPT003" + "fssrRefs": ["REQ-FO-ORC003", "REQ-FO-RPT003"] }, { "id": "SG-003", @@ -81,7 +81,7 @@ "hazards": ["H-003"], "asil": "ASIL-A", "safeState": "Exit code 1", - "fssrRef": "REQ-FO-CLI008 REQ-FO-RPT006 REQ-FO-ERR005" + "fssrRefs": ["REQ-FO-CLI008", "REQ-FO-RPT006", "REQ-FO-ERR005"] }, { "id": "SG-004", @@ -89,7 +89,7 @@ "hazards": ["H-004"], "asil": "QM", "safeState": "Findings carry language+tool from the producing adapter", - "fssrRef": "REQ-FO-CORE004 REQ-FO-ADP006" + "fssrRefs": ["REQ-FO-CORE004", "REQ-FO-ADP006"] }, { "id": "SG-005", @@ -97,7 +97,7 @@ "hazards": ["H-005"], "asil": "ASIL-B", "safeState": "Provide an explicit refresh that recomputes before serving", - "fssrRef": "REQ-FO-SRV003 REQ-FO-SRV004" + "fssrRefs": ["REQ-FO-SRV003", "REQ-FO-SRV004"] } ] } diff --git a/.fusa-reqs.json b/.fusa-reqs.json index bb2aeb1..735656a 100644 --- a/.fusa-reqs.json +++ b/.fusa-reqs.json @@ -2950,6 +2950,49 @@ "description": "FuSaOps shall provide an ada-FuSa adapter (adafusa) that detects .ads/.adb files and delegates check/trace to adafusa via the generic cmdAdapter path. The adafusa binary produces spec v1.11 conformant JSON so no output normalisation is required.", "priority": "MUST", "rationale": "Extends FuSaOps multi-language orchestration to Ada/SPARK projects using ada-FuSa v0.1.0." + }, + { + "id": "REQ-FO-CORE008", + "title": "RFC 8785 canonical JSON helper", + "description": "FuSaOps shall provide a Canonicalize function producing RFC 8785 (JSON Canonicalization Scheme) sorted-key, whitespace-free JSON, used by every self-integrity hash field per x-FuSa spec section 6/2.7.", + "priority": "MUST", + "rationale": "A hash computed over Go struct-field-order JSON is deterministic only within one Go binary; genuine RFC 8785 canonicalization is what the spec actually requires and is reusable across every artifact hash." + }, + { + "id": "REQ-FO-HARA005", + "title": "SafetyGoal.FssrRefs is a MUST, >=1-entry array", + "priority": "MUST", + "standard": "ISO 26262-8 Clause 6" + }, + { + "id": "REQ-FO-FMEA006", + "title": "FMEA schema conformance: entries/item/actionPriority/requirementIds/ratingScale/summary per x-FuSa spec 9.2", + "priority": "MUST", + "standard": "IEC 61508:2010 / ISO 26262:2018 Part 8-7" + }, + { + "id": "REQ-FO-TARA006", + "title": "TARA schema conformance: threats/threat/attackFeasibility/SFOP impact/summary per x-FuSa spec 9.2", + "priority": "MUST", + "standard": "ISO 21434:2021 Clause 15" + }, + { + "id": "REQ-FO-SC006", + "title": "SafetyCase GSN projection: nodes/edges/completeness per the GSN Community Standard v3 and x-FuSa spec 9.2", + "priority": "MUST", + "standard": "ISO 26262, DO-178C, IEC 61508, ISO 21434" + }, + { + "id": "REQ-FO-SAS006", + "title": "SAS checklist/summary projection per x-FuSa spec 9.3", + "priority": "MUST", + "standard": "DO-178C \u00a711.20" + }, + { + "id": "REQ-FO-SCI005", + "title": "SCI artifacts[] projection (file/hash/version) per x-FuSa spec 9.3", + "priority": "MUST", + "standard": "DO-178C \u00a711.16" } ] } diff --git a/CHANGELOG.md b/CHANGELOG.md index e634902..57435bc 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,57 @@ and the project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0. ## [Unreleased] +## [1.140.0] — 2026-07-28 + +### feat: bring FuSaOps' own hara/fmea/tara/safetycase/sas/sci into x-FuSa spec v1.14.0 conformance + +Dogfoods the schema work from spec v1.13.0/v1.14.0 against FuSaOps' own +evidence artifacts (it develops itself as an ISO 26262 ASIL-C tool). Also +fixes two real, pre-existing bugs found by reading the actual generated +artifacts against the spec: + +- **`Hash` fields across `fmea`/`tara`/`safetycase`/`sas`/`sci`/`qualify` + now carry the `sha256:` prefix** required by spec §2.7 for a field named + `hash` — every one of them previously emitted bare hex. +- **New shared `Canonicalize` (RFC 8785 JSON Canonicalization Scheme)** + in the root package, replacing each package's own fixed-struct-order + `computeHash`, so the hash is genuinely deterministic (not just + deterministic within one Go binary) — the same procedure now backs + `qualify`'s hash too. +- **`hara/hara.go`**: `SafetyGoal.fssrRef` (a space-delimited string + cramming multiple ids into one field) promoted to `fssrRefs []string` + (MUST, ≥1 entry) per spec §1.2.5 — closes a real ISO 26262-8 Clause 6 + traceability gap. `.fusa-hara.json`'s 5 safety goals migrated in place + (all 11 referenced ids already resolved correctly). +- **`fmea/fmea.go`**: `entries[]`/`failureMode`/`item`/`actionPriority`/ + `mitigations`/`requirementIds`/`ratingScale`/`summary` per spec §9.2. + Each of the 8 failure modes now links to the real FuSaOps requirement + its corrective action lives in. +- **`tara/tara.go`**: `threats[]`/`threat`/`attackFeasibility`/ + `mitigations` renamed; `impact` is now an SFOP object (safety/financial/ + operational/privacy, ISO 21434 Clause 15.7) instead of one generic + severity — each of the 8 scenarios re-rated per-axis rather than + copy-pasted. `FeasibilityVeryLow` corrected `very_low` → `very-low` + (spec enum spelling). +- **`safetycase/safetycase.go`**: adds a GSN (Goal Structuring Notation, + v3) `nodes`/`edges`/`completeness` projection alongside the existing + `claims`/evidence model — each claim becomes a goal+strategy node pair, + each present evidence file becomes a solution node; a claim with no + evidence counts toward `completeness.undeveloped` rather than + fabricating a solution. +- **`sas/sas.go`**: adds `checklist`/`summary` (spec §9.3) alongside the + existing three-state `activities` model. +- **`sci/sci.go`**: adds `artifacts[]` (`file`/`hash`/`version`, spec + §9.3) alongside the existing tool+artefact+component `items[]`. +- Regenerated `fmea.json`, `tara.json`, `safety-case.json` against the + new code (all three were badly stale — pre-dating even the prior schema + by several tool generations). `fmea.csv`/`tara.md`/`safety-case.md`/ + `.mermaid` are legacy static files with no current code path producing + them; left untouched. +- 7 new requirements registered (`REQ-FO-CORE008`, `REQ-FO-HARA005`, + `REQ-FO-FMEA006`, `REQ-FO-TARA006`, `REQ-FO-SC006`, `REQ-FO-SAS006`, + `REQ-FO-SCI005`). + ## [1.139.0] — 2026-07-28 ### docs: x-FuSa spec v1.14.0 — detection heuristics, attestation, traceability MUSTs, coverage metrics diff --git a/canonjson.go b/canonjson.go new file mode 100644 index 0000000..73b1369 --- /dev/null +++ b/canonjson.go @@ -0,0 +1,107 @@ +package fusaops + +import ( + "bytes" + "encoding/json" + "fmt" + "sort" + "strconv" +) + +// Canonicalize re-serializes JSON data per a practical subset of RFC 8785 +// (the JSON Canonicalization Scheme): object keys sorted, no insignificant +// whitespace, numbers in shortest round-trip form. It targets the JSON +// shapes this codebase emits (structs marshaled via encoding/json) rather +// than adversarial input — object keys are sorted by Go's default string +// comparison, which matches RFC 8785's UTF-16 code-unit ordering except for +// keys containing supplementary-plane codepoints, which do not occur in any +// x-FuSa document. +// +//fusa:req REQ-FO-CORE008 +func Canonicalize(data []byte) ([]byte, error) { + dec := json.NewDecoder(bytes.NewReader(data)) + dec.UseNumber() + var v interface{} + if err := dec.Decode(&v); err != nil { + return nil, fmt.Errorf("canonjson: decode: %w", err) + } + var buf bytes.Buffer + if err := writeCanonical(&buf, v); err != nil { + return nil, err + } + return buf.Bytes(), nil +} + +func writeCanonical(buf *bytes.Buffer, v interface{}) error { + switch x := v.(type) { + case nil: + buf.WriteString("null") + case bool: + if x { + buf.WriteString("true") + } else { + buf.WriteString("false") + } + case json.Number: + buf.WriteString(canonicalNumber(x)) + case string: + buf.Write(canonicalString(x)) + case []interface{}: + buf.WriteByte('[') + for i, e := range x { + if i > 0 { + buf.WriteByte(',') + } + if err := writeCanonical(buf, e); err != nil { + return err + } + } + buf.WriteByte(']') + case map[string]interface{}: + keys := make([]string, 0, len(x)) + for k := range x { + keys = append(keys, k) + } + sort.Strings(keys) + buf.WriteByte('{') + for i, k := range keys { + if i > 0 { + buf.WriteByte(',') + } + buf.Write(canonicalString(k)) + buf.WriteByte(':') + if err := writeCanonical(buf, x[k]); err != nil { + return err + } + } + buf.WriteByte('}') + default: + return fmt.Errorf("canonjson: unsupported type %T", v) + } + return nil +} + +// canonicalNumber formats n in the shortest round-trip form: integers with +// no decimal point or exponent, everything else via Go's shortest-round-trip +// float formatting. +func canonicalNumber(n json.Number) string { + if i, err := n.Int64(); err == nil { + return strconv.FormatInt(i, 10) + } + f, err := n.Float64() + if err != nil { + return n.String() + } + return strconv.FormatFloat(f, 'g', -1, 64) +} + +// canonicalString encodes s as a JSON string with HTML-escaping disabled, so +// the output matches standard JSON string escaping rather than Go's +// web-safe default (which would otherwise escape '<', '>', '&'). +func canonicalString(s string) []byte { + var buf bytes.Buffer + enc := json.NewEncoder(&buf) + enc.SetEscapeHTML(false) + _ = enc.Encode(s) + return bytes.TrimSuffix(buf.Bytes(), []byte("\n")) +} diff --git a/canonjson_test.go b/canonjson_test.go new file mode 100644 index 0000000..61d5959 --- /dev/null +++ b/canonjson_test.go @@ -0,0 +1,112 @@ +package fusaops + +import ( + "bytes" + "testing" +) + +//fusa:test REQ-FO-CORE008 +func TestCanonicalizeSortsKeys(t *testing.T) { + got, err := Canonicalize([]byte(`{"b":1,"a":2,"c":{"z":1,"y":2}}`)) + if err != nil { + t.Fatalf("Canonicalize: %v", err) + } + want := `{"a":2,"b":1,"c":{"y":2,"z":1}}` + if string(got) != want { + t.Errorf("Canonicalize() = %s, want %s", got, want) + } +} + +//fusa:test REQ-FO-CORE008 +func TestCanonicalizeStripsWhitespace(t *testing.T) { + got, err := Canonicalize([]byte(`{ + "a" : [1, 2, 3], + "b" : "hello world" + }`)) + if err != nil { + t.Fatalf("Canonicalize: %v", err) + } + want := `{"a":[1,2,3],"b":"hello world"}` + if string(got) != want { + t.Errorf("Canonicalize() = %s, want %s", got, want) + } +} + +//fusa:test REQ-FO-CORE008 +func TestCanonicalizeIsIdempotentAcrossKeyOrder(t *testing.T) { + a, err := Canonicalize([]byte(`{"x":1,"y":2}`)) + if err != nil { + t.Fatalf("Canonicalize: %v", err) + } + b, err := Canonicalize([]byte(`{"y":2,"x":1}`)) + if err != nil { + t.Fatalf("Canonicalize: %v", err) + } + if string(a) != string(b) { + t.Errorf("Canonicalize order dependence: %s != %s", a, b) + } +} + +//fusa:test REQ-FO-CORE008 +func TestCanonicalizeNoHTMLEscaping(t *testing.T) { + got, err := Canonicalize([]byte(`{"a":"&\"quote\""}`)) + if err != nil { + t.Fatalf("Canonicalize: %v", err) + } + want := `{"a":"&\"quote\""}` + if string(got) != want { + t.Errorf("Canonicalize() = %s, want %s", got, want) + } +} + +//fusa:test REQ-FO-CORE008 +func TestCanonicalizeInvalidJSON(t *testing.T) { + if _, err := Canonicalize([]byte(`not json`)); err == nil { + t.Error("expected error for invalid JSON") + } +} + +//fusa:test REQ-FO-CORE008 +func TestCanonicalizeIntegersNoDecimal(t *testing.T) { + got, err := Canonicalize([]byte(`{"n":42}`)) + if err != nil { + t.Fatalf("Canonicalize: %v", err) + } + if string(got) != `{"n":42}` { + t.Errorf("Canonicalize() = %s, want integer with no decimal point", got) + } +} + +// TestCanonicalizeFloat covers canonicalNumber's non-integer branch +// (strconv.FormatFloat), not exercised by the integer-only cases above. +// +//fusa:test REQ-FO-CORE008 +func TestCanonicalizeFloat(t *testing.T) { + got, err := Canonicalize([]byte(`{"pct":96.3}`)) + if err != nil { + t.Fatalf("Canonicalize: %v", err) + } + if string(got) != `{"pct":96.3}` { + t.Errorf("Canonicalize() = %s, want {\"pct\":96.3}", got) + } +} + +// TestCanonicalizeNestedArrayError covers writeCanonical's error-propagation +// path when an element deep inside an array/object cannot be canonicalized. +// +//fusa:test REQ-FO-CORE008 +func TestCanonicalizeNestedArrayError(t *testing.T) { + var buf bytes.Buffer + // A Go channel has no JSON representation, so writeCanonical must return + // an error rather than panic or silently drop the value. + err := writeCanonical(&buf, []interface{}{make(chan int)}) + if err == nil { + t.Error("expected error for unsupported nested type, got nil") + } + + buf.Reset() + err = writeCanonical(&buf, map[string]interface{}{"k": make(chan int)}) + if err == nil { + t.Error("expected error for unsupported map-value type, got nil") + } +} diff --git a/cmd/fusaops/cmd_hara.go b/cmd/fusaops/cmd_hara.go index f16fbfb..f922d30 100644 --- a/cmd/fusaops/cmd_hara.go +++ b/cmd/fusaops/cmd_hara.go @@ -120,36 +120,16 @@ func runHaraInit(args []string, projectRoot string, stdout, stderr io.Writer) in name = filepath.Base(projectRoot) } + // Per x-FuSa spec §1.2.5/§1.6 rule 1: a scaffold MUST emit empty + // collections, never a dummy row — placeholder text asserts a false + // completeness that a real "empty, honestly incomplete" state does not. h := &hara.HARA{ - Project: name, - Standard: *standard, - CreatedAt: time.Now().UTC(), - Situations: []hara.OperationalSituation{ - {ID: "OS-001", Description: "Normal operation"}, - }, - Hazards: []hara.Hazard{ - { - ID: "H-001", - Description: "Example hazard — replace with project-specific hazard", - Situations: []string{"OS-001"}, - Risk: hara.RiskRating{ - Severity: hara.SeverityS2, - Exposure: hara.ExposureE3, - Controllability: hara.ControllabilityC2, - ASIL: hara.DetermineASIL(hara.SeverityS2, hara.ExposureE3, hara.ControllabilityC2), - }, - SafetyGoals: []string{"SG-001"}, - }, - }, - SafetyGoals: []hara.SafetyGoal{ - { - ID: "SG-001", - Description: "Example safety goal — replace with project-specific goal", - HazardIDs: []string{"H-001"}, - ASIL: hara.DetermineASIL(hara.SeverityS2, hara.ExposureE3, hara.ControllabilityC2), - SafeState: "safe state description", - }, - }, + Project: name, + Standard: *standard, + CreatedAt: time.Now().UTC(), + Situations: []hara.OperationalSituation{}, + Hazards: []hara.Hazard{}, + SafetyGoals: []hara.SafetyGoal{}, } if err := hara.Save(path, h); err != nil { diff --git a/cmd/fusaops/cmd_v147_v150_test.go b/cmd/fusaops/cmd_v147_v150_test.go index 7abbb58..b5942e0 100644 --- a/cmd/fusaops/cmd_v147_v150_test.go +++ b/cmd/fusaops/cmd_v147_v150_test.go @@ -532,7 +532,7 @@ func TestTARAJSON(t *testing.T) { if code > 1 { t.Fatalf("tara json: unexpected exit code %d", code) } - if !strings.Contains(out, `"scenarios"`) || !strings.Contains(out, `"riskLevel"`) { + if !strings.Contains(out, `"threats"`) || !strings.Contains(out, `"riskLevel"`) { t.Errorf("tara json missing expected fields: %q", out[:min(len(out), 200)]) } } @@ -589,7 +589,7 @@ func TestFMEAJSON(t *testing.T) { if code > 1 { t.Fatalf("fmea json: unexpected exit code %d", code) } - if !strings.Contains(out, `"failureModes"`) || !strings.Contains(out, `"rpn"`) { + if !strings.Contains(out, `"entries"`) || !strings.Contains(out, `"rpn"`) { t.Errorf("fmea json missing expected fields: %q", out[:min(len(out), 200)]) } } diff --git a/docs/commands/fmea.md b/docs/commands/fmea.md index 1b72fb3..9b7ebae 100644 --- a/docs/commands/fmea.md +++ b/docs/commands/fmea.md @@ -33,3 +33,12 @@ fusaops fmea --format json --output fmea.json Serves IEC 61508 / ISO 26262 Part 8 dFMEA objectives for the orchestration tool itself. + +## x-FuSa spec conformance + +The JSON shape follows x-FuSa spec §9.2's `fmea` schema: failure modes are +under `entries[]` (each carrying `failureMode`/`item`/`actionPriority`/ +`requirementIds`), and `ratingScale`/`summary` are top-level fields. This +package's 8 entries are a fixed, curated review of key architectural risk +areas in the FuSaOps orchestration pipeline, not an exhaustive per-function +scan. diff --git a/docs/commands/safety-case.md b/docs/commands/safety-case.md index e6bddc7..2c66a5e 100644 --- a/docs/commands/safety-case.md +++ b/docs/commands/safety-case.md @@ -31,7 +31,13 @@ fusaops safety-case [--dir ] [--output ] [--format text|json] - Looks for each claim's required evidence file (e.g. `.fusaops-evidence.json` for `verify`, `sbom.json` for the SBOM claim) under the project root. -- The generated report includes a SHA-256 integrity hash. +- The generated report includes a SHA-256 integrity hash (`sha256:`-prefixed). +- Alongside `claims`/`totalClaims`/`passedClaims`, the JSON also carries a GSN + (Goal Structuring Notation) projection of the same data — `nodes`/`edges`/ + `completeness` — per x-FuSa spec §9.2: each claim becomes a `goal` node + supported by a `strategy` node, itself supported by one `solution` node per + present evidence file. A claim with no present evidence counts toward + `completeness.undeveloped` rather than fabricating a solution. ## Example diff --git a/docs/commands/sas.md b/docs/commands/sas.md index 8ae881a..015eb90 100644 --- a/docs/commands/sas.md +++ b/docs/commands/sas.md @@ -32,3 +32,11 @@ fusaops sas [--dir ] [--output ] [--format text|json] ```bash fusaops sas --level DAL-B --format json --output sas.json ``` + +## x-FuSa spec conformance + +Alongside `activities`/`totalActivities`/`completeActivities` (this package's +three-state complete/incomplete/N-A model), the JSON also carries a +`checklist`/`summary` projection per x-FuSa spec §9.3 — `checklist[].present` +is `true` for both `complete` and `N/A` activities, matching the existing +"counts as done" semantics. diff --git a/docs/commands/tara.md b/docs/commands/tara.md index b4a285f..49715c7 100644 --- a/docs/commands/tara.md +++ b/docs/commands/tara.md @@ -33,3 +33,11 @@ fusaops tara --format json --output tara.json Serves ISO 21434 threat analysis objectives across the FuSaOps orchestration pipeline and the tools it invokes. + +## x-FuSa spec conformance + +The JSON shape follows x-FuSa spec §9.2's `tara` schema: threat scenarios are +under `threats[]` (each carrying `threat`/`attackFeasibility`/`mitigations`), +and `impact` is an SFOP object (`safety`/`financial`/`operational`/`privacy`) +per ISO 21434 Clause 15.7 rather than a single generic severity, since a +threat can rate differently on each axis. diff --git a/fmea.json b/fmea.json index 9bb0e23..f2d3c6a 100644 --- a/fmea.json +++ b/fmea.json @@ -1,800 +1,199 @@ { - "format": "go-FuSa dFMEA v1", - "generated_at": "2026-06-10T14:32:27.912521Z", - "module": "github.com/SoundMatt/FuSaOps", + "generatedAt": "2026-07-28T18:47:06.891193Z", + "projectRoot": ".", + "tool": "fusaops", + "toolVersion": "1.140.0", + "standard": "IEC 61508:2010 / ISO 26262:2018 Part 8-7", + "ratingScale": "custom-1-10", "entries": [ { - "component": "FuSaOps (fusaops)", - "function": "Rank", - "file": "/Users/matt/Documents/Coding/SoundMatt/FuSaOps/fusaops.go", - "failure_modes": [ - "Incorrect output" - ], - "effects": [ - "Incorrect system behavior" - ], - "severity": "high", - "detection_control": "requirement testing + unit tests", - "requirement_ids": [ - "REQ-FO-CORE002" - ] - }, - { - "component": "FuSaOps (fusaops)", - "function": "String", - "file": "/Users/matt/Documents/Coding/SoundMatt/FuSaOps/fusaops.go", - "failure_modes": [ - "Incorrect output" - ], - "effects": [ - "Incorrect system behavior" - ], - "severity": "low", - "detection_control": "unit tests" - }, - { - "component": "FuSaOps (fusaops)", - "function": "String", - "file": "/Users/matt/Documents/Coding/SoundMatt/FuSaOps/fusaops.go", - "failure_modes": [ - "Incorrect output" - ], - "effects": [ - "Incorrect system behavior" - ], - "severity": "low", - "detection_control": "unit tests" - }, - { - "component": "adapter", - "function": "All", - "file": "/Users/matt/Documents/Coding/SoundMatt/FuSaOps/adapter/adapter.go", - "failure_modes": [ - "Incorrect output" - ], - "effects": [ - "Incorrect system behavior" - ], - "severity": "low", - "detection_control": "unit tests" - }, - { - "component": "adapter", - "function": "Applicable", - "file": "/Users/matt/Documents/Coding/SoundMatt/FuSaOps/adapter/adapter.go", - "failure_modes": [ - "Returns unexpected error" - ], - "effects": [ - "Silent failure propagated to caller" - ], - "severity": "high", - "detection_control": "requirement testing + unit tests", - "requirement_ids": [ - "REQ-FO-ADP008" - ] - }, - { - "component": "adapter", - "function": "AuditPack", - "file": "/Users/matt/Documents/Coding/SoundMatt/FuSaOps/adapter/capabilities.go", - "failure_modes": [ - "Returns unexpected error" - ], - "effects": [ - "Silent failure propagated to caller" - ], - "severity": "high", - "detection_control": "requirement testing + unit tests", - "requirement_ids": [ - "REQ-FO-ADP017" - ] - }, - { - "component": "adapter", - "function": "Available", - "file": "/Users/matt/Documents/Coding/SoundMatt/FuSaOps/adapter/adapter.go", - "failure_modes": [ - "Incorrect output" - ], - "effects": [ - "Incorrect system behavior" - ], - "severity": "high", - "detection_control": "requirement testing + unit tests", - "requirement_ids": [ - "REQ-FO-ADP003" - ] - }, - { - "component": "adapter", - "function": "Check", - "file": "/Users/matt/Documents/Coding/SoundMatt/FuSaOps/adapter/adapter.go", - "failure_modes": [ - "Returns unexpected error" - ], - "effects": [ - "Silent failure propagated to caller" - ], - "severity": "high", - "detection_control": "requirement testing + unit tests", - "requirement_ids": [ - "REQ-FO-ADP005" - ] - }, - { - "component": "adapter", - "function": "Detect", - "file": "/Users/matt/Documents/Coding/SoundMatt/FuSaOps/adapter/adapter.go", - "failure_modes": [ - "Returns unexpected error" - ], - "effects": [ - "Silent failure propagated to caller" - ], - "severity": "high", - "detection_control": "requirement testing + unit tests", - "requirement_ids": [ - "REQ-FO-ADP004" - ] - }, - { - "component": "adapter", - "function": "Language", - "file": "/Users/matt/Documents/Coding/SoundMatt/FuSaOps/adapter/adapter.go", - "failure_modes": [ - "Incorrect output" - ], - "effects": [ - "Incorrect system behavior" - ], - "severity": "low", - "detection_control": "unit tests" - }, - { - "component": "adapter", - "function": "MustRegister", - "file": "/Users/matt/Documents/Coding/SoundMatt/FuSaOps/adapter/adapter.go", - "failure_modes": [ - "Incorrect output" - ], - "effects": [ - "Incorrect system behavior" - ], - "severity": "low", - "detection_control": "unit tests" - }, - { - "component": "adapter", - "function": "Name", - "file": "/Users/matt/Documents/Coding/SoundMatt/FuSaOps/adapter/adapter.go", - "failure_modes": [ - "Incorrect output" - ], - "effects": [ - "Incorrect system behavior" - ], - "severity": "low", - "detection_control": "unit tests" - }, - { - "component": "adapter", - "function": "NewRegistry", - "file": "/Users/matt/Documents/Coding/SoundMatt/FuSaOps/adapter/adapter.go", - "failure_modes": [ - "Incorrect output" - ], - "effects": [ - "Incorrect system behavior" - ], - "severity": "low", - "detection_control": "unit tests" - }, - { - "component": "adapter", - "function": "Qualify", - "file": "/Users/matt/Documents/Coding/SoundMatt/FuSaOps/adapter/capabilities.go", - "failure_modes": [ - "Returns unexpected error" - ], - "effects": [ - "Silent failure propagated to caller" - ], - "severity": "high", - "detection_control": "requirement testing + unit tests", - "requirement_ids": [ - "REQ-FO-ADP015" - ] - }, - { - "component": "adapter", - "function": "Register", - "file": "/Users/matt/Documents/Coding/SoundMatt/FuSaOps/adapter/adapter.go", - "failure_modes": [ - "Returns unexpected error" - ], - "effects": [ - "Silent failure propagated to caller" - ], - "severity": "medium", - "detection_control": "unit tests" - }, - { - "component": "adapter", - "function": "SBOM", - "file": "/Users/matt/Documents/Coding/SoundMatt/FuSaOps/adapter/capabilities.go", - "failure_modes": [ - "Returns unexpected error" - ], - "effects": [ - "Silent failure propagated to caller" - ], - "severity": "high", - "detection_control": "requirement testing + unit tests", - "requirement_ids": [ - "REQ-FO-ADP016" - ] - }, - { - "component": "adapter", - "function": "Tool", - "file": "/Users/matt/Documents/Coding/SoundMatt/FuSaOps/adapter/adapter.go", - "failure_modes": [ - "Incorrect output" - ], - "effects": [ - "Incorrect system behavior" - ], - "severity": "low", - "detection_control": "unit tests" - }, - { - "component": "adapter", - "function": "Trace", - "file": "/Users/matt/Documents/Coding/SoundMatt/FuSaOps/adapter/capabilities.go", - "failure_modes": [ - "Returns unexpected error" - ], - "effects": [ - "Silent failure propagated to caller" - ], - "severity": "high", - "detection_control": "requirement testing + unit tests", - "requirement_ids": [ - "REQ-FO-ADP014" - ] - }, - { - "component": "auditpack", - "function": "JSONSource", - "file": "/Users/matt/Documents/Coding/SoundMatt/FuSaOps/auditpack/auditpack.go", - "failure_modes": [ - "Returns unexpected error" - ], - "effects": [ - "Silent failure propagated to caller" - ], - "severity": "high", - "detection_control": "requirement testing + unit tests", - "requirement_ids": [ - "REQ-FO-PCK005" - ] - }, - { - "component": "auditpack", - "function": "Pack", - "file": "/Users/matt/Documents/Coding/SoundMatt/FuSaOps/auditpack/auditpack.go", - "failure_modes": [ - "Returns unexpected error" - ], - "effects": [ - "Silent failure propagated to caller" - ], - "severity": "high", - "detection_control": "requirement testing + unit tests", - "requirement_ids": [ - "REQ-FO-PCK004" - ] - }, - { - "component": "config", - "function": "Default", - "file": "/Users/matt/Documents/Coding/SoundMatt/FuSaOps/config/config.go", - "failure_modes": [ - "Incorrect output" - ], - "effects": [ - "Incorrect system behavior" - ], - "severity": "high", - "detection_control": "requirement testing + unit tests", - "requirement_ids": [ - "REQ-FO-CFG003" - ] - }, - { - "component": "config", - "function": "Load", - "file": "/Users/matt/Documents/Coding/SoundMatt/FuSaOps/config/config.go", - "failure_modes": [ - "Returns unexpected error" - ], - "effects": [ - "Silent failure propagated to caller" - ], - "severity": "high", - "detection_control": "requirement testing + unit tests", - "requirement_ids": [ - "REQ-FO-CFG004" - ] - }, - { - "component": "config", - "function": "Save", - "file": "/Users/matt/Documents/Coding/SoundMatt/FuSaOps/config/config.go", - "failure_modes": [ - "Returns unexpected error", - "Partial write / data corruption" - ], - "effects": [ - "Silent failure propagated to caller", - "Incorrect system state" - ], - "severity": "high", - "detection_control": "requirement testing + unit tests", - "requirement_ids": [ - "REQ-FO-CFG005" - ] - }, - { - "component": "config", - "function": "Validate", - "file": "/Users/matt/Documents/Coding/SoundMatt/FuSaOps/config/config.go", - "failure_modes": [ - "Returns unexpected error" - ], - "effects": [ - "Silent failure propagated to caller" - ], - "severity": "high", - "detection_control": "requirement testing + unit tests", - "requirement_ids": [ - "REQ-FO-CFG006" - ] - }, - { - "component": "orchestrator", - "function": "New", - "file": "/Users/matt/Documents/Coding/SoundMatt/FuSaOps/orchestrator/orchestrator.go", - "failure_modes": [ - "Incorrect output" - ], - "effects": [ - "Incorrect system behavior" - ], - "severity": "low", - "detection_control": "unit tests" - }, - { - "component": "orchestrator", - "function": "Run", - "file": "/Users/matt/Documents/Coding/SoundMatt/FuSaOps/orchestrator/orchestrator.go", - "failure_modes": [ - "Returns unexpected error", - "Uncontrolled execution" - ], - "effects": [ - "Silent failure propagated to caller", - "Resource exhaustion" - ], - "severity": "high", - "detection_control": "requirement testing + unit tests", - "requirement_ids": [ - "REQ-FO-ORC003" - ] - }, - { - "component": "orchestrator", - "function": "RunAuditPack", - "file": "/Users/matt/Documents/Coding/SoundMatt/FuSaOps/orchestrator/rollup.go", - "failure_modes": [ - "Returns unexpected error", - "Uncontrolled execution" - ], - "effects": [ - "Silent failure propagated to caller", - "Resource exhaustion" - ], - "severity": "high", - "detection_control": "requirement testing + unit tests", - "requirement_ids": [ - "REQ-FO-ORC006" - ] - }, - { - "component": "orchestrator", - "function": "RunSBOM", - "file": "/Users/matt/Documents/Coding/SoundMatt/FuSaOps/orchestrator/rollup.go", - "failure_modes": [ - "Returns unexpected error", - "Uncontrolled execution" - ], - "effects": [ - "Silent failure propagated to caller", - "Resource exhaustion" - ], - "severity": "high", - "detection_control": "requirement testing + unit tests", - "requirement_ids": [ - "REQ-FO-ORC005" - ] - }, - { - "component": "orchestrator", - "function": "RunTrace", - "file": "/Users/matt/Documents/Coding/SoundMatt/FuSaOps/orchestrator/rollup.go", - "failure_modes": [ - "Returns unexpected error", - "Uncontrolled execution" - ], - "effects": [ - "Silent failure propagated to caller", - "Resource exhaustion" - ], - "severity": "high", - "detection_control": "requirement testing + unit tests", - "requirement_ids": [ - "REQ-FO-ORC004" - ] - }, - { - "component": "report", - "function": "HasErrors", - "file": "/Users/matt/Documents/Coding/SoundMatt/FuSaOps/report/report.go", - "failure_modes": [ - "Incorrect output" - ], - "effects": [ - "Incorrect system behavior" - ], - "severity": "high", - "detection_control": "requirement testing + unit tests", - "requirement_ids": [ - "REQ-FO-RPT006" - ] - }, - { - "component": "report", - "function": "New", - "file": "/Users/matt/Documents/Coding/SoundMatt/FuSaOps/report/report.go", - "failure_modes": [ - "Incorrect output" - ], - "effects": [ - "Incorrect system behavior" - ], - "severity": "high", - "detection_control": "requirement testing + unit tests", - "requirement_ids": [ - "REQ-FO-RPT005" - ] - }, - { - "component": "report", - "function": "Render", - "file": "/Users/matt/Documents/Coding/SoundMatt/FuSaOps/report/report.go", - "failure_modes": [ - "Returns unexpected error" - ], - "effects": [ - "Silent failure propagated to caller" - ], - "severity": "high", - "detection_control": "requirement testing + unit tests", - "requirement_ids": [ - "REQ-FO-RPT007" - ] - }, - { - "component": "report", - "function": "RenderToFile", - "file": "/Users/matt/Documents/Coding/SoundMatt/FuSaOps/report/report.go", - "failure_modes": [ - "Returns unexpected error" - ], - "effects": [ - "Silent failure propagated to caller" - ], - "severity": "high", - "detection_control": "requirement testing + unit tests", - "requirement_ids": [ - "REQ-FO-RPT008" - ] - }, - { - "component": "report", - "function": "Status", - "file": "/Users/matt/Documents/Coding/SoundMatt/FuSaOps/report/report.go", - "failure_modes": [ - "Incorrect output" - ], - "effects": [ - "Incorrect system behavior" - ], - "severity": "high", - "detection_control": "requirement testing + unit tests", - "requirement_ids": [ - "REQ-FO-RPT002" - ] - }, - { - "component": "report", - "function": "Version", - "file": "/Users/matt/Documents/Coding/SoundMatt/FuSaOps/report/html.go", - "failure_modes": [ - "Incorrect output" - ], - "effects": [ - "Incorrect system behavior" - ], - "severity": "low", - "detection_control": "unit tests" - }, - { - "component": "sbom", - "function": "New", - "file": "/Users/matt/Documents/Coding/SoundMatt/FuSaOps/sbom/sbom.go", - "failure_modes": [ - "Incorrect output" - ], - "effects": [ - "Incorrect system behavior" - ], - "severity": "high", - "detection_control": "requirement testing + unit tests", - "requirement_ids": [ - "REQ-FO-SBM005" - ] - }, - { - "component": "sbom", - "function": "Render", - "file": "/Users/matt/Documents/Coding/SoundMatt/FuSaOps/sbom/render.go", - "failure_modes": [ - "Returns unexpected error" - ], - "effects": [ - "Silent failure propagated to caller" - ], - "severity": "high", - "detection_control": "requirement testing + unit tests", - "requirement_ids": [ - "REQ-FO-SBM006" - ] - }, - { - "component": "sbom", - "function": "RenderToFile", - "file": "/Users/matt/Documents/Coding/SoundMatt/FuSaOps/sbom/render.go", - "failure_modes": [ - "Returns unexpected error" - ], - "effects": [ - "Silent failure propagated to caller" - ], - "severity": "high", - "detection_control": "requirement testing + unit tests", - "requirement_ids": [ - "REQ-FO-SBM006" - ] - }, - { - "component": "scan", - "function": "Languages", - "file": "/Users/matt/Documents/Coding/SoundMatt/FuSaOps/scan/scan.go", - "failure_modes": [ - "Incorrect output" - ], - "effects": [ - "Incorrect system behavior" - ], - "severity": "high", - "detection_control": "requirement testing + unit tests", - "requirement_ids": [ - "REQ-FO-SCAN002" - ] - }, - { - "component": "scan", - "function": "Scan", - "file": "/Users/matt/Documents/Coding/SoundMatt/FuSaOps/scan/scan.go", - "failure_modes": [ - "Returns unexpected error" - ], - "effects": [ - "Silent failure propagated to caller" - ], - "severity": "high", - "detection_control": "requirement testing + unit tests", - "requirement_ids": [ - "REQ-FO-SCAN003" - ] - }, - { - "component": "server", - "function": "Handler", - "file": "/Users/matt/Documents/Coding/SoundMatt/FuSaOps/server/server.go", - "failure_modes": [ - "Incorrect output" - ], - "effects": [ - "Incorrect system behavior" - ], - "severity": "high", - "detection_control": "requirement testing + unit tests", - "requirement_ids": [ - "REQ-FO-SRV004" - ] - }, - { - "component": "server", - "function": "ListenAndServe", - "file": "/Users/matt/Documents/Coding/SoundMatt/FuSaOps/server/server.go", - "failure_modes": [ - "Returns unexpected error" - ], - "effects": [ - "Silent failure propagated to caller" - ], - "severity": "high", - "detection_control": "requirement testing + unit tests", - "requirement_ids": [ - "REQ-FO-SRV005" - ] - }, - { - "component": "server", - "function": "New", - "file": "/Users/matt/Documents/Coding/SoundMatt/FuSaOps/server/server.go", - "failure_modes": [ - "Incorrect output" - ], - "effects": [ - "Incorrect system behavior" - ], - "severity": "high", - "detection_control": "requirement testing + unit tests", - "requirement_ids": [ - "REQ-FO-SRV002" - ] - }, - { - "component": "server", - "function": "Serve", - "file": "/Users/matt/Documents/Coding/SoundMatt/FuSaOps/server/server.go", - "failure_modes": [ - "Returns unexpected error" - ], - "effects": [ - "Silent failure propagated to caller" - ], - "severity": "high", - "detection_control": "requirement testing + unit tests", - "requirement_ids": [ - "REQ-FO-SRV005" - ] - }, - { - "component": "trace", - "function": "HasGaps", - "file": "/Users/matt/Documents/Coding/SoundMatt/FuSaOps/trace/trace.go", - "failure_modes": [ - "Incorrect output" - ], - "effects": [ - "Incorrect system behavior" - ], - "severity": "high", - "detection_control": "requirement testing + unit tests", - "requirement_ids": [ - "REQ-FO-TRC011" - ] - }, - { - "component": "trace", - "function": "New", - "file": "/Users/matt/Documents/Coding/SoundMatt/FuSaOps/trace/trace.go", - "failure_modes": [ - "Incorrect output" - ], - "effects": [ - "Incorrect system behavior" - ], - "severity": "high", - "detection_control": "requirement testing + unit tests", - "requirement_ids": [ - "REQ-FO-TRC010" - ] - }, - { - "component": "trace", - "function": "Render", - "file": "/Users/matt/Documents/Coding/SoundMatt/FuSaOps/trace/render.go", - "failure_modes": [ - "Returns unexpected error" - ], - "effects": [ - "Silent failure propagated to caller" - ], - "severity": "high", - "detection_control": "requirement testing + unit tests", - "requirement_ids": [ - "REQ-FO-TRC012" - ] - }, - { - "component": "trace", - "function": "RenderToFile", - "file": "/Users/matt/Documents/Coding/SoundMatt/FuSaOps/trace/render.go", - "failure_modes": [ - "Returns unexpected error" - ], - "effects": [ - "Silent failure propagated to caller" - ], - "severity": "high", - "detection_control": "requirement testing + unit tests", - "requirement_ids": [ - "REQ-FO-TRC012" - ] - }, - { - "component": "trace", - "function": "Status", - "file": "/Users/matt/Documents/Coding/SoundMatt/FuSaOps/trace/trace.go", - "failure_modes": [ - "Incorrect output" - ], - "effects": [ - "Incorrect system behavior" - ], - "severity": "high", - "detection_control": "requirement testing + unit tests", - "requirement_ids": [ - "REQ-FO-TRC011" - ] - }, - { - "component": "trace", - "function": "TestedPct", - "file": "/Users/matt/Documents/Coding/SoundMatt/FuSaOps/trace/trace.go", - "failure_modes": [ - "Incorrect output" - ], - "effects": [ - "Incorrect system behavior" - ], - "severity": "high", - "detection_control": "requirement testing + unit tests", - "requirement_ids": [ - "REQ-FO-TRC007" - ] - }, - { - "component": "trace", - "function": "TracedPct", - "file": "/Users/matt/Documents/Coding/SoundMatt/FuSaOps/trace/trace.go", - "failure_modes": [ - "Incorrect output" - ], - "effects": [ - "Incorrect system behavior" - ], - "severity": "high", - "detection_control": "requirement testing + unit tests", - "requirement_ids": [ - "REQ-FO-TRC007" + "id": "FM-001", + "component": "Adapter Registry", + "function": "Execute language-specific safety analysis", + "item": "Adapter Registry.Execute language-specific safety analysis", + "failureMode": "Adapter binary not found on PATH", + "effect": "Language component analysis silently skipped; safety defects in that language undetected.", + "cause": "Missing tool installation or PATH misconfiguration in CI environment.", + "severity": 9, + "occurrence": 4, + "detection": 3, + "rpn": 108, + "actionPriority": "high", + "mitigations": [ + "fusaops adapters — lists adapter availability before analysis", + "CI gate fails when a required adapter is absent", + "fusaops sci — inventories tool presence with version and hash" + ], + "action": "Add adapter availability check to the CI gate; fail the build on missing required adapters.", + "requirementIds": [ + "REQ-FO-ADP007" + ] + }, + { + "id": "FM-002", + "component": "cmdAdapter", + "function": "Run x-FuSa tool and collect findings", + "item": "cmdAdapter.Run x-FuSa tool and collect findings", + "failureMode": "Adapter process exits with non-zero code", + "effect": "Findings from that language component dropped from the aggregate report.", + "cause": "Tool bug, memory exhaustion, or malformed source input.", + "severity": 8, + "occurrence": 3, + "detection": 5, + "rpn": 120, + "actionPriority": "high", + "mitigations": [ + "Adapter exit code propagated as orchestrator error", + "Orchestrator records skipped and failed adapters in the aggregate report", + "fusaops qualify — validates adapter health against each subcommand" + ], + "action": "Surface adapter error prominently in aggregate report; treat adapter failure as a gate failure.", + "requirementIds": [ + "REQ-FO-ORC002" + ] + }, + { + "id": "FM-003", + "component": "Report decoder", + "function": "Parse adapter JSON output per x-FuSa spec", + "item": "Report decoder.Parse adapter JSON output per x-FuSa spec", + "failureMode": "Adapter output schema does not conform to x-FuSa spec", + "effect": "Findings silently dropped or aggregate report mis-renders.", + "cause": "Tool version skew between adapter binary and FuSaOps decoder expectations.", + "severity": 9, + "occurrence": 3, + "detection": 4, + "rpn": 108, + "actionPriority": "high", + "mitigations": [ + "fusaops conform — schema conformance checks against the x-FuSa spec", + "fusaops qualify — validates adapter output against each spec section", + "SpecVersion pinning in capabilities response" + ], + "action": "Run fusaops conform in CI after every tool version update; pin tool versions in Dockerfile.", + "requirementIds": [ + "REQ-FO-CNF003" + ] + }, + { + "id": "FM-004", + "component": "trace package", + "function": "Cross-language requirement traceability", + "item": "trace package.Cross-language requirement traceability", + "failureMode": "Source annotation removed or misspelled", + "effect": "Requirement appears untraceable; false coverage gap reported.", + "cause": "Developer removes or typos a //fusa:req annotation during refactoring.", + "severity": 7, + "occurrence": 5, + "detection": 5, + "rpn": 175, + "actionPriority": "medium", + "mitigations": [ + "go-FuSa selfcheck catches annotation errors in Go source", + "CI gate fails on untraced requirements", + "fusaops trace report flags coverage gaps" + ], + "action": "Install fusaops hooks pre-commit hook to validate annotation format before every commit.", + "requirementIds": [ + "REQ-FO-TRC002" + ] + }, + { + "id": "FM-005", + "component": "sbom package", + "function": "Merge per-language SBOMs into cross-language SBOM", + "item": "sbom package.Merge per-language SBOMs into cross-language SBOM", + "failureMode": "Language SBOM file missing from merge input", + "effect": "Component not included in final SBOM; unknown dependency risk reaches certification.", + "cause": "Adapter SBOM generation failed silently or output file not written.", + "severity": 6, + "occurrence": 3, + "detection": 4, + "rpn": 72, + "actionPriority": "medium", + "mitigations": [ + "fusaops sbom warns on missing per-language SBOM inputs", + "fusaops sci checks SBOM artefact presence with file hash", + "Audit pack includes all SBOM artefacts for reviewer verification" + ], + "action": "Add SBOM completeness check to the release gate; fail release if any language SBOM is absent.", + "requirementIds": [ + "REQ-FO-SBM004" + ] + }, + { + "id": "FM-006", + "component": "auditpack / sign packages", + "function": "Verify integrity of evidence artefacts", + "item": "auditpack / sign packages.Verify integrity of evidence artefacts", + "failureMode": "Artefact file modified after signing", + "effect": "Tampered evidence passes a manual inspection that does not re-verify the HMAC.", + "cause": "Post-sign file modification (intentional or accidental) or disk corruption.", + "severity": 9, + "occurrence": 2, + "detection": 3, + "rpn": 54, + "actionPriority": "high", + "mitigations": [ + "fusaops sign — HMAC-SHA256 verification step re-verifies before submission", + "fusaops audit-pack — includes hash manifest with all artefact digests", + "Release gate re-verifies all signatures before generating provenance" + ], + "action": "Mandate fusaops sign --verify as the final step of the release pipeline before submission.", + "requirementIds": [ + "REQ-FO-SIGN004" + ] + }, + { + "id": "FM-007", + "component": "cmdAdapter / orchestrator", + "function": "Execute adapter within configured timeout", + "item": "cmdAdapter / orchestrator.Execute adapter within configured timeout", + "failureMode": "Adapter process hangs indefinitely", + "effect": "Analysis pipeline blocked; no findings produced; release delayed or silently skipped.", + "cause": "Deadlock in adapter tool, infinite loop, or unavailable external resource.", + "severity": 7, + "occurrence": 3, + "detection": 6, + "rpn": 126, + "actionPriority": "medium", + "mitigations": [ + "Adapter process killed after configurable per-adapter timeout", + "Orchestrator records the skipped component with reason in the aggregate report", + "CI wall-clock timeout surfaces the failure as a build error" + ], + "action": "Set per-adapter timeout in .fusaops.json; raise alert in report when timeout fires.", + "requirementIds": [ + "REQ-FO-ORC009" + ] + }, + { + "id": "FM-008", + "component": "suppression package", + "function": "Filter findings against the suppression list", + "item": "suppression package.Filter findings against the suppression list", + "failureMode": "Overly broad suppression rule hides new violations", + "effect": "New safety violations match an existing suppression entry and are never reviewed.", + "cause": "Wildcard or unlimited-duration suppression entry in .fusaops-suppress.json.", + "severity": 8, + "occurrence": 4, + "detection": 4, + "rpn": 128, + "actionPriority": "high", + "mitigations": [ + "fusaops suppress requires explicit rule IDs; no glob wildcards", + "PR review required for all changes to .fusaops-suppress.json", + "fusaops qualify audits active suppressions for expiry compliance" + ], + "action": "Enforce suppression expiry dates; alert in CI when expired suppressions are still active.", + "requirementIds": [ + "REQ-FO-SUP001" ] } - ] + ], + "summary": { + "total": 8, + "highPriority": 6 + }, + "hash": "sha256:33fc5ca071aca851d874052cd2c68ebbe925be759aeb130ea85afa10ff52fac2" } diff --git a/fmea/fmea.go b/fmea/fmea.go index 63075b0..7bc114f 100644 --- a/fmea/fmea.go +++ b/fmea/fmea.go @@ -11,7 +11,6 @@ package fmea import ( "crypto/sha256" - "encoding/hex" "encoding/json" "fmt" "io" @@ -32,57 +31,101 @@ const ReportFile = ".fusaops-fmea.json" //fusa:req REQ-FO-FMEA001 const HighRPNThreshold = 100 +// RatingScale identifies the severity/occurrence/detection rating table in +// use. FuSaOps predates AIAG-VDA 2019 adoption and uses its own 1-10 scale, +// so it is named rather than claiming a standard it does not implement. +// +//fusa:req REQ-FO-FMEA006 +const RatingScale = "custom-1-10" + // FailureMode is one row in the FMEA worksheet. // //fusa:req REQ-FO-FMEA001 type FailureMode struct { - ID string `json:"id"` - Component string `json:"component"` - Function string `json:"function"` - Mode string `json:"mode"` - Effect string `json:"effect"` - Cause string `json:"cause"` - Severity int `json:"severity"` // 1 (negligible) – 10 (catastrophic) - Occurrence int `json:"occurrence"` // 1 (improbable) – 10 (frequent) - Detection int `json:"detection"` // 1 (certain) – 10 (undetectable) - RPN int `json:"rpn"` // Severity × Occurrence × Detection - Controls []string `json:"controls"` - Action string `json:"action"` + ID string `json:"id"` + Component string `json:"component"` + Function string `json:"function"` + Item string `json:"item"` // x-FuSa spec §9.2 single-field identity: "Component.Function" + Mode string `json:"failureMode"` + Effect string `json:"effect"` + Cause string `json:"cause"` + Severity int `json:"severity"` // 1 (negligible) – 10 (catastrophic) + Occurrence int `json:"occurrence"` // 1 (improbable) – 10 (frequent) + Detection int `json:"detection"` // 1 (certain) – 10 (undetectable) + RPN int `json:"rpn"` // Severity × Occurrence × Detection + // ActionPriority approximates the AIAG-VDA Handbook's Action Priority + // method (high|medium|low) — see actionPriority() for the (deliberately + // coarse, severity-dominant) bucketing used in place of the full S×O×D + // lookup table that method defines. + ActionPriority string `json:"actionPriority"` + Controls []string `json:"mitigations"` + Action string `json:"action"` + // RequirementIDs links this failure mode back to the FuSaOps requirement(s) + // (in .fusa-reqs.json) whose behaviour the corrective action lives in. + RequirementIDs []string `json:"requirementIds"` +} + +// Summary rolls up the FMEA's totals and analysis-coverage metrics. +// +//fusa:req REQ-FO-FMEA006 +type Summary struct { + Total int `json:"total"` + HighPriority int `json:"highPriority"` } // FMEA is the top-level dFMEA document. // //fusa:req REQ-FO-FMEA001 type FMEA struct { - GeneratedAt time.Time `json:"generatedAt"` - ProjectRoot string `json:"projectRoot"` - Tool string `json:"tool"` - ToolVersion string `json:"toolVersion"` - Standard string `json:"standard"` - FailureModes []FailureMode `json:"failureModes"` - TotalItems int `json:"totalItems"` - HighRPNItems int `json:"highRpnItems"` - Hash string `json:"hash"` + GeneratedAt time.Time `json:"generatedAt"` + ProjectRoot string `json:"projectRoot"` + Tool string `json:"tool"` + ToolVersion string `json:"toolVersion"` + Standard string `json:"standard"` + RatingScale string `json:"ratingScale"` + Entries []FailureMode `json:"entries"` + Summary Summary `json:"summary"` + Hash string `json:"hash"` } // HasHighRPN returns true when any failure mode exceeds HighRPNThreshold. // //fusa:req REQ-FO-FMEA005 -func (f *FMEA) HasHighRPN() bool { return f.HighRPNItems > 0 } +func (f *FMEA) HasHighRPN() bool { return f.Summary.HighPriority > 0 } + +// actionPriority approximates the AIAG-VDA Handbook's Action Priority method +// (high|medium|low), which in the real handbook derives from a full S×O×D +// lookup table. FuSaOps uses a deliberately coarser, severity-dominant +// bucketing — severity ≥8 is always "high" regardless of occurrence/ +// detection, matching AIAG-VDA's own philosophy that a severe failure +// warrants urgent attention even when rare or well-detected. +// +//fusa:req REQ-FO-FMEA006 +func actionPriority(severity int) string { + switch { + case severity >= 8: + return "high" + case severity >= 5: + return "medium" + default: + return "low" + } +} // modeSpec holds the static definition of one failure mode; RPN is computed. type modeSpec struct { - id string - component string - function string - mode string - effect string - cause string - severity int - occurrence int - detection int - controls []string - action string + id string + component string + function string + mode string + effect string + cause string + severity int + occurrence int + detection int + controls []string + action string + requirementIDs []string } // standardModes covers failure modes in the FuSaOps orchestration pipeline @@ -103,7 +146,8 @@ var standardModes = []modeSpec{ "CI gate fails when a required adapter is absent", "fusaops sci — inventories tool presence with version and hash", }, - action: "Add adapter availability check to the CI gate; fail the build on missing required adapters.", + action: "Add adapter availability check to the CI gate; fail the build on missing required adapters.", + requirementIDs: []string{"REQ-FO-ADP007"}, }, { id: "FM-002", @@ -120,7 +164,8 @@ var standardModes = []modeSpec{ "Orchestrator records skipped and failed adapters in the aggregate report", "fusaops qualify — validates adapter health against each subcommand", }, - action: "Surface adapter error prominently in aggregate report; treat adapter failure as a gate failure.", + action: "Surface adapter error prominently in aggregate report; treat adapter failure as a gate failure.", + requirementIDs: []string{"REQ-FO-ORC002"}, }, { id: "FM-003", @@ -137,7 +182,8 @@ var standardModes = []modeSpec{ "fusaops qualify — validates adapter output against each spec section", "SpecVersion pinning in capabilities response", }, - action: "Run fusaops conform in CI after every tool version update; pin tool versions in Dockerfile.", + action: "Run fusaops conform in CI after every tool version update; pin tool versions in Dockerfile.", + requirementIDs: []string{"REQ-FO-CNF003"}, }, { id: "FM-004", @@ -154,7 +200,8 @@ var standardModes = []modeSpec{ "CI gate fails on untraced requirements", "fusaops trace report flags coverage gaps", }, - action: "Install fusaops hooks pre-commit hook to validate annotation format before every commit.", + action: "Install fusaops hooks pre-commit hook to validate annotation format before every commit.", + requirementIDs: []string{"REQ-FO-TRC002"}, }, { id: "FM-005", @@ -171,7 +218,8 @@ var standardModes = []modeSpec{ "fusaops sci checks SBOM artefact presence with file hash", "Audit pack includes all SBOM artefacts for reviewer verification", }, - action: "Add SBOM completeness check to the release gate; fail release if any language SBOM is absent.", + action: "Add SBOM completeness check to the release gate; fail release if any language SBOM is absent.", + requirementIDs: []string{"REQ-FO-SBM004"}, }, { id: "FM-006", @@ -188,7 +236,8 @@ var standardModes = []modeSpec{ "fusaops audit-pack — includes hash manifest with all artefact digests", "Release gate re-verifies all signatures before generating provenance", }, - action: "Mandate fusaops sign --verify as the final step of the release pipeline before submission.", + action: "Mandate fusaops sign --verify as the final step of the release pipeline before submission.", + requirementIDs: []string{"REQ-FO-SIGN004"}, }, { id: "FM-007", @@ -205,7 +254,8 @@ var standardModes = []modeSpec{ "Orchestrator records the skipped component with reason in the aggregate report", "CI wall-clock timeout surfaces the failure as a build error", }, - action: "Set per-adapter timeout in .fusaops.json; raise alert in report when timeout fires.", + action: "Set per-adapter timeout in .fusaops.json; raise alert in report when timeout fires.", + requirementIDs: []string{"REQ-FO-ORC009"}, }, { id: "FM-008", @@ -222,7 +272,8 @@ var standardModes = []modeSpec{ "PR review required for all changes to .fusaops-suppress.json", "fusaops qualify audits active suppressions for expiry compliance", }, - action: "Enforce suppression expiry dates; alert in CI when expired suppressions are still active.", + action: "Enforce suppression expiry dates; alert in CI when expired suppressions are still active.", + requirementIDs: []string{"REQ-FO-SUP001"}, }, } @@ -236,28 +287,32 @@ func Build(root string) (*FMEA, error) { Tool: "fusaops", ToolVersion: fusaops.Version, Standard: "IEC 61508:2010 / ISO 26262:2018 Part 8-7", + RatingScale: RatingScale, } for _, spec := range standardModes { rpn := spec.severity * spec.occurrence * spec.detection fm := FailureMode{ - ID: spec.id, - Component: spec.component, - Function: spec.function, - Mode: spec.mode, - Effect: spec.effect, - Cause: spec.cause, - Severity: spec.severity, - Occurrence: spec.occurrence, - Detection: spec.detection, - RPN: rpn, - Controls: spec.controls, - Action: spec.action, + ID: spec.id, + Component: spec.component, + Function: spec.function, + Item: spec.component + "." + spec.function, + Mode: spec.mode, + Effect: spec.effect, + Cause: spec.cause, + Severity: spec.severity, + Occurrence: spec.occurrence, + Detection: spec.detection, + RPN: rpn, + ActionPriority: actionPriority(spec.severity), + Controls: spec.controls, + Action: spec.action, + RequirementIDs: spec.requirementIDs, } - f.FailureModes = append(f.FailureModes, fm) - f.TotalItems++ + f.Entries = append(f.Entries, fm) + f.Summary.Total++ if rpn > HighRPNThreshold { - f.HighRPNItems++ + f.Summary.HighPriority++ } } @@ -272,8 +327,12 @@ func computeHash(f *FMEA) string { if err != nil { return "" } - sum := sha256.Sum256(data) - return hex.EncodeToString(sum[:]) + canon, err := fusaops.Canonicalize(data) + if err != nil { + return "" + } + sum := sha256.Sum256(canon) + return fmt.Sprintf("sha256:%x", sum) } // Save writes the FMEA to path as indented JSON. @@ -333,9 +392,9 @@ func renderText(w io.Writer, f *FMEA) error { fmt.Fprintf(w, "Tool: %s v%s\n", f.Tool, f.ToolVersion) fmt.Fprintf(w, "Generated: %s\n", f.GeneratedAt.Format("2006-01-02T15:04:05Z")) fmt.Fprintf(w, "Items: %d total, %d high-RPN (>%d)\n\n", - f.TotalItems, f.HighRPNItems, HighRPNThreshold) + f.Summary.Total, f.Summary.HighPriority, HighRPNThreshold) - for _, fm := range f.FailureModes { + for _, fm := range f.Entries { priority := " " if fm.RPN > HighRPNThreshold { priority = "!" @@ -361,7 +420,7 @@ func renderText(w io.Writer, f *FMEA) error { } if f.HasHighRPN() { fmt.Fprintf(w, "\nHIGH-RPN ITEMS: %d failure mode(s) require corrective action (RPN > %d).\n", - f.HighRPNItems, HighRPNThreshold) + f.Summary.HighPriority, HighRPNThreshold) } return nil } diff --git a/fmea/fmea_test.go b/fmea/fmea_test.go index a16fcf0..d8c4293 100644 --- a/fmea/fmea_test.go +++ b/fmea/fmea_test.go @@ -31,11 +31,11 @@ func TestBuildReturnsItems(t *testing.T) { if err != nil { t.Fatalf("Build: %v", err) } - if f.TotalItems == 0 { + if f.Summary.Total == 0 { t.Error("Build must return at least one failure mode") } - if len(f.FailureModes) != f.TotalItems { - t.Errorf("len(FailureModes)=%d, TotalItems=%d", len(f.FailureModes), f.TotalItems) + if len(f.Entries) != f.Summary.Total { + t.Errorf("len(Entries)=%d, Summary.Total=%d", len(f.Entries), f.Summary.Total) } } @@ -45,7 +45,7 @@ func TestBuildRPNComputed(t *testing.T) { if err != nil { t.Fatalf("Build: %v", err) } - for _, fm := range f.FailureModes { + for _, fm := range f.Entries { if fm.ID == "" { t.Error("failure mode has empty ID") } @@ -63,6 +63,15 @@ func TestBuildRPNComputed(t *testing.T) { if fm.Action == "" { t.Errorf("%s: action must not be empty", fm.ID) } + if fm.Item == "" { + t.Errorf("%s: item must not be empty", fm.ID) + } + if fm.ActionPriority == "" { + t.Errorf("%s: actionPriority must not be empty", fm.ID) + } + if len(fm.RequirementIDs) == 0 { + t.Errorf("%s: requirementIds must not be empty", fm.ID) + } } } @@ -73,13 +82,13 @@ func TestBuildHighRPNCounter(t *testing.T) { t.Fatalf("Build: %v", err) } var count int - for _, fm := range f.FailureModes { + for _, fm := range f.Entries { if fm.RPN > fmea.HighRPNThreshold { count++ } } - if f.HighRPNItems != count { - t.Errorf("HighRPNItems=%d, counted %d", f.HighRPNItems, count) + if f.Summary.HighPriority != count { + t.Errorf("Summary.HighPriority=%d, counted %d", f.Summary.HighPriority, count) } } @@ -110,6 +119,21 @@ func TestBuildMetadata(t *testing.T) { } } +// TestBuildHashHasAlgoPrefix verifies Hash carries the "sha256:" prefix +// required by x-FuSa spec §2.7 for a field named "hash" (as opposed to a +// field named "sha256", which stays bare hex). +// +//fusa:test REQ-FO-FMEA006 +func TestBuildHashHasAlgoPrefix(t *testing.T) { + f, err := fmea.Build(t.TempDir()) + if err != nil { + t.Fatalf("Build: %v", err) + } + if !strings.HasPrefix(f.Hash, "sha256:") { + t.Errorf("Hash = %q, want sha256: prefix", f.Hash) + } +} + //fusa:test REQ-FO-FMEA002 func TestBuildHashChanges(t *testing.T) { f1, _ := fmea.Build(t.TempDir()) @@ -126,10 +150,10 @@ func TestHasHighRPN(t *testing.T) { if err != nil { t.Fatalf("Build: %v", err) } - want := f.HighRPNItems > 0 + want := f.Summary.HighPriority > 0 if f.HasHighRPN() != want { - t.Errorf("HasHighRPN()=%v, want %v (HighRPNItems=%d)", - f.HasHighRPN(), want, f.HighRPNItems) + t.Errorf("HasHighRPN()=%v, want %v (Summary.HighPriority=%d)", + f.HasHighRPN(), want, f.Summary.HighPriority) } } @@ -151,8 +175,8 @@ func TestSaveLoad(t *testing.T) { if loaded.Hash != f.Hash { t.Errorf("Hash mismatch after round-trip: got %q, want %q", loaded.Hash, f.Hash) } - if loaded.TotalItems != f.TotalItems { - t.Errorf("TotalItems mismatch: got %d, want %d", loaded.TotalItems, f.TotalItems) + if loaded.Summary.Total != f.Summary.Total { + t.Errorf("Summary.Total mismatch: got %d, want %d", loaded.Summary.Total, f.Summary.Total) } } @@ -217,8 +241,8 @@ func TestRenderJSON(t *testing.T) { if err := json.Unmarshal(buf.Bytes(), &got); err != nil { t.Fatalf("JSON unmarshal: %v", err) } - if got.TotalItems != f.TotalItems { - t.Errorf("TotalItems=%d, want %d", got.TotalItems, f.TotalItems) + if got.Summary.Total != f.Summary.Total { + t.Errorf("Summary.Total=%d, want %d", got.Summary.Total, f.Summary.Total) } } @@ -248,7 +272,7 @@ func TestRenderUnknownFormat(t *testing.T) { //fusa:test REQ-FO-FMEA004 func TestRenderTextLowRPN(t *testing.T) { f := &fmea.FMEA{ - FailureModes: []fmea.FailureMode{ + Entries: []fmea.FailureMode{ {ID: "FM-LOW", Component: "comp", Mode: "test", RPN: 10}, }, } diff --git a/fusaops.go b/fusaops.go index 87fd47c..268728e 100644 --- a/fusaops.go +++ b/fusaops.go @@ -20,7 +20,7 @@ import ( ) // Version is the current release of FuSaOps. -const Version = "1.139.0" +const Version = "1.140.0" // SpecVersion is the x-FuSa specification version this release targets. // diff --git a/hara/hara.go b/hara/hara.go index 7747fff..416af29 100644 --- a/hara/hara.go +++ b/hara/hara.go @@ -101,7 +101,13 @@ type SafetyGoal struct { HazardIDs []string `json:"hazards"` ASIL ASIL `json:"asil"` SafeState string `json:"safeState,omitempty"` - FSSRRef string `json:"fssrRef,omitempty"` + // FssrRefs lists the Functional Safety Requirement id(s), from + // .fusa-reqs.json, that decompose this safety goal. Per x-FuSa spec + // §1.2.5, a safety goal MUST have at least one — ISO 26262-8 Clause 6 + // requires a safety goal to decompose into a traceable requirement. + // + //fusa:req REQ-FO-HARA005 + FssrRefs []string `json:"fssrRefs"` } // HARA is the full hazard analysis and risk assessment for a project. @@ -303,6 +309,12 @@ func Validate(h *HARA) []ValidationFinding { Message: fmt.Sprintf("safety goal %s has no ASIL assigned", g.ID), }) } + if len(g.FssrRefs) == 0 { + out = append(out, ValidationFinding{ + SafetyGoalID: g.ID, + Message: fmt.Sprintf("safety goal %s has no fssrRefs — a safety goal MUST decompose into at least one functional safety requirement (x-FuSa spec §1.2.5, ISO 26262-8 Clause 6)", g.ID), + }) + } } return out } diff --git a/hara/hara_test.go b/hara/hara_test.go index c39f602..beac374 100644 --- a/hara/hara_test.go +++ b/hara/hara_test.go @@ -129,7 +129,7 @@ func TestValidateComplete(t *testing.T) { }, }, SafetyGoals: []hara.SafetyGoal{ - {ID: "SG-001", Description: "test goal", ASIL: hara.ASILB}, + {ID: "SG-001", Description: "test goal", ASIL: hara.ASILB, FssrRefs: []string{"REQ-FO-HARA001"}}, }, } findings := hara.Validate(h) @@ -229,6 +229,25 @@ func TestValidateNoASILOnGoal(t *testing.T) { } } +//fusa:test REQ-FO-HARA005 +func TestValidateNoFssrRefsOnGoal(t *testing.T) { + h := &hara.HARA{ + SafetyGoals: []hara.SafetyGoal{ + {ID: "SG-001", Description: "no fssrRefs", ASIL: hara.ASILB}, + }, + } + findings := hara.Validate(h) + found := false + for _, f := range findings { + if strings.Contains(f.Message, "no fssrRefs") { + found = true + } + } + if !found { + t.Error("expected no-fssrRefs finding on safety goal") + } +} + //fusa:test REQ-FO-HARA003 func TestLoadMissing(t *testing.T) { h, err := hara.Load(t.TempDir()) diff --git a/qualify/qualify.go b/qualify/qualify.go index 25f358a..4f10d88 100644 --- a/qualify/qualify.go +++ b/qualify/qualify.go @@ -294,6 +294,10 @@ func renderText(w io.Writer, r *Report) error { return nil } +// computeHash canonicalizes the same fields as before (everything except +// Hash itself) via fusaops.Canonicalize, so the hash is genuine RFC 8785 +// sorted-key canonical JSON (§6) rather than relying on Go's fixed struct +// field order to be deterministic. func computeHash(r *Report) string { data, _ := json.Marshal(struct { GeneratedAt time.Time `json:"generatedAt"` @@ -314,6 +318,10 @@ func computeHash(r *Report) string { r.ImplementationAuthor, r.IndependentReviewer, r.AchievableASIL, r.Total, r.Passed, r.Failed, r.Components, }) - h := sha256.Sum256(data) + canon, err := fusaops.Canonicalize(data) + if err != nil { + canon = data + } + h := sha256.Sum256(canon) return fmt.Sprintf("sha256:%x", h) } diff --git a/safety-case.json b/safety-case.json index 57e8d08..666719a 100644 --- a/safety-case.json +++ b/safety-case.json @@ -1,94 +1,274 @@ { - "format": "go-FuSa Safety Case v1", - "generatedAt": "2026-06-10T14:31:49.528893Z", - "module": "github.com/SoundMatt/FuSaOps", - "standard": "generic", - "evidence": [ + "generatedAt": "2026-07-28T18:47:06.907424Z", + "projectRoot": ".", + "tool": "fusaops", + "toolVersion": "1.140.0", + "standard": "ISO 26262", + "claims": [ { - "id": "check", - "description": "Coding standard and static analysis checks", - "file": "check-report.json", - "status": "absent", - "detail": "run 'gofusa check --output check-report.json' to generate" + "id": "C-001", + "title": "Tool qualification evidence is available", + "strategy": "Argument by inspection of tool qualification report", + "evidence": [ + { + "title": "Tool qualification report", + "path": "./.fusaops-qualify-report.json", + "status": "missing" + } + ], + "passed": false }, { - "id": "trace", - "description": "Requirements traceability matrix", - "file": ".fusa-reqs.json", - "status": "present", - "detail": "101 requirements" + "id": "C-002", + "title": "Requirements are traced to tests across all language components", + "strategy": "Argument by inspection of cross-language traceability matrix", + "evidence": [ + { + "title": "Traceability matrix (JSON)", + "path": "./.fusaops-trace.json", + "status": "missing" + } + ], + "passed": false }, { - "id": "verify", - "description": "Test evidence bundle", - "file": ".fusa-evidence.json", - "status": "present", - "detail": "105/105 tests passed" + "id": "C-003", + "title": "Test execution evidence is captured", + "strategy": "Argument by inspection of structured test evidence bundle", + "evidence": [ + { + "title": "Test evidence bundle", + "path": "./.fusaops-evidence.json", + "status": "missing" + } + ], + "passed": false }, { - "id": "qualify", - "description": "Tool qualification report", - "file": "qualify-report.json", - "status": "present", - "detail": "44/44 cases passed" + "id": "C-004", + "title": "Software Bill of Materials is complete", + "strategy": "Argument by inspection of cross-language SBOM", + "evidence": [ + { + "title": "Cross-language SBOM", + "path": "./sbom.json", + "status": "present", + "sha256": "17e9172e968bc17d813e3c7960eb40fe89d3327c102da83365258bc2fc7d9388", + "size": 623 + } + ], + "passed": true }, { - "id": "sbom", - "description": "SBOM (SPDX 3.0.1)", - "file": "sbom.json", - "status": "present" + "id": "C-005", + "title": "Build integrity is established", + "strategy": "Argument by inspection of build provenance and artifact manifest", + "evidence": [ + { + "title": "Build provenance", + "path": "./provenance.json", + "status": "present", + "sha256": "b599f32f336abb4f473dbb4bb7a426af815de3c0004ccf345dc70a6cb538c14b", + "size": 283 + }, + { + "title": "Artifact manifest (SHA-256)", + "path": "./artifact-manifest.json", + "status": "present", + "sha256": "bff8d3d64849cecec8173e4f117649b483c2b7d5f2ab3be571e1ab4fea46f6bf", + "size": 453 + } + ], + "passed": true }, { - "id": "provenance", - "description": "Build provenance", - "file": "provenance.json", - "status": "present" + "id": "C-006", + "title": "Problem reports are tracked and resolved", + "strategy": "Argument by inspection of problem report log", + "evidence": [ + { + "title": "Problem report log", + "path": "./.fusaops-problems.json", + "status": "missing" + } + ], + "passed": false + }, + { + "id": "C-007", + "title": "Audit evidence pack is available for review", + "strategy": "Argument by inspection of bundled audit evidence", + "evidence": [ + { + "title": "Audit pack archive", + "path": "./audit-pack.zip", + "status": "present", + "sha256": "490f4b5c57c02ce6c835df93be110e5ce123e0537995ad2b5d8b768abee743e9", + "size": 17730 + } + ], + "passed": true + } + ], + "totalClaims": 7, + "passedClaims": 3, + "nodes": [ + { + "id": "C-001", + "type": "goal", + "text": "Tool qualification evidence is available" + }, + { + "id": "C-001-St", + "type": "strategy", + "text": "Argument by inspection of tool qualification report" + }, + { + "id": "C-002", + "type": "goal", + "text": "Requirements are traced to tests across all language components" + }, + { + "id": "C-002-St", + "type": "strategy", + "text": "Argument by inspection of cross-language traceability matrix" + }, + { + "id": "C-003", + "type": "goal", + "text": "Test execution evidence is captured" + }, + { + "id": "C-003-St", + "type": "strategy", + "text": "Argument by inspection of structured test evidence bundle" + }, + { + "id": "C-004", + "type": "goal", + "text": "Software Bill of Materials is complete" + }, + { + "id": "C-004-St", + "type": "strategy", + "text": "Argument by inspection of cross-language SBOM" + }, + { + "id": "C-004-Sn1", + "type": "solution", + "text": "Cross-language SBOM", + "evidence": "./sbom.json" + }, + { + "id": "C-005", + "type": "goal", + "text": "Build integrity is established" + }, + { + "id": "C-005-St", + "type": "strategy", + "text": "Argument by inspection of build provenance and artifact manifest" + }, + { + "id": "C-005-Sn1", + "type": "solution", + "text": "Build provenance", + "evidence": "./provenance.json" + }, + { + "id": "C-005-Sn2", + "type": "solution", + "text": "Artifact manifest (SHA-256)", + "evidence": "./artifact-manifest.json" + }, + { + "id": "C-006", + "type": "goal", + "text": "Problem reports are tracked and resolved" + }, + { + "id": "C-006-St", + "type": "strategy", + "text": "Argument by inspection of problem report log" + }, + { + "id": "C-007", + "type": "goal", + "text": "Audit evidence pack is available for review" + }, + { + "id": "C-007-St", + "type": "strategy", + "text": "Argument by inspection of bundled audit evidence" + }, + { + "id": "C-007-Sn1", + "type": "solution", + "text": "Audit pack archive", + "evidence": "./audit-pack.zip" } ], - "clauses": [ - { - "standard": "Generic", - "clause": "CS-1", - "title": "Coding standard compliance", - "evidenceIds": [ - "check" - ] - }, - { - "standard": "Generic", - "clause": "CS-2", - "title": "Requirements traceability", - "evidenceIds": [ - "trace" - ] - }, - { - "standard": "Generic", - "clause": "CS-3", - "title": "Test evidence", - "evidenceIds": [ - "verify" - ] - }, - { - "standard": "Generic", - "clause": "CS-4", - "title": "Tool qualification", - "evidenceIds": [ - "qualify" - ] - }, - { - "standard": "Generic", - "clause": "CS-5", - "title": "Release inventory", - "evidenceIds": [ - "sbom", - "provenance" - ] + "edges": [ + { + "from": "C-001", + "to": "C-001-St", + "type": "supportedBy" + }, + { + "from": "C-002", + "to": "C-002-St", + "type": "supportedBy" + }, + { + "from": "C-003", + "to": "C-003-St", + "type": "supportedBy" + }, + { + "from": "C-004", + "to": "C-004-St", + "type": "supportedBy" + }, + { + "from": "C-004-St", + "to": "C-004-Sn1", + "type": "supportedBy" + }, + { + "from": "C-005", + "to": "C-005-St", + "type": "supportedBy" + }, + { + "from": "C-005-St", + "to": "C-005-Sn1", + "type": "supportedBy" + }, + { + "from": "C-005-St", + "to": "C-005-Sn2", + "type": "supportedBy" + }, + { + "from": "C-006", + "to": "C-006-St", + "type": "supportedBy" + }, + { + "from": "C-007", + "to": "C-007-St", + "type": "supportedBy" + }, + { + "from": "C-007-St", + "to": "C-007-Sn1", + "type": "supportedBy" } ], - "gaps": [ - "check" - ] + "completeness": { + "totalGoals": 7, + "goalsWithEvidence": 3, + "undeveloped": 4 + }, + "hash": "sha256:8a624d3327d600e5625d8f8a17c6308f417808b98a8140ba27f70ed6107bf7c7" } diff --git a/safetycase/safetycase.go b/safetycase/safetycase.go index 7db256a..1c945c8 100644 --- a/safetycase/safetycase.go +++ b/safetycase/safetycase.go @@ -72,19 +72,83 @@ type Claim struct { Passed bool `json:"passed"` } -// SafetyCase is the top-level safety argument document. +// GSNNodeType is one of the six node types defined by the GSN Community +// Standard (Assurance Case Working Group, v3, 2021). +// +//fusa:req REQ-FO-SC006 +type GSNNodeType string + +const ( + GSNGoal GSNNodeType = "goal" + GSNStrategy GSNNodeType = "strategy" + GSNSolution GSNNodeType = "solution" + GSNContext GSNNodeType = "context" + GSNAssumption GSNNodeType = "assumption" + GSNJustification GSNNodeType = "justification" +) + +// GSNNode is one node in the GSN argument graph. +// +//fusa:req REQ-FO-SC006 +type GSNNode struct { + ID string `json:"id"` + Type GSNNodeType `json:"type"` + Text string `json:"text"` + Evidence string `json:"evidence,omitempty"` +} + +// GSNEdgeType is one of the two edge types defined by the GSN Community +// Standard: an argument step (supportedBy) or a context/assumption/ +// justification attachment (inContextOf). +// +//fusa:req REQ-FO-SC006 +type GSNEdgeType string + +const ( + GSNSupportedBy GSNEdgeType = "supportedBy" + GSNInContextOf GSNEdgeType = "inContextOf" +) + +// GSNEdge is one edge in the GSN argument graph. +// +//fusa:req REQ-FO-SC006 +type GSNEdge struct { + From string `json:"from"` + To string `json:"to"` + Type GSNEdgeType `json:"type"` +} + +// Completeness rolls up the GSN graph's goal/evidence coverage. +// +//fusa:req REQ-FO-SC006 +type Completeness struct { + TotalGoals int `json:"totalGoals"` + GoalsWithEvidence int `json:"goalsWithEvidence"` + Undeveloped int `json:"undeveloped"` +} + +// SafetyCase is the top-level safety argument document. Claims/EvidenceRef +// remain the primary evidence-resolution model (file-existence + SHA-256); +// Nodes/Edges/Completeness are a GSN (Goal Structuring Notation) projection +// of that same data, per x-FuSa spec §9.2 — each Claim becomes a goal node +// with its strategy text as a strategy node, and each present EvidenceRef +// becomes a solution node, so the GSN graph and the claims are two views of +// one underlying analysis rather than a second, disconnected model. // //fusa:req REQ-FO-SC001 type SafetyCase struct { - GeneratedAt time.Time `json:"generatedAt"` - ProjectRoot string `json:"projectRoot"` - Tool string `json:"tool"` - ToolVersion string `json:"toolVersion"` - Standard Standard `json:"standard"` - Claims []Claim `json:"claims"` - TotalClaims int `json:"totalClaims"` - PassedClaims int `json:"passedClaims"` - Hash string `json:"hash"` + GeneratedAt time.Time `json:"generatedAt"` + ProjectRoot string `json:"projectRoot"` + Tool string `json:"tool"` + ToolVersion string `json:"toolVersion"` + Standard Standard `json:"standard"` + Claims []Claim `json:"claims"` + TotalClaims int `json:"totalClaims"` + PassedClaims int `json:"passedClaims"` + Nodes []GSNNode `json:"nodes"` + Edges []GSNEdge `json:"edges"` + Completeness Completeness `json:"completeness"` + Hash string `json:"hash"` } // HasGaps returns true when at least one claim failed due to missing evidence. @@ -92,6 +156,45 @@ type SafetyCase struct { //fusa:req REQ-FO-SC005 func (s *SafetyCase) HasGaps() bool { return s.PassedClaims < s.TotalClaims } +// buildGSN projects claims into a GSN graph: each claim is a goal node +// supported by a strategy node, itself supported by one solution node per +// present evidence item. A claim with any missing evidence counts toward +// Completeness.Undeveloped rather than fabricating a solution node for +// evidence that doesn't exist. +// +//fusa:req REQ-FO-SC006 +func buildGSN(claims []Claim) ([]GSNNode, []GSNEdge, Completeness) { + var nodes []GSNNode + var edges []GSNEdge + var comp Completeness + + for _, c := range claims { + comp.TotalGoals++ + goalID := c.ID + strategyID := c.ID + "-St" + nodes = append(nodes, GSNNode{ID: goalID, Type: GSNGoal, Text: c.Title}) + nodes = append(nodes, GSNNode{ID: strategyID, Type: GSNStrategy, Text: c.Strategy}) + edges = append(edges, GSNEdge{From: goalID, To: strategyID, Type: GSNSupportedBy}) + + hasEvidence := false + for i, e := range c.Evidence { + if e.Status != StatusPresent { + continue + } + hasEvidence = true + solutionID := fmt.Sprintf("%s-Sn%d", c.ID, i+1) + nodes = append(nodes, GSNNode{ID: solutionID, Type: GSNSolution, Text: e.Title, Evidence: e.Path}) + edges = append(edges, GSNEdge{From: strategyID, To: solutionID, Type: GSNSupportedBy}) + } + if hasEvidence { + comp.GoalsWithEvidence++ + } else { + comp.Undeveloped++ + } + } + return nodes, edges, comp +} + // evidenceSpec defines one expected evidence file. type evidenceSpec struct { title string @@ -235,6 +338,8 @@ func Build(root string, std Standard) (*SafetyCase, error) { } } + sc.Nodes, sc.Edges, sc.Completeness = buildGSN(sc.Claims) + // Compute integrity hash over JSON representation (without Hash field). sc.Hash = computeHash(sc) return sc, nil @@ -247,8 +352,12 @@ func computeHash(sc *SafetyCase) string { if err != nil { return "" } - sum := sha256.Sum256(data) - return hex.EncodeToString(sum[:]) + canon, err := fusaops.Canonicalize(data) + if err != nil { + return "" + } + sum := sha256.Sum256(canon) + return fmt.Sprintf("sha256:%x", sum) } // Save writes the safety case to path as indented JSON. diff --git a/safetycase/safetycase_test.go b/safetycase/safetycase_test.go index c3a0208..a1cae6b 100644 --- a/safetycase/safetycase_test.go +++ b/safetycase/safetycase_test.go @@ -131,6 +131,86 @@ func TestBuildWithEvidence(t *testing.T) { } } +// TestBuildHashHasAlgoPrefix verifies Hash carries the "sha256:" prefix +// required by x-FuSa spec §2.7 for a field named "hash". +// +//fusa:test REQ-FO-SC006 +func TestBuildHashHasAlgoPrefix(t *testing.T) { + sc, err := safetycase.Build(t.TempDir(), safetycase.StandardISO26262) + if err != nil { + t.Fatalf("Build: %v", err) + } + if !strings.HasPrefix(sc.Hash, "sha256:") { + t.Errorf("Hash = %q, want sha256: prefix", sc.Hash) + } +} + +// TestBuildGSNProjection verifies each Claim becomes a goal+strategy node +// pair, present evidence becomes solution nodes, and a claim with no +// evidence at all counts toward Completeness.Undeveloped rather than +// fabricating a solution node. +// +//fusa:test REQ-FO-SC006 +func TestBuildGSNProjection(t *testing.T) { + dir := t.TempDir() + // Give C-001 (qualify report) real evidence so it has >=1 solution node. + if err := os.WriteFile(filepath.Join(dir, ".fusaops-qualify-report.json"), + []byte(`{"components":[]}`), 0o640); err != nil { + t.Fatal(err) + } + sc, err := safetycase.Build(dir, safetycase.StandardISO26262) + if err != nil { + t.Fatalf("Build: %v", err) + } + + if sc.Completeness.TotalGoals != sc.TotalClaims { + t.Errorf("Completeness.TotalGoals=%d, want %d", sc.Completeness.TotalGoals, sc.TotalClaims) + } + if sc.Completeness.GoalsWithEvidence == 0 { + t.Error("expected at least one goal with evidence (C-001)") + } + if sc.Completeness.Undeveloped == 0 { + t.Error("expected at least one undeveloped goal (no other evidence present)") + } + + var goals, strategies, solutions int + for _, n := range sc.Nodes { + switch n.Type { + case safetycase.GSNGoal: + goals++ + case safetycase.GSNStrategy: + strategies++ + case safetycase.GSNSolution: + solutions++ + if n.Evidence == "" { + t.Errorf("solution node %s missing evidence path", n.ID) + } + } + } + if goals != sc.TotalClaims { + t.Errorf("goal node count=%d, want %d", goals, sc.TotalClaims) + } + if strategies != sc.TotalClaims { + t.Errorf("strategy node count=%d, want %d", strategies, sc.TotalClaims) + } + if solutions == 0 { + t.Error("expected at least one solution node for present evidence") + } + + var supportedBy int + for _, e := range sc.Edges { + if e.Type != safetycase.GSNSupportedBy && e.Type != safetycase.GSNInContextOf { + t.Errorf("unexpected edge type %q", e.Type) + } + if e.Type == safetycase.GSNSupportedBy { + supportedBy++ + } + } + if supportedBy != strategies+solutions { + t.Errorf("supportedBy edges=%d, want %d (one goal->strategy plus one strategy->solution)", supportedBy, strategies+solutions) + } +} + //fusa:test REQ-FO-SC002 func TestBuildEvidenceHash(t *testing.T) { dir := t.TempDir() diff --git a/sas/sas.go b/sas/sas.go index 9fde1a3..8100a95 100644 --- a/sas/sas.go +++ b/sas/sas.go @@ -8,7 +8,6 @@ package sas import ( "crypto/sha256" - "encoding/hex" "encoding/json" "fmt" "io" @@ -44,19 +43,44 @@ type Activity struct { Evidence string `json:"evidence,omitempty"` // path of supporting evidence file } -// SAS is the Software Accomplishment Summary document. +// ChecklistItem is x-FuSa spec §9.3's promoted-schema view of one Activity: +// `item`/`clause`/`present`/`evidence` rather than this package's richer +// three-state Activity/ActivityStatus. +// +//fusa:req REQ-FO-SAS006 +type ChecklistItem struct { + Item string `json:"item"` + Clause string `json:"clause"` + Present bool `json:"present"` + Evidence string `json:"evidence,omitempty"` +} + +// Summary rolls up the SAS's checklist totals. +// +//fusa:req REQ-FO-SAS006 +type Summary struct { + Total int `json:"total"` + Present int `json:"present"` +} + +// SAS is the Software Accomplishment Summary document. Activities/ +// ActivityStatus remain the primary three-state (complete/incomplete/N-A) +// model; Checklist/Summary are the boolean-`present` projection required by +// x-FuSa spec §9.3, derived from the same Activities at Build time. // //fusa:req REQ-FO-SAS001 type SAS struct { - GeneratedAt time.Time `json:"generatedAt"` - ProjectRoot string `json:"projectRoot"` - Tool string `json:"tool"` - ToolVersion string `json:"toolVersion"` - SoftwareLevel string `json:"softwareLevel"` // DAL-A through DAL-E - Activities []Activity `json:"activities"` - TotalActivities int `json:"totalActivities"` - CompleteActivities int `json:"completeActivities"` - Hash string `json:"hash"` + GeneratedAt time.Time `json:"generatedAt"` + ProjectRoot string `json:"projectRoot"` + Tool string `json:"tool"` + ToolVersion string `json:"toolVersion"` + SoftwareLevel string `json:"softwareLevel"` // DAL-A through DAL-E + Activities []Activity `json:"activities"` + TotalActivities int `json:"totalActivities"` + CompleteActivities int `json:"completeActivities"` + Checklist []ChecklistItem `json:"checklist"` + Summary Summary `json:"summary"` + Hash string `json:"hash"` } // HasGaps returns true when at least one required activity is incomplete. @@ -64,6 +88,31 @@ type SAS struct { //fusa:req REQ-FO-SAS005 func (s *SAS) HasGaps() bool { return s.CompleteActivities < s.TotalActivities } +// buildChecklist projects activities into the x-FuSa spec §9.3 checklist +// shape: `present` is true for both StatusComplete and StatusNA (an N/A +// activity is not a gap — same semantics Build already uses for +// CompleteActivities), false only for StatusIncomplete. +// +//fusa:req REQ-FO-SAS006 +func buildChecklist(activities []Activity) ([]ChecklistItem, Summary) { + items := make([]ChecklistItem, 0, len(activities)) + var summary Summary + for _, a := range activities { + present := a.Status == StatusComplete || a.Status == StatusNA + items = append(items, ChecklistItem{ + Item: a.Title, + Clause: a.ID, + Present: present, + Evidence: a.Evidence, + }) + summary.Total++ + if present { + summary.Present++ + } + } + return items, summary +} + // activitySpec defines one lifecycle activity and the evidence file that // demonstrates its completion. type activitySpec struct { @@ -131,6 +180,8 @@ func Build(root, softwareLevel string) (*SAS, error) { } } + s.Checklist, s.Summary = buildChecklist(s.Activities) + s.Hash = computeHash(s) return s, nil } @@ -142,8 +193,12 @@ func computeHash(s *SAS) string { if err != nil { return "" } - sum := sha256.Sum256(data) - return hex.EncodeToString(sum[:]) + canon, err := fusaops.Canonicalize(data) + if err != nil { + return "" + } + sum := sha256.Sum256(canon) + return fmt.Sprintf("sha256:%x", sum) } // Save writes the SAS to path as indented JSON. diff --git a/sas/sas_test.go b/sas/sas_test.go index 358d84d..eb23f9b 100644 --- a/sas/sas_test.go +++ b/sas/sas_test.go @@ -118,6 +118,51 @@ func TestBuildWithEvidence(t *testing.T) { } } +// TestBuildHashHasAlgoPrefix verifies Hash carries the "sha256:" prefix +// required by x-FuSa spec §2.7 for a field named "hash". +// +//fusa:test REQ-FO-SAS006 +func TestBuildHashHasAlgoPrefix(t *testing.T) { + s, err := sas.Build(t.TempDir(), "DAL-C") + if err != nil { + t.Fatalf("Build: %v", err) + } + if !strings.HasPrefix(s.Hash, "sha256:") { + t.Errorf("Hash = %q, want sha256: prefix", s.Hash) + } +} + +// TestBuildChecklistProjection verifies Checklist/Summary mirror +// Activities/TotalActivities/CompleteActivities per x-FuSa spec §9.3. +// +//fusa:test REQ-FO-SAS006 +func TestBuildChecklistProjection(t *testing.T) { + dir := t.TempDir() + s, err := sas.Build(dir, "DAL-C") + if err != nil { + t.Fatalf("Build: %v", err) + } + if len(s.Checklist) != len(s.Activities) { + t.Errorf("len(Checklist)=%d, want %d", len(s.Checklist), len(s.Activities)) + } + if s.Summary.Total != s.TotalActivities { + t.Errorf("Summary.Total=%d, want %d", s.Summary.Total, s.TotalActivities) + } + if s.Summary.Present != s.CompleteActivities { + t.Errorf("Summary.Present=%d, want %d", s.Summary.Present, s.CompleteActivities) + } + for i, item := range s.Checklist { + a := s.Activities[i] + if item.Item != a.Title || item.Clause != a.ID || item.Evidence != a.Evidence { + t.Errorf("Checklist[%d] = %+v, want item/clause/evidence from %+v", i, item, a) + } + wantPresent := a.Status == sas.StatusComplete || a.Status == sas.StatusNA + if item.Present != wantPresent { + t.Errorf("Checklist[%d].Present=%v, want %v (status=%s)", i, item.Present, wantPresent, a.Status) + } + } +} + //fusa:test REQ-FO-SAS002 func TestBuildHashChanges(t *testing.T) { dir := t.TempDir() diff --git a/sci/sci.go b/sci/sci.go index 8aad31b..02fe459 100644 --- a/sci/sci.go +++ b/sci/sci.go @@ -51,18 +51,34 @@ type ConfigItem struct { Present bool `json:"present"` } -// SCI is the Software Configuration Index document. +// ArtifactRef is x-FuSa spec §9.3's promoted-schema view of an evidence +// artefact: `file`/`hash`/`version` — a project-relative subset of the +// richer ConfigItem/Items model, covering only KindArtefact entries that +// are actually present (a missing artefact has no hash to report). +// +//fusa:req REQ-FO-SCI005 +type ArtifactRef struct { + File string `json:"file"` + Hash string `json:"hash"` + Version string `json:"version,omitempty"` +} + +// SCI is the Software Configuration Index document. Items/ConfigItem remain +// the primary tool+artefact+component inventory; Artifacts is the +// project-relative file/hash projection required by x-FuSa spec §9.3, +// derived from the same artefact scan at Build time. // //fusa:req REQ-FO-SCI001 type SCI struct { - GeneratedAt time.Time `json:"generatedAt"` - ProjectRoot string `json:"projectRoot"` - Tool string `json:"tool"` - ToolVersion string `json:"toolVersion"` - GoVersion string `json:"goVersion"` - Items []ConfigItem `json:"items"` - TotalItems int `json:"totalItems"` - Hash string `json:"hash"` + GeneratedAt time.Time `json:"generatedAt"` + ProjectRoot string `json:"projectRoot"` + Tool string `json:"tool"` + ToolVersion string `json:"toolVersion"` + GoVersion string `json:"goVersion"` + Items []ConfigItem `json:"items"` + TotalItems int `json:"totalItems"` + Artifacts []ArtifactRef `json:"artifacts"` + Hash string `json:"hash"` } // knownArtefacts lists the standard FuSaOps evidence artefacts. @@ -145,6 +161,9 @@ func Build(root string, adapters []adapter.Adapter) (*SCI, error) { Size: size, } s.Items = append(s.Items, item) + if sum != "" { + s.Artifacts = append(s.Artifacts, ArtifactRef{File: art.file, Hash: "sha256:" + sum}) + } } s.TotalItems = len(s.Items) @@ -159,8 +178,12 @@ func computeHash(s *SCI) string { if err != nil { return "" } - sum := sha256.Sum256(data) - return hex.EncodeToString(sum[:]) + canon, err := fusaops.Canonicalize(data) + if err != nil { + return "" + } + sum := sha256.Sum256(canon) + return fmt.Sprintf("sha256:%x", sum) } // Save writes the SCI to path as indented JSON. diff --git a/sci/sci_test.go b/sci/sci_test.go index 49744c7..8896ade 100644 --- a/sci/sci_test.go +++ b/sci/sci_test.go @@ -161,6 +161,33 @@ func TestBuildWithArtefacts(t *testing.T) { if presentCount < 2 { t.Errorf("expected at least 2 present artefacts, got %d", presentCount) } + if len(s.Artifacts) != presentCount { + t.Errorf("len(Artifacts)=%d, want %d (one per present artefact)", len(s.Artifacts), presentCount) + } + for _, a := range s.Artifacts { + if a.File == "" { + t.Error("Artifacts[].File must not be empty") + } + if !strings.HasPrefix(a.Hash, "sha256:") { + t.Errorf("Artifacts[].Hash = %q, want sha256: prefix", a.Hash) + } + } +} + +// TestBuildHashHasAlgoPrefix verifies the document-level Hash carries the +// "sha256:" prefix required by x-FuSa spec §2.7 for a field named "hash" +// (Items[].sha256 is correctly bare hex — different field name, §2.7's other +// rule). +// +//fusa:test REQ-FO-SCI005 +func TestBuildHashHasAlgoPrefix(t *testing.T) { + s, err := sci.Build(t.TempDir(), nil) + if err != nil { + t.Fatalf("Build: %v", err) + } + if !strings.HasPrefix(s.Hash, "sha256:") { + t.Errorf("Hash = %q, want sha256: prefix", s.Hash) + } } //fusa:test REQ-FO-SCI002 diff --git a/tara.json b/tara.json index 47f1b13..2d12709 100644 --- a/tara.json +++ b/tara.json @@ -1,26 +1,191 @@ { - "format": "go-FuSa TARA v1", - "generated_at": "2026-06-10T14:32:27.98215Z", - "module": "github.com/SoundMatt/FuSaOps", - "entries": [ - { - "id": "TARA-001", - "asset": "adapter.go", - "threat": "Command injection from variable input enables arbitrary command execution", - "stride": [ - "E", - "R" - ], - "cwe": "CWE-78", - "attack_vector": "Network", - "likelihood": "Medium", - "impact": "High", - "security_level": 3, - "current_control": "Use exec.Command with fixed command and sanitised args", - "residual_risk": "Low after remediation", - "cyber_rule_id": "CYBER005", - "source_file": "/Users/matt/Documents/Coding/SoundMatt/FuSaOps/adapter/adapter.go", - "source_line": 138 + "generatedAt": "2026-07-28T18:47:06.900168Z", + "projectRoot": ".", + "tool": "fusaops", + "toolVersion": "1.140.0", + "standard": "ISO 21434:2021 Chapter 9", + "threats": [ + { + "id": "TS-001", + "asset": "Software Bill of Materials (SBOM)", + "threatProperty": "Integrity", + "threat": "Attacker modifies the SBOM to hide malicious components or misrepresent software composition.", + "damageScenario": "False software composition evidence submitted to a certification authority.", + "impact": { + "safety": "critical", + "financial": "major", + "operational": "moderate", + "privacy": "negligible" + }, + "attackPath": "Unauthorized write access to sbom.json or sbom-spdx.json in the release directory.", + "attackFeasibility": "low", + "riskLevel": "high", + "treatment": "mitigate", + "mitigations": [ + "fusaops sign — HMAC artifact signing", + "Read-only CI output directories", + "Hash verification via artifact-manifest" + ] + }, + { + "id": "TS-002", + "asset": "Test Evidence Bundle", + "threatProperty": "Authenticity", + "threat": "Insider substitutes passing test results to conceal known defects.", + "damageScenario": "Defective software approved for use in a safety-critical system.", + "impact": { + "safety": "critical", + "financial": "major", + "operational": "moderate", + "privacy": "negligible" + }, + "attackPath": "Modification of .fusaops-evidence.json before certification review by a developer with repository write access.", + "attackFeasibility": "medium", + "riskLevel": "critical", + "treatment": "mitigate", + "mitigations": [ + "HMAC signing (fusaops sign)", + "CI-only write access to evidence artefacts", + "Audit trail via fusaops audit-pack" + ] + }, + { + "id": "TS-003", + "asset": "Language Adapter Tools (go-FuSa, c-FuSa, cpp-FuSa, etc.)", + "threatProperty": "Integrity", + "threat": "Supply-chain attacker replaces a language adapter binary with a trojaned version that suppresses findings.", + "damageScenario": "Safety defects not detected by the analysis pipeline; defective software ships.", + "impact": { + "safety": "critical", + "financial": "major", + "operational": "major", + "privacy": "negligible" + }, + "attackPath": "Compromise of upstream tool registry, tampered container image, or PATH manipulation in CI.", + "attackFeasibility": "very-low", + "riskLevel": "medium", + "treatment": "mitigate", + "mitigations": [ + "SLSA provenance verification (fusaops slsa)", + "Tool version pinning in Dockerfile", + "Hash verification in Software Configuration Index (fusaops sci)" + ] + }, + { + "id": "TS-004", + "asset": "Aggregate Safety Report", + "threatProperty": "Integrity", + "threat": "Insider or attacker modifies the aggregate report to downgrade or remove findings.", + "damageScenario": "Safety defects concealed from engineering and certification review.", + "impact": { + "safety": "major", + "financial": "moderate", + "operational": "moderate", + "privacy": "negligible" + }, + "attackPath": "Direct file modification of report artefacts before or during review by a developer with local file access.", + "attackFeasibility": "high", + "riskLevel": "high", + "treatment": "mitigate", + "mitigations": [ + "Artifact signing (fusaops sign)", + "Immutable CI artefact storage", + "Hash manifest (artifact-manifest.json)" + ] + }, + { + "id": "TS-005", + "asset": "Signed Release Artifacts", + "threatProperty": "Authenticity", + "threat": "Attacker gains access to the HMAC signing key and creates fraudulent signed artefacts.", + "damageScenario": "Fraudulent signed artefacts accepted as legitimate evidence by a certification body.", + "impact": { + "safety": "critical", + "financial": "major", + "operational": "moderate", + "privacy": "negligible" + }, + "attackPath": "Compromise of signing key stored in CI/CD secrets, developer workstation, or key management system.", + "attackFeasibility": "low", + "riskLevel": "high", + "treatment": "mitigate", + "mitigations": [ + "Hardware security modules for key storage", + "Periodic key rotation", + "Least-privilege access to signing secrets" + ] + }, + { + "id": "TS-006", + "asset": "Requirement Traceability Matrix", + "threatProperty": "Integrity", + "threat": "Attacker modifies or deletes requirement trace links to conceal coverage gaps.", + "damageScenario": "Incomplete requirements coverage not detected; defective design reaches production.", + "impact": { + "safety": "major", + "financial": "moderate", + "operational": "moderate", + "privacy": "negligible" + }, + "attackPath": "Unauthorized commit to the repository or direct modification of .fusaops-trace.json artefact.", + "attackFeasibility": "medium", + "riskLevel": "high", + "treatment": "mitigate", + "mitigations": [ + "PR-gated branch protection with mandatory review", + "Signed commits (DCO)", + "Artefact hash in artifact-manifest.json" + ] + }, + { + "id": "TS-007", + "asset": "FuSaOps Orchestration Pipeline", + "threatProperty": "Availability", + "threat": "Attacker disrupts the CI/CD pipeline preventing safety analysis from running before release.", + "damageScenario": "Release proceeds without required safety analysis; unreviewed defects ship.", + "impact": { + "safety": "moderate", + "financial": "moderate", + "operational": "major", + "privacy": "negligible" + }, + "attackPath": "DoS against CI infrastructure, sabotage of build scripts, or removal of adapter tool binaries.", + "attackFeasibility": "medium", + "riskLevel": "high", + "treatment": "mitigate", + "mitigations": [ + "Required status checks for all pull requests", + "Redundant CI runner configuration", + "Pipeline integrity monitoring" + ] + }, + { + "id": "TS-008", + "asset": "FuSaOps Configuration (.fusaops.json)", + "threatProperty": "Integrity", + "threat": "Attacker modifies configuration to exclude safety-critical components from analysis.", + "damageScenario": "Known-dangerous code paths excluded from safety analysis; findings suppressed globally.", + "impact": { + "safety": "critical", + "financial": "moderate", + "operational": "moderate", + "privacy": "negligible" + }, + "attackPath": "Unauthorized commit modifying .fusaops.json to add scan exclusions or disable adapters.", + "attackFeasibility": "medium", + "riskLevel": "critical", + "treatment": "mitigate", + "mitigations": [ + "PR review gate with code-owner approval", + "Git commit signing", + "Configuration change alerting in CI" + ] } - ] + ], + "summary": { + "total": 8, + "critical": 2, + "high": 5 + }, + "hash": "sha256:2f181b63e0a2dc6df3c80445d3fdac6d4a17f67429c19b6934c7089f709004d5" } diff --git a/tara/tara.go b/tara/tara.go index f13d865..98cefa2 100644 --- a/tara/tara.go +++ b/tara/tara.go @@ -10,7 +10,6 @@ package tara import ( "crypto/sha256" - "encoding/hex" "encoding/json" "fmt" "io" @@ -46,7 +45,7 @@ const ( FeasibilityHigh Feasibility = "high" // Well-resourced attacker with easy access. FeasibilityMedium Feasibility = "medium" // Moderate skill and access required. FeasibilityLow Feasibility = "low" // Significant barriers; specialised access needed. - FeasibilityVeryLow Feasibility = "very_low" // Exceptional attacker capability required. + FeasibilityVeryLow Feasibility = "very-low" // Exceptional attacker capability required. (x-FuSa spec §9.2 enum spelling) ) // RiskLevel is the computed outcome of the Impact × Feasibility risk matrix. @@ -73,6 +72,19 @@ const ( TreatmentAccept TreatmentDecision = "accept" ) +// ImpactRating rates a threat's damage potential across ISO 21434 Clause +// 15.7's four SFOP categories — a threat can rate differently on each axis +// (e.g. high safety impact, negligible privacy impact), which a single +// generic severity cannot represent. +// +//fusa:req REQ-FO-TARA006 +type ImpactRating struct { + Safety Impact `json:"safety"` + Financial Impact `json:"financial"` + Operational Impact `json:"operational"` + Privacy Impact `json:"privacy"` +} + // ThreatScenario is one row in the TARA, covering a single threat/asset pair. // //fusa:req REQ-FO-TARA001 @@ -80,36 +92,77 @@ type ThreatScenario struct { ID string `json:"id"` Asset string `json:"asset"` ThreatProperty string `json:"threatProperty"` - Description string `json:"description"` + Threat string `json:"threat"` DamageScenario string `json:"damageScenario"` - Impact Impact `json:"impact"` + Impact ImpactRating `json:"impact"` AttackPath string `json:"attackPath"` - Feasibility Feasibility `json:"feasibility"` + Feasibility Feasibility `json:"attackFeasibility"` RiskLevel RiskLevel `json:"riskLevel"` Treatment TreatmentDecision `json:"treatment"` - Controls []string `json:"controls"` + Controls []string `json:"mitigations"` + // CWE is the applicable CWE id, when the threat maps to one (SHOULD when + // applicable). Left empty here: FuSaOps' 8 scenarios are supply-chain and + // process-integrity threats, not classic single-CWE vulnerabilities. + CWE string `json:"cwe,omitempty"` +} + +// Summary rolls up the TARA's totals and analysis-coverage metrics. +// +//fusa:req REQ-FO-TARA006 +type Summary struct { + Total int `json:"total"` + Critical int `json:"critical"` + High int `json:"high"` } // TARA is the top-level threat analysis document. // //fusa:req REQ-FO-TARA001 type TARA struct { - GeneratedAt time.Time `json:"generatedAt"` - ProjectRoot string `json:"projectRoot"` - Tool string `json:"tool"` - ToolVersion string `json:"toolVersion"` - Standard string `json:"standard"` - Scenarios []ThreatScenario `json:"scenarios"` - TotalScenarios int `json:"totalScenarios"` - CriticalScenarios int `json:"criticalScenarios"` - HighScenarios int `json:"highScenarios"` - Hash string `json:"hash"` + GeneratedAt time.Time `json:"generatedAt"` + ProjectRoot string `json:"projectRoot"` + Tool string `json:"tool"` + ToolVersion string `json:"toolVersion"` + Standard string `json:"standard"` + Threats []ThreatScenario `json:"threats"` + Summary Summary `json:"summary"` + Hash string `json:"hash"` } // HasCritical returns true when any scenario carries a critical risk level. // //fusa:req REQ-FO-TARA005 -func (t *TARA) HasCritical() bool { return t.CriticalScenarios > 0 } +func (t *TARA) HasCritical() bool { return t.Summary.Critical > 0 } + +// impactRank maps an Impact to a comparable integer so the highest of the +// four SFOP axes can be found. +func impactRank(i Impact) int { + switch i { + case ImpactCritical: + return 3 + case ImpactMajor: + return 2 + case ImpactModerate: + return 1 + default: + return 0 + } +} + +// highestImpact returns the most severe of ir's four SFOP axes, which drives +// risk determination the same way a single-axis Impact used to (ISO 21434 +// Clause 15.7: overall risk considers the worst-case damage scenario). +// +//fusa:req REQ-FO-TARA006 +func highestImpact(ir ImpactRating) Impact { + best := ir.Safety + for _, i := range []Impact{ir.Financial, ir.Operational, ir.Privacy} { + if impactRank(i) > impactRank(best) { + best = i + } + } + return best +} // riskMatrix maps Impact × Feasibility to a RiskLevel per ISO 21434 Table 1. func riskMatrix(imp Impact, feas Feasibility) RiskLevel { @@ -143,14 +196,15 @@ func riskMatrix(imp Impact, feas Feasibility) RiskLevel { } // scenarioSpec defines the static fields of a threat scenario; RiskLevel is -// computed from Impact × Feasibility at Build time. +// computed from the highest of impact's four SFOP axes × feasibility at +// Build time. type scenarioSpec struct { id string asset string property string description string damageScenario string - impact Impact + impact ImpactRating attackPath string feasibility Feasibility treatment TreatmentDecision @@ -166,7 +220,7 @@ var standardScenarios = []scenarioSpec{ property: "Integrity", description: "Attacker modifies the SBOM to hide malicious components or misrepresent software composition.", damageScenario: "False software composition evidence submitted to a certification authority.", - impact: ImpactCritical, + impact: ImpactRating{Safety: ImpactCritical, Financial: ImpactMajor, Operational: ImpactModerate, Privacy: ImpactNegligible}, attackPath: "Unauthorized write access to sbom.json or sbom-spdx.json in the release directory.", feasibility: FeasibilityLow, treatment: TreatmentMitigate, @@ -178,7 +232,7 @@ var standardScenarios = []scenarioSpec{ property: "Authenticity", description: "Insider substitutes passing test results to conceal known defects.", damageScenario: "Defective software approved for use in a safety-critical system.", - impact: ImpactCritical, + impact: ImpactRating{Safety: ImpactCritical, Financial: ImpactMajor, Operational: ImpactModerate, Privacy: ImpactNegligible}, attackPath: "Modification of .fusaops-evidence.json before certification review by a developer with repository write access.", feasibility: FeasibilityMedium, treatment: TreatmentMitigate, @@ -190,7 +244,7 @@ var standardScenarios = []scenarioSpec{ property: "Integrity", description: "Supply-chain attacker replaces a language adapter binary with a trojaned version that suppresses findings.", damageScenario: "Safety defects not detected by the analysis pipeline; defective software ships.", - impact: ImpactCritical, + impact: ImpactRating{Safety: ImpactCritical, Financial: ImpactMajor, Operational: ImpactMajor, Privacy: ImpactNegligible}, attackPath: "Compromise of upstream tool registry, tampered container image, or PATH manipulation in CI.", feasibility: FeasibilityVeryLow, treatment: TreatmentMitigate, @@ -202,7 +256,7 @@ var standardScenarios = []scenarioSpec{ property: "Integrity", description: "Insider or attacker modifies the aggregate report to downgrade or remove findings.", damageScenario: "Safety defects concealed from engineering and certification review.", - impact: ImpactMajor, + impact: ImpactRating{Safety: ImpactMajor, Financial: ImpactModerate, Operational: ImpactModerate, Privacy: ImpactNegligible}, attackPath: "Direct file modification of report artefacts before or during review by a developer with local file access.", feasibility: FeasibilityHigh, treatment: TreatmentMitigate, @@ -214,7 +268,7 @@ var standardScenarios = []scenarioSpec{ property: "Authenticity", description: "Attacker gains access to the HMAC signing key and creates fraudulent signed artefacts.", damageScenario: "Fraudulent signed artefacts accepted as legitimate evidence by a certification body.", - impact: ImpactCritical, + impact: ImpactRating{Safety: ImpactCritical, Financial: ImpactMajor, Operational: ImpactModerate, Privacy: ImpactNegligible}, attackPath: "Compromise of signing key stored in CI/CD secrets, developer workstation, or key management system.", feasibility: FeasibilityLow, treatment: TreatmentMitigate, @@ -226,7 +280,7 @@ var standardScenarios = []scenarioSpec{ property: "Integrity", description: "Attacker modifies or deletes requirement trace links to conceal coverage gaps.", damageScenario: "Incomplete requirements coverage not detected; defective design reaches production.", - impact: ImpactMajor, + impact: ImpactRating{Safety: ImpactMajor, Financial: ImpactModerate, Operational: ImpactModerate, Privacy: ImpactNegligible}, attackPath: "Unauthorized commit to the repository or direct modification of .fusaops-trace.json artefact.", feasibility: FeasibilityMedium, treatment: TreatmentMitigate, @@ -238,7 +292,7 @@ var standardScenarios = []scenarioSpec{ property: "Availability", description: "Attacker disrupts the CI/CD pipeline preventing safety analysis from running before release.", damageScenario: "Release proceeds without required safety analysis; unreviewed defects ship.", - impact: ImpactMajor, + impact: ImpactRating{Safety: ImpactModerate, Financial: ImpactModerate, Operational: ImpactMajor, Privacy: ImpactNegligible}, attackPath: "DoS against CI infrastructure, sabotage of build scripts, or removal of adapter tool binaries.", feasibility: FeasibilityMedium, treatment: TreatmentMitigate, @@ -250,7 +304,7 @@ var standardScenarios = []scenarioSpec{ property: "Integrity", description: "Attacker modifies configuration to exclude safety-critical components from analysis.", damageScenario: "Known-dangerous code paths excluded from safety analysis; findings suppressed globally.", - impact: ImpactMajor, + impact: ImpactRating{Safety: ImpactCritical, Financial: ImpactModerate, Operational: ImpactModerate, Privacy: ImpactNegligible}, attackPath: "Unauthorized commit modifying .fusaops.json to add scan exclusions or disable adapters.", feasibility: FeasibilityMedium, treatment: TreatmentMitigate, @@ -271,12 +325,12 @@ func Build(root string) (*TARA, error) { } for _, spec := range standardScenarios { - risk := riskMatrix(spec.impact, spec.feasibility) + risk := riskMatrix(highestImpact(spec.impact), spec.feasibility) ts := ThreatScenario{ ID: spec.id, Asset: spec.asset, ThreatProperty: spec.property, - Description: spec.description, + Threat: spec.description, DamageScenario: spec.damageScenario, Impact: spec.impact, AttackPath: spec.attackPath, @@ -285,13 +339,13 @@ func Build(root string) (*TARA, error) { Treatment: spec.treatment, Controls: spec.controls, } - t.Scenarios = append(t.Scenarios, ts) - t.TotalScenarios++ + t.Threats = append(t.Threats, ts) + t.Summary.Total++ switch risk { case RiskCritical: - t.CriticalScenarios++ + t.Summary.Critical++ case RiskHigh: - t.HighScenarios++ + t.Summary.High++ } } @@ -306,8 +360,12 @@ func computeHash(t *TARA) string { if err != nil { return "" } - sum := sha256.Sum256(data) - return hex.EncodeToString(sum[:]) + canon, err := fusaops.Canonicalize(data) + if err != nil { + return "" + } + sum := sha256.Sum256(canon) + return fmt.Sprintf("sha256:%x", sum) } // Save writes the TARA to path as indented JSON. @@ -367,12 +425,13 @@ func renderText(w io.Writer, t *TARA) error { fmt.Fprintf(w, "Tool: %s v%s\n", t.Tool, t.ToolVersion) fmt.Fprintf(w, "Generated: %s\n", t.GeneratedAt.Format("2006-01-02T15:04:05Z")) fmt.Fprintf(w, "Scenarios: %d total, %d critical, %d high\n\n", - t.TotalScenarios, t.CriticalScenarios, t.HighScenarios) + t.Summary.Total, t.Summary.Critical, t.Summary.High) - for _, s := range t.Scenarios { + for _, s := range t.Threats { fmt.Fprintf(w, "[%s] %s — %s (%s)\n", s.RiskLevel, s.ID, s.Asset, s.ThreatProperty) - fmt.Fprintf(w, " Impact: %-12s Feasibility: %s\n", s.Impact, s.Feasibility) - fmt.Fprintf(w, " Threat: %s\n", s.Description) + fmt.Fprintf(w, " Impact: safety=%s financial=%s operational=%s privacy=%s Feasibility: %s\n", + s.Impact.Safety, s.Impact.Financial, s.Impact.Operational, s.Impact.Privacy, s.Feasibility) + fmt.Fprintf(w, " Threat: %s\n", s.Threat) fmt.Fprintf(w, " Damage: %s\n", s.DamageScenario) fmt.Fprintf(w, " Treatment: %s\n", s.Treatment) for _, c := range s.Controls { @@ -385,7 +444,7 @@ func renderText(w io.Writer, t *TARA) error { fmt.Fprintf(w, "Integrity: %s\n", t.Hash) } if t.HasCritical() { - fmt.Fprintf(w, "\nCRITICAL RISK: %d scenario(s) require immediate treatment.\n", t.CriticalScenarios) + fmt.Fprintf(w, "\nCRITICAL RISK: %d scenario(s) require immediate treatment.\n", t.Summary.Critical) } return nil } diff --git a/tara/tara_test.go b/tara/tara_test.go index 0df3a42..037f003 100644 --- a/tara/tara_test.go +++ b/tara/tara_test.go @@ -75,11 +75,11 @@ func TestBuildReturnsScenarios(t *testing.T) { if err != nil { t.Fatalf("Build: %v", err) } - if tr.TotalScenarios == 0 { + if tr.Summary.Total == 0 { t.Error("Build must return at least one scenario") } - if len(tr.Scenarios) != tr.TotalScenarios { - t.Errorf("len(Scenarios)=%d, TotalScenarios=%d", len(tr.Scenarios), tr.TotalScenarios) + if len(tr.Threats) != tr.Summary.Total { + t.Errorf("len(Threats)=%d, Summary.Total=%d", len(tr.Threats), tr.Summary.Total) } } @@ -90,7 +90,7 @@ func TestBuildScenarioIDs(t *testing.T) { t.Fatalf("Build: %v", err) } seen := map[string]bool{} - for _, s := range tr.Scenarios { + for _, s := range tr.Threats { if s.ID == "" { t.Error("scenario has empty ID") } @@ -107,6 +107,9 @@ func TestBuildScenarioIDs(t *testing.T) { if len(s.Controls) == 0 { t.Errorf("%s: controls must not be empty", s.ID) } + if s.Impact.Safety == "" || s.Impact.Financial == "" || s.Impact.Operational == "" || s.Impact.Privacy == "" { + t.Errorf("%s: all four SFOP impact axes must be set", s.ID) + } } } @@ -117,7 +120,7 @@ func TestBuildCounters(t *testing.T) { t.Fatalf("Build: %v", err) } var critCount, highCount int - for _, s := range tr.Scenarios { + for _, s := range tr.Threats { switch s.RiskLevel { case tara.RiskCritical: critCount++ @@ -125,11 +128,11 @@ func TestBuildCounters(t *testing.T) { highCount++ } } - if tr.CriticalScenarios != critCount { - t.Errorf("CriticalScenarios=%d, counted %d", tr.CriticalScenarios, critCount) + if tr.Summary.Critical != critCount { + t.Errorf("Summary.Critical=%d, counted %d", tr.Summary.Critical, critCount) } - if tr.HighScenarios != highCount { - t.Errorf("HighScenarios=%d, counted %d", tr.HighScenarios, highCount) + if tr.Summary.High != highCount { + t.Errorf("Summary.High=%d, counted %d", tr.Summary.High, highCount) } } @@ -160,6 +163,20 @@ func TestBuildMetadata(t *testing.T) { } } +// TestBuildHashHasAlgoPrefix verifies Hash carries the "sha256:" prefix +// required by x-FuSa spec §2.7 for a field named "hash". +// +//fusa:test REQ-FO-TARA006 +func TestBuildHashHasAlgoPrefix(t *testing.T) { + tr, err := tara.Build(t.TempDir()) + if err != nil { + t.Fatalf("Build: %v", err) + } + if !strings.HasPrefix(tr.Hash, "sha256:") { + t.Errorf("Hash = %q, want sha256: prefix", tr.Hash) + } +} + //fusa:test REQ-FO-TARA002 func TestBuildHashChanges(t *testing.T) { tr1, _ := tara.Build(t.TempDir()) @@ -177,10 +194,10 @@ func TestHasCritical(t *testing.T) { if err != nil { t.Fatalf("Build: %v", err) } - want := tr.CriticalScenarios > 0 + want := tr.Summary.Critical > 0 if tr.HasCritical() != want { - t.Errorf("HasCritical()=%v, want %v (CriticalScenarios=%d)", - tr.HasCritical(), want, tr.CriticalScenarios) + t.Errorf("HasCritical()=%v, want %v (Summary.Critical=%d)", + tr.HasCritical(), want, tr.Summary.Critical) } } @@ -202,8 +219,8 @@ func TestSaveLoad(t *testing.T) { if loaded.Hash != tr.Hash { t.Errorf("Hash mismatch after round-trip: got %q, want %q", loaded.Hash, tr.Hash) } - if loaded.TotalScenarios != tr.TotalScenarios { - t.Errorf("TotalScenarios mismatch: got %d, want %d", loaded.TotalScenarios, tr.TotalScenarios) + if loaded.Summary.Total != tr.Summary.Total { + t.Errorf("Summary.Total mismatch: got %d, want %d", loaded.Summary.Total, tr.Summary.Total) } } @@ -265,8 +282,8 @@ func TestRenderJSON(t *testing.T) { if err := json.Unmarshal(buf.Bytes(), &got); err != nil { t.Fatalf("JSON unmarshal: %v", err) } - if got.TotalScenarios != tr.TotalScenarios { - t.Errorf("TotalScenarios=%d, want %d", got.TotalScenarios, tr.TotalScenarios) + if got.Summary.Total != tr.Summary.Total { + t.Errorf("Summary.Total=%d, want %d", got.Summary.Total, tr.Summary.Total) } }