diff --git a/.fusa-hara.json b/.fusa-hara.json index 68a6ebe..a897111 100644 --- a/.fusa-hara.json +++ b/.fusa-hara.json @@ -1,6 +1,6 @@ { "project": "go-FuSa", - "standard": "ISO 26262", + "standard": "iso26262", "createdAt": "2026-06-09T00:00:00Z", "operationalSituations": [ { diff --git a/.fusa-reqs.json b/.fusa-reqs.json index a8eae98..8f972a1 100644 --- a/.fusa-reqs.json +++ b/.fusa-reqs.json @@ -2526,6 +2526,18 @@ "text": "gofusa hara show shall support --strict/--require-attestation, escalating an unsuppressed FUSA-STUB002 finding on .fusa-hara.json to exit 1.", "standard": "x-FuSa spec section 1.6.2" }, + { + "id": "REQ-HARA024", + "title": "HARA008 engine rule / ValidateASIL: risk.asil cross-validated against S/E/C", + "text": "ValidateASIL (and the HARA008 engine rule that wraps it) shall flag a hazard whose declared risk.asil disagrees with DetermineASIL(severity, exposure, controllability) per ISO 26262-3:2018 Table 4, skipping hazards with an incomplete S/E/C rating or an unset risk.asil; Validate shall include the same check in hara show's rendered gap list.", + "standard": "x-FuSa spec section 1.2.5" + }, + { + "id": "REQ-HARA025", + "title": "hara.Load normalises a legacy display-string standard value", + "text": "hara.Load shall normalise a legacy display-string standard value (e.g. \"ISO 26262\") onto its x-FuSa spec section 2.4.1 canonical lowercase id (e.g. \"iso26262\"), leaving an already-canonical or unrecognised id unchanged; hara init's default --standard flag value is the canonical id.", + "standard": "x-FuSa spec section 2.4.1" + }, { "id": "REQ-SAS004", "title": "SAS ChecklistItem canonical shape", diff --git a/CHANGELOG.md b/CHANGELOG.md index c33f02d..3d11c72 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -55,6 +55,31 @@ Dates reference the merged commit timestamp. continues to be suppressed only by a valid §1.6.2 attestation, never by disposition. +## v0.42.0 — 2026-07-28 (hara: risk.asil cross-validation + canonical standard id) + +### Fixed +- **HARA008: `risk.asil` is now cross-validated against `DetermineASIL(S,E,C)`** + (x-FuSa spec §1.2.5 MUST — ASIL determination). Previously a hazard's + stored `risk.asil` was accepted verbatim: `DetermineASIL` was only ever + used as a *fallback* for an empty value, so a hand-edited or + copy-pasted hazard could claim any ASIL regardless of its own S/E/C + inputs, with zero findings/gaps from either `gofusa hara show` or + `check`. The new `hara.ValidateASIL` (wrapped by the new engine rule + `HARA008`, and folded into `hara.Validate`'s own gap list so `hara show` + surfaces it directly) flags a hazard whose declared `risk.asil` disagrees + with the ISO 26262-3:2018 Table 4 value for its own severity/exposure/ + controllability — skipping hazards with an incomplete S/E/C rating + (HARA002's job) or no `risk.asil` set yet. +- **`standard` now uses the x-FuSa spec §2.4.1 canonical lowercase id** + (`iso26262`, not `"ISO 26262"`) in `.fusa-hara.json`: `hara init`'s + default `--standard` flag value changed from `"ISO 26262"` to + `"iso26262"`, the repo's own checked-in `.fusa-hara.json` was + normalised, and `hara.Load` now transparently normalises a legacy + display-string value (`"ISO 26262"`, `"IEC 61508"`, …) onto its + canonical id for backward compatibility with hand-authored files + predating this convention — an unrecognised id is still passed through + verbatim, never rejected. + ## v0.36.0 — 2026-07-28 (x-FuSa spec v1.13.0/v1.14.0 — evidence-artifact schema conformance + content-quality baseline) ### Added diff --git a/README.md b/README.md index cee3c64..00b78f5 100644 --- a/README.md +++ b/README.md @@ -285,7 +285,7 @@ docker build -t go-fusa . docker run --rm -v "$(pwd)":/project go-fusa check ``` -Published tags: `latest`, `0.41`, `0.41.0` (and matching semver for every release). +Published tags: `latest`, `0.42`, `0.42.0` (and matching semver for every release). ## Standards coverage diff --git a/cmd/gofusa/cmd_hara.go b/cmd/gofusa/cmd_hara.go index db79eaa..2793228 100644 --- a/cmd/gofusa/cmd_hara.go +++ b/cmd/gofusa/cmd_hara.go @@ -147,7 +147,9 @@ func runHaraInit(args []string, projectRoot string, stdout, stderr io.Writer) in fs := flag.NewFlagSet("gofusa hara init", flag.ContinueOnError) fs.SetOutput(stderr) project := fs.String("project", "", "project name (default: directory name)") - standard := fs.String("standard", "ISO 26262", "safety standard (e.g. 'ISO 26262', 'IEC 61508')") + // x-FuSa spec §2.4.1: standard is a canonical lowercase id, never a + // display string ("iso26262", not "ISO 26262"). + standard := fs.String("standard", "iso26262", "safety standard canonical id (e.g. 'iso26262', 'iec61508')") if code := parseFlags(fs, args); code != 0 { return code } diff --git a/docs/tool-safety-manual.md b/docs/tool-safety-manual.md index abcc553..c7a622e 100644 --- a/docs/tool-safety-manual.md +++ b/docs/tool-safety-manual.md @@ -1,6 +1,6 @@ # go-FuSa Tool Safety Manual -**Version:** 0.41.0 +**Version:** 0.42.0 **Module:** `github.com/SoundMatt/go-FuSa` **License:** Mozilla Public License 2.0 **Standards addressed:** ISO 26262, IEC 61508, ISO 21434, DO-178C @@ -50,7 +50,7 @@ Capabilities: | Data/control coupling report generation | COUP001–003 | `gofusa coupling` | | ISO 26262 Part 6 compliance gap report | ISO26262001 | `gofusa iso26262` | | IEC 61508 Parts 1-3 compliance gap report | IEC61508001 | `gofusa iec61508` | -| Hazard Analysis and Risk Assessment (HARA) | HARA001–005 | `gofusa hara` | +| Hazard Analysis and Risk Assessment (HARA) | HARA001–008 | `gofusa hara` | | Finding disposition log | DISP001 | `gofusa disposition` | | Change impact analysis | — | `gofusa impact` | | Safety metrics trending | — | `gofusa metrics` | diff --git a/fusa.go b/fusa.go index ea676bc..2d928c4 100644 --- a/fusa.go +++ b/fusa.go @@ -23,7 +23,7 @@ import ( ) // Version is the current release of go-FuSa. -const Version = "0.41.0" +const Version = "0.42.0" // SpecVersion is the x-FuSa spec version this release implements. const SpecVersion = "1.14.0" diff --git a/hara/hara.go b/hara/hara.go index d147b36..909f3e0 100644 --- a/hara/hara.go +++ b/hara/hara.go @@ -14,6 +14,7 @@ // - HARA005: max hazard ASIL exceeds project ASIL from .fusa.json // - HARA006: safety goal with no fssrRefs (x-FuSa spec §1.2.5 MUST) // - HARA007: fssrRefs entry dangling (no matching id in .fusa-reqs.json) +// - HARA008: hazard's stored risk.asil disagrees with DetermineASIL(S,E,C) // // Usage: // @@ -136,8 +137,13 @@ type SafetyGoal struct { // //fusa:req REQ-HARA005 type HARA struct { - Project string `json:"project"` - Standard string `json:"standard"` // "ISO 26262" or "IEC 61508" + Project string `json:"project"` + // Standard is the x-FuSa spec §2.4.1 canonical lowercase standard id + // ("iso26262", "iec61508", …) — never a display string. Load normalises + // a legacy display-string value (e.g. "ISO 26262") onto its canonical + // id for backward compatibility with hand-authored files predating this + // convention; see normalizeStandard. + Standard string `json:"standard"` CreatedAt time.Time `json:"createdAt"` Situations []OperationalSituation `json:"operationalSituations"` Hazards []Hazard `json:"hazards"` @@ -326,9 +332,34 @@ func Load(projectRoot string) (*HARA, error) { if err := json.Unmarshal(data, &h); err != nil { return nil, fmt.Errorf("%w: %s: %s", fusa.ErrInvalidConfig, HARAFile, err) } + h.Standard = normalizeStandard(h.Standard) return &h, nil } +// normalizeStandard maps a legacy display-string standard value (e.g. +// "ISO 26262", as written by hara init before this normalisation existed) +// onto the x-FuSa spec §2.4.1 canonical lowercase id (e.g. "iso26262"), for +// backward compatibility with hand-authored/older .fusa-hara.json files. An +// empty value, an id that already looks canonical, or one go-FuSa does not +// recognise is returned unchanged — §2.4.1: an unrecognised id MUST be +// treated verbatim, never rejected. +// +//fusa:req REQ-HARA025 +func normalizeStandard(s string) string { + switch strings.ToLower(strings.Join(strings.Fields(s), " ")) { + case "iso 26262", "iso26262": + return "iso26262" + case "iec 61508", "iec61508": + return "iec61508" + case "iso 21434", "iso21434": + return "iso21434" + case "do-178c", "do 178c", "do178c": + return "do178c" + default: + return s + } +} + // Save writes the HARA to path. // //fusa:req REQ-HARA008 @@ -399,6 +430,10 @@ func Validate(h *HARA) []ValidationFinding { } } + // HARA008: a hazard's declared risk.asil must match its own S/E/C rating + // (x-FuSa spec §1.2.5 MUST — see ValidateASIL doc). + out = append(out, ValidateASIL(h)...) + return out } @@ -557,6 +592,7 @@ func init() { engine.Default.MustRegister(&ruleHARA005{}) engine.Default.MustRegister(&ruleHARA006{}) engine.Default.MustRegister(&ruleHARA007{}) + engine.Default.MustRegister(&ruleHARA008{}) } // HARA001 — no HARA file present. @@ -803,3 +839,61 @@ func (r *ruleHARA007) Run(_ context.Context, projectRoot string, _ *config.Confi } return out, nil } + +// HARA008 — a hazard's stored risk.asil disagrees with the S/E/C-derived ASIL. +type ruleHARA008 struct{} + +func (r *ruleHARA008) ID() string { return "HARA008" } +func (r *ruleHARA008) Description() string { + return "Hazard's declared risk.asil does not match DetermineASIL(severity, exposure, controllability) per ISO 26262-3:2018 Table 4 (x-FuSa spec §1.2.5 MUST)." +} + +// ValidateASIL cross-checks every hazard in h with a complete S/E/C rating +// against DetermineASIL, flagging a hazard whose stored risk.asil disagrees +// with what the table derives. A hazard with an incomplete S/E/C rating is +// skipped here — that gap is HARA002's responsibility, and DetermineASIL +// would otherwise report a misleading "should be QM" for missing inputs +// rather than a genuine table mismatch. +// +//fusa:req REQ-HARA024 +func ValidateASIL(h *HARA) []ValidationFinding { + var out []ValidationFinding + for _, hz := range h.Hazards { + if hz.Risk.ASIL == "" { + continue + } + if hz.Risk.Severity == "" || hz.Risk.Exposure == "" || hz.Risk.Controllability == "" { + continue + } + derived := DetermineASIL(hz.Risk.Severity, hz.Risk.Exposure, hz.Risk.Controllability) + if hz.Risk.ASIL != derived { + out = append(out, ValidationFinding{ + HazardID: hz.ID, + Message: fmt.Sprintf( + "hazard %s declares risk.asil=%s but S=%s E=%s C=%s derives %s per ISO 26262-3 Table 4 (DetermineASIL)", + hz.ID, hz.Risk.ASIL, hz.Risk.Severity, hz.Risk.Exposure, hz.Risk.Controllability, derived, + ), + }) + } + } + return out +} + +//fusa:req REQ-HARA024 +func (r *ruleHARA008) Run(_ context.Context, projectRoot string, _ *config.Config) ([]fusa.Finding, error) { + h, err := Load(projectRoot) + if err != nil || len(h.Hazards) == 0 { + return nil, nil + } + var out []fusa.Finding + for _, f := range ValidateASIL(h) { + out = append(out, fusa.Finding{ + RuleID: r.ID(), + Severity: fusa.SeverityWarning, + Message: f.Message, + Location: fusa.Location{File: HARAFile}, + Remediation: fmt.Sprintf("update risk.asil for hazard %s in %s to match its S/E/C rating, or correct the S/E/C rating if the ASIL is right and the inputs are wrong", f.HazardID, HARAFile), + }) + } + return out, nil +} diff --git a/hara/hara_test.go b/hara/hara_test.go index 4ba1579..19979eb 100644 --- a/hara/hara_test.go +++ b/hara/hara_test.go @@ -601,3 +601,179 @@ func TestHARA005_SilentWhenNoProjectASIL(t *testing.T) { t.Error("HARA005 should not fire when project has no ASIL declared") } } + +// ─── HARA008: risk.asil cross-validated against DetermineASIL(S,E,C) ────────── + +//fusa:test REQ-HARA024 +func TestValidateASIL_MismatchFlagged(t *testing.T) { + h := &hara.HARA{Hazards: []hara.Hazard{{ + ID: "H-001", + Risk: hara.RiskRating{ + Severity: hara.SeverityS1, Exposure: hara.ExposureE1, Controllability: hara.ControllabilityC1, + ASIL: hara.ASILD, // S1×E1×C1 derives QM, not ASIL-D + }, + }}} + findings := hara.ValidateASIL(h) + if len(findings) != 1 { + t.Fatalf("expected 1 mismatch finding, got %d: %+v", len(findings), findings) + } + if findings[0].HazardID != "H-001" { + t.Errorf("HazardID = %q, want H-001", findings[0].HazardID) + } +} + +//fusa:test REQ-HARA024 +func TestValidateASIL_MatchingASILNotFlagged(t *testing.T) { + h := &hara.HARA{Hazards: []hara.Hazard{{ + ID: "H-001", + Risk: hara.RiskRating{ + Severity: hara.SeverityS2, Exposure: hara.ExposureE4, Controllability: hara.ControllabilityC2, + ASIL: hara.ASILC, // S2×E4×C2 derives ASIL-C — matches + }, + }}} + if findings := hara.ValidateASIL(h); len(findings) != 0 { + t.Errorf("expected no findings for a correctly-derived ASIL, got %+v", findings) + } +} + +//fusa:test REQ-HARA024 +func TestValidateASIL_IncompleteSECSkipped(t *testing.T) { + // A hazard with an incomplete S/E/C rating is HARA002's job, not + // HARA008's — DetermineASIL would otherwise report a misleading + // "should be QM" for missing inputs rather than a genuine mismatch. + h := &hara.HARA{Hazards: []hara.Hazard{{ + ID: "H-001", + Risk: hara.RiskRating{Severity: hara.SeverityS2, ASIL: hara.ASILC}, // E/C missing + }}} + if findings := hara.ValidateASIL(h); len(findings) != 0 { + t.Errorf("expected ValidateASIL to skip a hazard with incomplete S/E/C, got %+v", findings) + } +} + +//fusa:test REQ-HARA024 +func TestValidateASIL_EmptyASILSkipped(t *testing.T) { + h := &hara.HARA{Hazards: []hara.Hazard{{ + ID: "H-001", + Risk: hara.RiskRating{ + Severity: hara.SeverityS2, Exposure: hara.ExposureE4, Controllability: hara.ControllabilityC2, + // ASIL left empty — nothing to cross-check yet. + }, + }}} + if findings := hara.ValidateASIL(h); len(findings) != 0 { + t.Errorf("expected no findings when risk.asil is unset, got %+v", findings) + } +} + +func writeHARAWithMismatchedASIL(t *testing.T, dir string) { + t.Helper() + h := &hara.HARA{ + Project: "test", + Standard: "iso26262", + Hazards: []hara.Hazard{{ + ID: "H-001", + Description: "test hazard", + Risk: hara.RiskRating{ + Severity: hara.SeverityS1, Exposure: hara.ExposureE1, Controllability: hara.ControllabilityC1, + ASIL: hara.ASILD, // S1×E1×C1 derives QM — a false ASIL-D claim + }, + SafetyGoals: []string{"SG-001"}, + }}, + SafetyGoals: []hara.SafetyGoal{{ID: "SG-001", Description: "goal", ASIL: hara.ASILD}}, + } + if err := hara.Save(filepath.Join(dir, hara.HARAFile), h); err != nil { + t.Fatal(err) + } +} + +//fusa:test REQ-HARA024 +func TestHARA008_FiresOnMismatchedASIL(t *testing.T) { + dir := t.TempDir() + writeHARAWithMismatchedASIL(t, dir) + if !findingsForRule(t, dir, "HARA008") { + t.Error("HARA008 should fire when a hazard's risk.asil disagrees with its S/E/C-derived ASIL") + } +} + +//fusa:test REQ-HARA024 +func TestHARA008_SilentWhenASILMatches(t *testing.T) { + dir := t.TempDir() + writeHARAWithASIL(t, dir, hara.ASILC) // S2×E4×C2 genuinely derives ASIL-C + if findingsForRule(t, dir, "HARA008") { + t.Error("HARA008 should not fire when risk.asil matches DetermineASIL(S,E,C)") + } +} + +// Validate's rendered "Gaps" section (hara show) should surface an ASIL +// mismatch too, not just the engine rule — see go-FuSa#62. +// +//fusa:test REQ-HARA009 +//fusa:test REQ-HARA024 +func TestValidate_IncludesASILMismatch(t *testing.T) { + h := &hara.HARA{Hazards: []hara.Hazard{{ + ID: "H-001", + Risk: hara.RiskRating{ + Severity: hara.SeverityS1, Exposure: hara.ExposureE1, Controllability: hara.ControllabilityC1, + ASIL: hara.ASILD, + }, + }}} + findings := hara.Validate(h) + found := false + for _, f := range findings { + if f.HazardID == "H-001" { + found = true + } + } + if !found { + t.Errorf("expected Validate to include the ASIL mismatch finding, got %+v", findings) + } +} + +// ─── standard field canonicalisation (x-FuSa spec §2.4.1) ───────────────────── + +//fusa:test REQ-HARA025 +func TestLoad_NormalizesLegacyStandardDisplayString(t *testing.T) { + dir := t.TempDir() + h := &hara.HARA{Project: "p", Standard: "ISO 26262"} + if err := hara.Save(filepath.Join(dir, hara.HARAFile), h); err != nil { + t.Fatal(err) + } + loaded, err := hara.Load(dir) + if err != nil { + t.Fatalf("Load: %v", err) + } + if loaded.Standard != "iso26262" { + t.Errorf("Standard = %q, want canonical id %q", loaded.Standard, "iso26262") + } +} + +//fusa:test REQ-HARA025 +func TestLoad_CanonicalStandardUnchanged(t *testing.T) { + dir := t.TempDir() + h := &hara.HARA{Project: "p", Standard: "iso26262"} + if err := hara.Save(filepath.Join(dir, hara.HARAFile), h); err != nil { + t.Fatal(err) + } + loaded, err := hara.Load(dir) + if err != nil { + t.Fatalf("Load: %v", err) + } + if loaded.Standard != "iso26262" { + t.Errorf("Standard = %q, want unchanged %q", loaded.Standard, "iso26262") + } +} + +//fusa:test REQ-HARA025 +func TestLoad_UnrecognisedStandardPassedThrough(t *testing.T) { + dir := t.TempDir() + h := &hara.HARA{Project: "p", Standard: "some-future-standard"} + if err := hara.Save(filepath.Join(dir, hara.HARAFile), h); err != nil { + t.Fatal(err) + } + loaded, err := hara.Load(dir) + if err != nil { + t.Fatalf("Load: %v", err) + } + if loaded.Standard != "some-future-standard" { + t.Errorf("Standard = %q, want unrecognised id passed through verbatim", loaded.Standard) + } +}