From 92e4eadc1c0f2e1b556c6859846e0e8520f8d3c0 Mon Sep 17 00:00:00 2001 From: SoundMatt Date: Tue, 28 Jul 2026 14:18:16 -0700 Subject: [PATCH] =?UTF-8?q?fix:=20v1.144.0=20=E2=80=94=20FuSaOps=20self-au?= =?UTF-8?q?dit=20against=20spec=20v1.15.0=20(10=20bugs)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A fresh, deliberately skeptical audit of FuSaOps' own hara/tara/ safetycase/sci/fmea artifacts against spec v1.15.0 found 10 real defects the earlier PR1/PR2 conformance work had missed: ASIL never cross-checked against derived S×E×C, hara show --format json exiting 0 on an absent file, wrong TARA JSON field names, absolute paths leaking into safety- case.json/sci.json, non-canonical standard values, a missing FMEA file field, dangling fssrRefs never checked against the requirement registry, missing common header fields, and no coveragePct safety clamp. See CHANGELOG.md for the full breakdown per package. Signed-off-by: SoundMatt --- .fusa-hara.json | 2 +- .fusa-reqs.json | 15 +++++++ CHANGELOG.md | 54 +++++++++++++++++++++++ cmd/fusaops/cmd_extra_coverage_test.go | 33 ++++++++++++++ cmd/fusaops/cmd_hara.go | 18 +++++++- cmd/fusaops/cmd_v147_v150_test.go | 7 ++- docs/x-fusa-spec.md | 5 ++- fmea.json | 17 ++++++-- fmea/fmea.go | 60 ++++++++++++++++++-------- fmea/fmea_internal_test.go | 10 +++++ fusaops.go | 31 ++++++++++++- hara/hara.go | 30 +++++++++++++ safety-case.json | 35 ++++++++------- safetycase/safetycase.go | 57 +++++++++++++----------- safetycase/safetycase_test.go | 16 ++++--- sas/sas.go | 7 +++ sci/sci.go | 44 +++++++++++-------- tara.json | 43 +++++++++--------- tara/tara.go | 49 +++++++++++++-------- tara/tara_riskmatrix_test.go | 10 +++++ 20 files changed, 412 insertions(+), 131 deletions(-) diff --git a/.fusa-hara.json b/.fusa-hara.json index ab99aee..e1be9dc 100644 --- a/.fusa-hara.json +++ b/.fusa-hara.json @@ -1,6 +1,6 @@ { "project": "FuSaOps", - "standard": "ISO 26262", + "standard": "iso26262", "createdAt": "2026-06-09T00:00:00Z", "operationalSituations": [ { diff --git a/.fusa-reqs.json b/.fusa-reqs.json index 9a15a9e..93b0ad3 100644 --- a/.fusa-reqs.json +++ b/.fusa-reqs.json @@ -14,6 +14,13 @@ "priority": "MUST", "rationale": "Enables tooling and CI to verify which spec version FuSaOps implements without parsing docs." }, + { + "id": "REQ-FO-CORE008", + "title": "CanonicalStandardID helper", + "description": "FuSaOps shall export a CanonicalStandardID function mapping a human-readable standard display string to its canonical lowercase id per x-FuSa spec §2.4.1, for use wherever an artifact emits a 'standard' field.", + "priority": "MUST", + "rationale": "x-FuSa spec §2.9: a 'standard' field's value must be identical (and canonical) across every output format; a single shared helper prevents each artifact package from re-deriving this inconsistently." + }, { "id": "REQ-FO-CLI053", "title": "fusaops version --format json", @@ -3055,6 +3062,14 @@ "priority": "MUST", "standard": "ISO 26262-3 Clause 6" }, + { + "id": "REQ-FO-HARA007", + "title": "hara.ValidateFssrRefs cross-checks safety goal fssrRefs against the requirement registry", + "description": "hara.ValidateFssrRefs shall flag any safetyGoals[].fssrRefs entry that does not resolve to an id in .fusa-reqs.json, catching a dangling reference that a non-empty check alone cannot see.", + "priority": "MUST", + "rationale": "x-FuSa spec §1.2.5: a safety goal's fssrRefs must be real, resolvable requirements, not just present.", + "standard": "ISO 26262-8 Clause 6" + }, { "id": "REQ-FO-CLI084", "title": "Quality-gate wiring: runQualityGate + attestationValid + per-command QualFields extraction and attestation carry-forward across fmea/hara/tara/safety-case/sas", diff --git a/CHANGELOG.md b/CHANGELOG.md index 9170e25..d5603a4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,60 @@ and the project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0. ## [Unreleased] +## [1.144.0] — 2026-07-28 + +### fix: FuSaOps' own hara/tara/safetycase/sci/fmea artifacts against spec v1.15.0 — 10 bugs from a fresh self-audit + +The v1.143.0 changelog claimed FuSaOps' artifact packages already met the +spec's carry-forward mechanism — true, but a follow-up self-audit (deliberately +skeptical, same method used against the sibling tools) found 10 other real +defects that PR1/PR2 had missed, proving self-review has the same blind spots +found in every other tool this session: + +- **hara**: `Validate` never cross-checked a hazard's stated `risk.asil` + against `DetermineASIL(S,E,C)` — any ASIL value passed regardless of the + actual S/E/C rating. New `ValidateFssrRefs` also cross-checks every safety + goal's `fssrRefs` against `.fusa-reqs.json`'s real ids, catching a dangling + reference that a non-empty check alone can't see. +- **hara CLI**: `hara show --format json` on a project with no + `.fusa-hara.json` exited 0 and rendered an empty HARA as if the analysis + were complete — now exits 1 (§1.2.5). The text/markdown default format is + intentionally left tolerant, per the same spec text's scoping. +- **tara**: `ThreatScenario` JSON emitted `attackPath`/`riskLevel` instead of + the spec's `attackVector`/`risk` — the SFOP enum vocabulary and the + feasibility×impact combination table were already correct, only these two + field names were wrong. +- **safetycase / sci**: `evidence[].path` / `items[].path` leaked the full + filesystem path (`root + "/" + file`) instead of a project-relative one + (§4 MUST) — the file is still stat'd via its full path, only the recorded + value changed. +- **standard field canonicalization**: new `fusaops.CanonicalStandardID` + helper maps a display string (`"ISO 26262"`) to its §2.4.1 canonical id + (`"iso26262"`) — applied in hara (real MUST per §1.2.5), tara, and + safetycase. fmea's dual-standard citation (`IEC 61508 / ISO 26262 Part + 8-7`) has no single canonical id to map to and is left as a display + string; fmea/tara/safety-case don't actually carry a spec-mandated + `standard` field at all per §9.2's illustrative schemas, so this is a + consistency improvement, not a nonconformance fix, for the two that do + cleanly map. +- **fmea**: `FailureMode` had no `file` field at all, despite it being a + §9.2 MUST — added, populated with each of the 8 entries' real + representative source file. +- **common header**: `fmea`/`tara`/`safety-case`/`sas`/`sci` were all + missing the §3.1 common header (`schemaVersion`/`kind`/`language`) — added + to all five (HARA is explicitly an input file like `.fusa-reqs.json`, not + subject to the header, per §1.2.5's own text). Spec's §3.1 `kind` enum + list extended to include `fmea-report`/`tara-report`/`safety-case`/`sas`/ + `sci`, which were already used in the spec's own §9.2/§9.3 schema blocks + but not in the enum's explicit list. +- **coveragePct clamp**: `coveragePct` in both fmea and tara now clamps to + 100 defensively (§9.2 MUST), with a regression test seeding + analyzed > total directly at the unexported function, since neither + package's real data can currently trigger the bug by construction. + +New `.fusa-reqs.json` entries: `REQ-FO-HARA007` (fssrRefs registry +cross-check), `REQ-FO-CORE008` (`CanonicalStandardID`). + ## [1.143.0] — 2026-07-28 ### docs: x-FuSa spec v1.15.0 — attestation carry-forward MUST + implementer guidance, from a 4-tool deep audit diff --git a/cmd/fusaops/cmd_extra_coverage_test.go b/cmd/fusaops/cmd_extra_coverage_test.go index c1aad04..3e4bffc 100644 --- a/cmd/fusaops/cmd_extra_coverage_test.go +++ b/cmd/fusaops/cmd_extra_coverage_test.go @@ -115,6 +115,9 @@ func findEchoBinary() (string, error) { //fusa:test REQ-FO-CLI073 func TestHaraShowOutputFlag(t *testing.T) { dir := t.TempDir() + if code, _, errb := runArgs(t, "hara", "--dir", dir, "init"); code != 0 { + t.Fatalf("hara init: code=%d err=%q", code, errb) + } outFile := filepath.Join(dir, "hara-report.json") code, _, errb := runArgs(t, "hara", "--dir", dir, "show", "--format", "json", "--output", outFile) if code != 0 { @@ -140,6 +143,36 @@ func TestHaraShowBadFormat(t *testing.T) { } } +// TestHaraShowJSONAbsentFileExitsNonZero verifies hara show --format json +// exits non-zero on a project with no .fusa-hara.json, per x-FuSa spec +// §1.2.5: a hara command MUST NOT report zero hazards as if analysis were +// complete. +// +//fusa:test REQ-FO-CLI073 +func TestHaraShowJSONAbsentFileExitsNonZero(t *testing.T) { + dir := t.TempDir() + code, _, errb := runArgs(t, "hara", "--dir", dir, "show", "--format", "json") + if code == 0 { + t.Errorf("hara show --format json on absent file: want non-zero, got 0") + } + if !strings.Contains(errb, ".fusa-hara.json not found") { + t.Errorf("hara show --format json on absent file: stderr=%q, want mention of missing file", errb) + } +} + +// TestHaraShowTextAbsentFileStillExitsZero verifies hara show's default +// text format tolerates an absent .fusa-hara.json (the spec's exit-nonzero +// requirement is scoped to --format json, not the human-readable default). +// +//fusa:test REQ-FO-CLI073 +func TestHaraShowTextAbsentFileStillExitsZero(t *testing.T) { + dir := t.TempDir() + code, _, errb := runArgs(t, "hara", "--dir", dir, "show") + if code != 0 { + t.Errorf("hara show text on absent file: want 0, got %d (err=%q)", code, errb) + } +} + // TestHaraShowMarkdown verifies hara show markdown format. // //fusa:test REQ-FO-CLI073 diff --git a/cmd/fusaops/cmd_hara.go b/cmd/fusaops/cmd_hara.go index dfefc43..cf621a8 100644 --- a/cmd/fusaops/cmd_hara.go +++ b/cmd/fusaops/cmd_hara.go @@ -8,8 +8,10 @@ import ( "path/filepath" "time" + fusaops "github.com/SoundMatt/FuSaOps" "github.com/SoundMatt/FuSaOps/hara" "github.com/SoundMatt/FuSaOps/qualitybar" + "github.com/SoundMatt/FuSaOps/req" ) // runHara dispatches the hara subcommand (show|init|asil). @@ -77,6 +79,13 @@ func runHaraShow(args []string, projectRoot string, stdout, stderr io.Writer) in *requireAttestation = true } + if *format == "json" { + if _, statErr := os.Stat(filepath.Join(projectRoot, hara.HARAFile)); os.IsNotExist(statErr) { + fmt.Fprintf(stderr, "fusaops hara show: %s not found — run 'fusaops hara init' first (x-FuSa spec §1.2.5: a hara command MUST NOT report zero hazards as if analysis were complete)\n", hara.HARAFile) + return 1 + } + } + h, err := hara.Load(projectRoot) if err != nil { fmt.Fprintf(stderr, "fusaops hara show: %v\n", err) @@ -101,6 +110,13 @@ func runHaraShow(args []string, projectRoot string, stdout, stderr io.Writer) in exit := 0 findings := hara.Validate(h) + if entries, regErr := req.LoadRegistry(projectRoot); regErr == nil { + reqIDs := make(map[string]bool, len(entries)) + for _, e := range entries { + reqIDs[e.ID] = true + } + findings = append(findings, hara.ValidateFssrRefs(h, reqIDs)...) + } if len(findings) > 0 && *output != "" { fmt.Fprintf(stderr, "fusaops hara: %d gap(s) found — run 'fusaops hara show' for details\n", len(findings)) } @@ -152,7 +168,7 @@ func runHaraInit(args []string, projectRoot string, stdout, stderr io.Writer) in // completeness that a real "empty, honestly incomplete" state does not. h := &hara.HARA{ Project: name, - Standard: *standard, + Standard: fusaops.CanonicalStandardID(*standard), CreatedAt: time.Now().UTC(), Situations: []hara.OperationalSituation{}, Hazards: []hara.Hazard{}, diff --git a/cmd/fusaops/cmd_v147_v150_test.go b/cmd/fusaops/cmd_v147_v150_test.go index b5942e0..2ebc4cb 100644 --- a/cmd/fusaops/cmd_v147_v150_test.go +++ b/cmd/fusaops/cmd_v147_v150_test.go @@ -343,7 +343,7 @@ func TestSafetyCaseISO21434(t *testing.T) { if code > 1 { t.Fatalf("safety-case ISO 21434: unexpected exit code %d", code) } - if !strings.Contains(out, "ISO 21434") { + if !strings.Contains(out, "iso21434") { t.Errorf("safety-case output missing standard name: %q", out[:min(len(out), 200)]) } } @@ -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, `"threats"`) || !strings.Contains(out, `"riskLevel"`) { + if !strings.Contains(out, `"threats"`) || !strings.Contains(out, `"risk"`) { t.Errorf("tara json missing expected fields: %q", out[:min(len(out), 200)]) } } @@ -810,6 +810,9 @@ func TestHaraUnknownSubcommand(t *testing.T) { //fusa:test REQ-FO-CLI073 func TestHaraShowJSON(t *testing.T) { dir := t.TempDir() + if code, _, errb := runArgs(t, "hara", "--dir", dir, "init"); code != 0 { + t.Fatalf("hara init: code=%d err=%q", code, errb) + } code, out, errb := runArgs(t, "hara", "--dir", dir, "show", "--format", "json") if code != 0 { t.Fatalf("hara show json: code=%d err=%q", code, errb) diff --git a/docs/x-fusa-spec.md b/docs/x-fusa-spec.md index 701dae0..8fe9d8e 100644 --- a/docs/x-fusa-spec.md +++ b/docs/x-fusa-spec.md @@ -752,8 +752,9 @@ header, so FuSaOps (or anything) can read attribution and route decoding off **`kind` (MUST — closed, extensible enum).** Identifies the document type so a consumer routes generically: `check-report` (also `report`) · `trace-matrix` · `qualification` · `sbom` · `provenance` · `artifact-manifest` · `audit-manifest` -· `gap-report` · `capabilities`. A consumer MUST treat an unknown `kind` as -opaque (read the common header, skip the payload) — never reject it. +· `gap-report` · `capabilities` · `fmea-report` · `tara-report` · `safety-case` +· `sas` · `sci`. A consumer MUST treat an unknown `kind` as opaque (read the +common header, skip the payload) — never reject it. The common header applies to **every** document, including the file-format artefacts (`sbom.json`, `provenance.json`, `artifact-manifest.json`, audit-pack diff --git a/fmea.json b/fmea.json index cb7eeea..89108b7 100644 --- a/fmea.json +++ b/fmea.json @@ -1,8 +1,11 @@ { - "generatedAt": "2026-07-28T19:19:37.850745Z", + "schemaVersion": "1.15.0", + "kind": "fmea-report", + "language": "go", + "generatedAt": "2026-07-28T21:17:46.78645Z", "projectRoot": ".", "tool": "fusaops", - "toolVersion": "1.141.0", + "toolVersion": "1.144.0", "standard": "IEC 61508:2010 / ISO 26262:2018 Part 8-7", "ratingScale": "custom-1-10", "entries": [ @@ -11,6 +14,7 @@ "component": "Adapter Registry", "function": "Execute language-specific safety analysis", "item": "Adapter Registry.Execute language-specific safety analysis", + "file": "adapter/adapter.go", "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.", @@ -34,6 +38,7 @@ "component": "cmdAdapter", "function": "Run x-FuSa tool and collect findings", "item": "cmdAdapter.Run x-FuSa tool and collect findings", + "file": "adapter/adapter.go", "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.", @@ -57,6 +62,7 @@ "component": "Report decoder", "function": "Parse adapter JSON output per x-FuSa spec", "item": "Report decoder.Parse adapter JSON output per x-FuSa spec", + "file": "report/report.go", "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.", @@ -80,6 +86,7 @@ "component": "trace package", "function": "Cross-language requirement traceability", "item": "trace package.Cross-language requirement traceability", + "file": "trace/trace.go", "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.", @@ -103,6 +110,7 @@ "component": "sbom package", "function": "Merge per-language SBOMs into cross-language SBOM", "item": "sbom package.Merge per-language SBOMs into cross-language SBOM", + "file": "sbom/sbom.go", "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.", @@ -126,6 +134,7 @@ "component": "auditpack / sign packages", "function": "Verify integrity of evidence artefacts", "item": "auditpack / sign packages.Verify integrity of evidence artefacts", + "file": "sign/sign.go", "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.", @@ -149,6 +158,7 @@ "component": "cmdAdapter / orchestrator", "function": "Execute adapter within configured timeout", "item": "cmdAdapter / orchestrator.Execute adapter within configured timeout", + "file": "orchestrator/orchestrator.go", "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.", @@ -172,6 +182,7 @@ "component": "suppression package", "function": "Filter findings against the suppression list", "item": "suppression package.Filter findings against the suppression list", + "file": "suppression/suppression.go", "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.", @@ -199,5 +210,5 @@ "coveragePct": 19.5, "componentInventoryMethod": "count of all packages in CLAUDE.md's core orchestration-pipeline table (11) and layered compliance/evidence/workflow table (30), 41 total; componentsAnalyzed counts distinct FMEA entries, not distinct packages named (some entries span multiple packages, e.g. \"auditpack / sign packages\")" }, - "hash": "sha256:1bb7b76663c10f4c2e5a1681a16a143fe255f8633953ed75f466547f628b116b" + "hash": "sha256:52449b2f766eea3ba01b28a9a5cf311855b766ef773545eedf033595ed4d033c" } diff --git a/fmea/fmea.go b/fmea/fmea.go index 402410b..be30ba3 100644 --- a/fmea/fmea.go +++ b/fmea/fmea.go @@ -62,6 +62,7 @@ type FailureMode struct { Component string `json:"component"` Function string `json:"function"` Item string `json:"item"` // x-FuSa spec §9.2 single-field identity: "Component.Function" + File string `json:"file"` // x-FuSa spec §9.2 MUST: project-relative path to the component's source Mode string `json:"failureMode"` Effect string `json:"effect"` Cause string `json:"cause"` @@ -102,16 +103,20 @@ type Summary struct { // //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"` - RatingScale string `json:"ratingScale"` - Entries []FailureMode `json:"entries"` - Summary Summary `json:"summary"` - Attestation *fusaops.Attestation `json:"attestation,omitempty"` - Hash string `json:"hash"` + // Common header, x-FuSa spec §3.1. + SchemaVersion string `json:"schemaVersion"` + Kind string `json:"kind"` + Language string `json:"language"` + 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"` + Attestation *fusaops.Attestation `json:"attestation,omitempty"` + Hash string `json:"hash"` } // HasHighRPN returns true when any failure mode exceeds HighRPNThreshold. @@ -143,6 +148,7 @@ type modeSpec struct { id string component string function string + file string mode string effect string cause string @@ -161,6 +167,7 @@ var standardModes = []modeSpec{ id: "FM-001", component: "Adapter Registry", function: "Execute language-specific safety analysis", + file: "adapter/adapter.go", mode: "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.", @@ -179,6 +186,7 @@ var standardModes = []modeSpec{ id: "FM-002", component: "cmdAdapter", function: "Run x-FuSa tool and collect findings", + file: "adapter/adapter.go", mode: "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.", @@ -197,6 +205,7 @@ var standardModes = []modeSpec{ id: "FM-003", component: "Report decoder", function: "Parse adapter JSON output per x-FuSa spec", + file: "report/report.go", mode: "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.", @@ -215,6 +224,7 @@ var standardModes = []modeSpec{ id: "FM-004", component: "trace package", function: "Cross-language requirement traceability", + file: "trace/trace.go", mode: "Source annotation removed or misspelled", effect: "Requirement appears untraceable; false coverage gap reported.", cause: "Developer removes or typos a //fusa:req annotation during refactoring.", @@ -233,6 +243,7 @@ var standardModes = []modeSpec{ id: "FM-005", component: "sbom package", function: "Merge per-language SBOMs into cross-language SBOM", + file: "sbom/sbom.go", mode: "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.", @@ -251,6 +262,7 @@ var standardModes = []modeSpec{ id: "FM-006", component: "auditpack / sign packages", function: "Verify integrity of evidence artefacts", + file: "sign/sign.go", mode: "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.", @@ -269,6 +281,7 @@ var standardModes = []modeSpec{ id: "FM-007", component: "cmdAdapter / orchestrator", function: "Execute adapter within configured timeout", + file: "orchestrator/orchestrator.go", mode: "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.", @@ -287,6 +300,7 @@ var standardModes = []modeSpec{ id: "FM-008", component: "suppression package", function: "Filter findings against the suppression list", + file: "suppression/suppression.go", mode: "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.", @@ -308,12 +322,15 @@ var standardModes = []modeSpec{ //fusa:req REQ-FO-FMEA002 func Build(root string) (*FMEA, error) { f := &FMEA{ - GeneratedAt: time.Now().UTC(), - ProjectRoot: root, - Tool: "fusaops", - ToolVersion: fusaops.Version, - Standard: "IEC 61508:2010 / ISO 26262:2018 Part 8-7", - RatingScale: RatingScale, + SchemaVersion: fusaops.SpecVersion, + Kind: "fmea-report", + Language: "go", + GeneratedAt: time.Now().UTC(), + ProjectRoot: root, + Tool: "fusaops", + ToolVersion: fusaops.Version, + Standard: "IEC 61508:2010 / ISO 26262:2018 Part 8-7", + RatingScale: RatingScale, } for _, spec := range standardModes { @@ -323,6 +340,7 @@ func Build(root string) (*FMEA, error) { Component: spec.component, Function: spec.function, Item: spec.component + "." + spec.function, + File: spec.file, Mode: spec.mode, Effect: spec.effect, Cause: spec.cause, @@ -352,13 +370,19 @@ func Build(root string) (*FMEA, error) { } // coveragePct returns 100*analyzed/total rounded to one decimal, or 100 when -// total is 0 (no denominator means nothing is uncovered). +// total is 0 (no denominator means nothing is uncovered). x-FuSa spec §9.2: +// coveragePct MUST NOT exceed 100 — clamped defensively even though analyzed +// cannot currently exceed total by construction. func coveragePct(analyzed, total int) float64 { if total == 0 { return 100 } pct := 100 * float64(analyzed) / float64(total) - return math.Round(pct*10) / 10 + pct = math.Round(pct*10) / 10 + if pct > 100 { + return 100 + } + return pct } func computeHash(f *FMEA) string { diff --git a/fmea/fmea_internal_test.go b/fmea/fmea_internal_test.go index 8bb4744..b36c771 100644 --- a/fmea/fmea_internal_test.go +++ b/fmea/fmea_internal_test.go @@ -12,3 +12,13 @@ func TestPriorityLabelCritical(t *testing.T) { t.Errorf("priorityLabel(201): want %q, got %q", "CRITICAL", got) } } + +// TestCoveragePctNeverExceeds100 verifies coveragePct clamps to 100 even when +// analyzed > total (x-FuSa spec §9.2: "coveragePct MUST NOT exceed 100"). +// +//fusa:test REQ-FO-FMEA008 +func TestCoveragePctNeverExceeds100(t *testing.T) { + if got := coveragePct(12, 10); got != 100 { + t.Errorf("coveragePct(12, 10) = %v, want 100 (clamped)", got) + } +} diff --git a/fusaops.go b/fusaops.go index 04b8121..eb5396c 100644 --- a/fusaops.go +++ b/fusaops.go @@ -20,7 +20,7 @@ import ( ) // Version is the current release of FuSaOps. -const Version = "1.143.0" +const Version = "1.144.0" // SpecVersion is the x-FuSa specification version this release targets. // @@ -119,6 +119,35 @@ type Finding struct { var digitRunRE = regexp.MustCompile(`[0-9]+`) +// canonicalStandardIDs maps a human-readable standard display string to its +// canonical lowercase id per x-FuSa spec §2.4.1. Only standards FuSaOps +// itself emits are covered; an unmapped input is returned unchanged by +// CanonicalStandardID rather than guessed at. +var canonicalStandardIDs = map[string]string{ + "ISO 26262": "iso26262", + "DO-178C": "do178c", + "IEC 61508": "iec61508", + "ISO 21434": "iso21434", + "iso26262": "iso26262", + "do178c": "do178c", + "iec61508": "iec61508", + "iso21434": "iso21434", +} + +// CanonicalStandardID returns display's canonical lowercase standard id per +// x-FuSa spec §2.4.1 (e.g. "ISO 26262" -> "iso26262"). Per §2.9, a "standard" +// field MUST carry the same canonical value in every output format — display +// remains distinct from the format string, unlike some strings. When display +// is not a known standard, it is returned unchanged. +// +//fusa:req REQ-FO-CORE008 +func CanonicalStandardID(display string) string { + if id, ok := canonicalStandardIDs[display]; ok { + return id + } + return display +} + // ComputeFingerprint returns the canonical sha256:<64 hex> fingerprint for f // per §4.2 of the x-FuSa spec. Digit runs in Message are normalised to "#" // and whitespace is collapsed so cosmetic differences do not produce distinct diff --git a/hara/hara.go b/hara/hara.go index 5b05b6a..edb9fcc 100644 --- a/hara/hara.go +++ b/hara/hara.go @@ -336,6 +336,36 @@ func Validate(h *HARA) []ValidationFinding { }) } } + for _, hz := range h.Hazards { + stated := hz.Risk.ASIL + derived := DetermineASIL(hz.Risk.Severity, hz.Risk.Exposure, hz.Risk.Controllability) + if stated != "" && stated != derived { + out = append(out, ValidationFinding{ + HazardID: hz.ID, + Message: fmt.Sprintf("hazard %s states ASIL %s but S=%s E=%s C=%s derives %s (ISO 26262-3:2018 Table 4) — stated ASIL must match the derived value", hz.ID, stated, hz.Risk.Severity, hz.Risk.Exposure, hz.Risk.Controllability, derived), + }) + } + } + return out +} + +// ValidateFssrRefs cross-checks every safety goal's fssrRefs against the +// project's real requirement registry (.fusa-reqs.json ids), catching a +// dangling reference that a non-empty check alone cannot see. +// +//fusa:req REQ-FO-HARA007 +func ValidateFssrRefs(h *HARA, reqIDs map[string]bool) []ValidationFinding { + var out []ValidationFinding + for _, g := range h.SafetyGoals { + for _, ref := range g.FssrRefs { + if !reqIDs[ref] { + out = append(out, ValidationFinding{ + SafetyGoalID: g.ID, + Message: fmt.Sprintf("safety goal %s references unknown requirement %s in fssrRefs — not present in .fusa-reqs.json", g.ID, ref), + }) + } + } + } return out } diff --git a/safety-case.json b/safety-case.json index b6aa41a..56261eb 100644 --- a/safety-case.json +++ b/safety-case.json @@ -1,9 +1,12 @@ { - "generatedAt": "2026-07-28T19:19:37.863874Z", + "schemaVersion": "1.15.0", + "kind": "safety-case", + "language": "go", + "generatedAt": "2026-07-28T21:17:47.147449Z", "projectRoot": ".", "tool": "fusaops", - "toolVersion": "1.141.0", - "standard": "ISO 26262", + "toolVersion": "1.144.0", + "standard": "iso26262", "claims": [ { "id": "C-001", @@ -12,7 +15,7 @@ "evidence": [ { "title": "Tool qualification report", - "path": "./.fusaops-qualify-report.json", + "path": ".fusaops-qualify-report.json", "status": "missing" } ], @@ -25,7 +28,7 @@ "evidence": [ { "title": "Traceability matrix (JSON)", - "path": "./.fusaops-trace.json", + "path": ".fusaops-trace.json", "status": "missing" } ], @@ -38,7 +41,7 @@ "evidence": [ { "title": "Test evidence bundle", - "path": "./.fusaops-evidence.json", + "path": ".fusaops-evidence.json", "status": "missing" } ], @@ -51,7 +54,7 @@ "evidence": [ { "title": "Cross-language SBOM", - "path": "./sbom.json", + "path": "sbom.json", "status": "present", "sha256": "17e9172e968bc17d813e3c7960eb40fe89d3327c102da83365258bc2fc7d9388", "size": 623 @@ -66,14 +69,14 @@ "evidence": [ { "title": "Build provenance", - "path": "./provenance.json", + "path": "provenance.json", "status": "present", "sha256": "b599f32f336abb4f473dbb4bb7a426af815de3c0004ccf345dc70a6cb538c14b", "size": 283 }, { "title": "Artifact manifest (SHA-256)", - "path": "./artifact-manifest.json", + "path": "artifact-manifest.json", "status": "present", "sha256": "bff8d3d64849cecec8173e4f117649b483c2b7d5f2ab3be571e1ab4fea46f6bf", "size": 453 @@ -88,7 +91,7 @@ "evidence": [ { "title": "Problem report log", - "path": "./.fusaops-problems.json", + "path": ".fusaops-problems.json", "status": "missing" } ], @@ -101,7 +104,7 @@ "evidence": [ { "title": "Audit pack archive", - "path": "./audit-pack.zip", + "path": "audit-pack.zip", "status": "present", "sha256": "490f4b5c57c02ce6c835df93be110e5ce123e0537995ad2b5d8b768abee743e9", "size": 17730 @@ -157,7 +160,7 @@ "id": "C-004-Sn1", "type": "solution", "text": "Cross-language SBOM", - "evidence": "./sbom.json" + "evidence": "sbom.json" }, { "id": "C-005", @@ -173,13 +176,13 @@ "id": "C-005-Sn1", "type": "solution", "text": "Build provenance", - "evidence": "./provenance.json" + "evidence": "provenance.json" }, { "id": "C-005-Sn2", "type": "solution", "text": "Artifact manifest (SHA-256)", - "evidence": "./artifact-manifest.json" + "evidence": "artifact-manifest.json" }, { "id": "C-006", @@ -205,7 +208,7 @@ "id": "C-007-Sn1", "type": "solution", "text": "Audit pack archive", - "evidence": "./audit-pack.zip" + "evidence": "audit-pack.zip" } ], "edges": [ @@ -270,5 +273,5 @@ "goalsWithEvidence": 3, "undeveloped": 4 }, - "hash": "sha256:19a5ac0d36c85571d8e44cdad29426e35ccba524e3eac8a6fe512960ecb03235" + "hash": "sha256:f9b9eb474c0711d4b726ab6863fe8e3183a90b175b00db3c517b84488a20a483" } diff --git a/safetycase/safetycase.go b/safetycase/safetycase.go index b2d9761..110ac3c 100644 --- a/safetycase/safetycase.go +++ b/safetycase/safetycase.go @@ -137,19 +137,23 @@ type Completeness struct { // //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"` - Nodes []GSNNode `json:"nodes"` - Edges []GSNEdge `json:"edges"` - Completeness Completeness `json:"completeness"` - Attestation *fusaops.Attestation `json:"attestation,omitempty"` - Hash string `json:"hash"` + // Common header, x-FuSa spec §3.1. + SchemaVersion string `json:"schemaVersion"` + Kind string `json:"kind"` + Language string `json:"language"` + 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"` + Attestation *fusaops.Attestation `json:"attestation,omitempty"` + Hash string `json:"hash"` } // HasGaps returns true when at least one claim failed due to missing evidence. @@ -272,18 +276,20 @@ var commonClaims = []claimSpec{ } // resolveEvidence checks whether each evidence file exists and records its -// SHA-256 hash and size when present. +// SHA-256 hash and size when present. Path is always project-relative +// (x-FuSa spec §4/§9.2 MUST) even though the file is stat'd via its full +// filesystem path. func resolveEvidence(root string, specs []evidenceSpec) []EvidenceRef { refs := make([]EvidenceRef, len(specs)) for i, s := range specs { - path := root + "/" + s.filename - info, err := os.Stat(path) + fullPath := root + "/" + s.filename + info, err := os.Stat(fullPath) if err != nil { - refs[i] = EvidenceRef{Title: s.title, Path: path, Status: StatusMissing} + refs[i] = EvidenceRef{Title: s.title, Path: s.filename, Status: StatusMissing} continue } - sum := hashFile(path) - refs[i] = EvidenceRef{Title: s.title, Path: path, Status: StatusPresent, SHA256: sum, Size: info.Size()} + sum := hashFile(fullPath) + refs[i] = EvidenceRef{Title: s.title, Path: s.filename, Status: StatusPresent, SHA256: sum, Size: info.Size()} } return refs } @@ -316,11 +322,14 @@ func hashFile(path string) string { //fusa:req REQ-FO-SC002 func Build(root string, std Standard) (*SafetyCase, error) { sc := &SafetyCase{ - GeneratedAt: time.Now().UTC(), - ProjectRoot: root, - Tool: "fusaops", - ToolVersion: fusaops.Version, - Standard: std, + SchemaVersion: fusaops.SpecVersion, + Kind: "safety-case", + Language: "go", + GeneratedAt: time.Now().UTC(), + ProjectRoot: root, + Tool: "fusaops", + ToolVersion: fusaops.Version, + Standard: Standard(fusaops.CanonicalStandardID(string(std))), } for _, cs := range commonClaims { diff --git a/safetycase/safetycase_test.go b/safetycase/safetycase_test.go index 031434d..72f1e84 100644 --- a/safetycase/safetycase_test.go +++ b/safetycase/safetycase_test.go @@ -8,6 +8,7 @@ import ( "strings" "testing" + fusaops "github.com/SoundMatt/FuSaOps" "github.com/SoundMatt/FuSaOps/safetycase" ) @@ -73,8 +74,8 @@ func TestBuildEmptyDir(t *testing.T) { if sc.Tool != "fusaops" { t.Errorf("Tool = %q, want fusaops", sc.Tool) } - if sc.Standard != safetycase.StandardISO26262 { - t.Errorf("Standard = %q, want ISO 26262", sc.Standard) + if sc.Standard != "iso26262" { + t.Errorf("Standard = %q, want iso26262", sc.Standard) } if sc.ProjectRoot != dir { t.Errorf("ProjectRoot = %q, want %q", sc.ProjectRoot, dir) @@ -260,8 +261,9 @@ func TestBuildAllStandards(t *testing.T) { t.Errorf("Build(%s): %v", std, err) continue } - if sc.Standard != std { - t.Errorf("Standard = %q, want %q", sc.Standard, std) + wantID := safetycase.Standard(fusaops.CanonicalStandardID(string(std))) + if sc.Standard != wantID { + t.Errorf("Standard = %q, want %q", sc.Standard, wantID) } } } @@ -333,7 +335,7 @@ func TestRenderText(t *testing.T) { t.Fatalf("Render text: %v", err) } out := buf.String() - for _, want := range []string{"FuSaOps Safety Case", "ISO 26262", "C-001", "GAPS DETECTED"} { + for _, want := range []string{"FuSaOps Safety Case", "iso26262", "C-001", "GAPS DETECTED"} { if !strings.Contains(out, want) { t.Errorf("text output missing %q:\n%s", want, out) } @@ -385,8 +387,8 @@ func TestRenderJSON(t *testing.T) { if err := json.Unmarshal(buf.Bytes(), &check); err != nil { t.Fatalf("Unmarshal: %v", err) } - if check.Standard != safetycase.StandardDO178C { - t.Errorf("Standard = %q, want DO-178C", check.Standard) + if check.Standard != "do178c" { + t.Errorf("Standard = %q, want do178c", check.Standard) } if check.TotalClaims != sc.TotalClaims { t.Errorf("TotalClaims = %d, want %d", check.TotalClaims, sc.TotalClaims) diff --git a/sas/sas.go b/sas/sas.go index f4203f4..196ca70 100644 --- a/sas/sas.go +++ b/sas/sas.go @@ -70,6 +70,10 @@ type Summary struct { // //fusa:req REQ-FO-SAS001 type SAS struct { + // Common header, x-FuSa spec §3.1. + SchemaVersion string `json:"schemaVersion"` + Kind string `json:"kind"` + Language string `json:"language"` GeneratedAt time.Time `json:"generatedAt"` ProjectRoot string `json:"projectRoot"` Tool string `json:"tool"` @@ -159,6 +163,9 @@ func Build(root, softwareLevel string) (*SAS, error) { } s := &SAS{ + SchemaVersion: fusaops.SpecVersion, + Kind: "sas", + Language: "go", GeneratedAt: time.Now().UTC(), ProjectRoot: root, Tool: "fusaops", diff --git a/sci/sci.go b/sci/sci.go index 02fe459..99563e2 100644 --- a/sci/sci.go +++ b/sci/sci.go @@ -70,15 +70,19 @@ type ArtifactRef struct { // //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"` - Artifacts []ArtifactRef `json:"artifacts"` - Hash string `json:"hash"` + // Common header, x-FuSa spec §3.1. + SchemaVersion string `json:"schemaVersion"` + Kind string `json:"kind"` + Language string `json:"language"` + 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. @@ -119,11 +123,14 @@ func hashFile(path string) (string, int64) { //fusa:req REQ-FO-SCI002 func Build(root string, adapters []adapter.Adapter) (*SCI, error) { s := &SCI{ - GeneratedAt: time.Now().UTC(), - ProjectRoot: root, - Tool: "fusaops", - ToolVersion: fusaops.Version, - GoVersion: runtime.Version(), + SchemaVersion: fusaops.SpecVersion, + Kind: "sci", + Language: "go", + GeneratedAt: time.Now().UTC(), + ProjectRoot: root, + Tool: "fusaops", + ToolVersion: fusaops.Version, + GoVersion: runtime.Version(), } // Orchestration tool itself. @@ -147,15 +154,16 @@ func Build(root string, adapters []adapter.Adapter) (*SCI, error) { s.Items = append(s.Items, item) } - // Known evidence artefacts. + // Known evidence artefacts. Path is project-relative (x-FuSa spec §4 MUST) + // even though hashFile stats the full filesystem path. for _, art := range knownArtefacts { - path := root + "/" + art.file - sum, size := hashFile(path) + fullPath := root + "/" + art.file + sum, size := hashFile(fullPath) item := ConfigItem{ ID: art.id, Name: art.name, Kind: KindArtefact, - Path: path, + Path: art.file, Present: sum != "", SHA256: sum, Size: size, diff --git a/tara.json b/tara.json index a7a2ed9..8189c2a 100644 --- a/tara.json +++ b/tara.json @@ -1,9 +1,12 @@ { - "generatedAt": "2026-07-28T19:19:37.857132Z", + "schemaVersion": "1.15.0", + "kind": "tara-report", + "language": "go", + "generatedAt": "2026-07-28T21:17:47.056554Z", "projectRoot": ".", "tool": "fusaops", - "toolVersion": "1.141.0", - "standard": "ISO 21434:2021 Chapter 9", + "toolVersion": "1.144.0", + "standard": "iso21434", "threats": [ { "id": "TS-001", @@ -17,9 +20,9 @@ "operational": "moderate", "privacy": "negligible" }, - "attackPath": "Unauthorized write access to sbom.json or sbom-spdx.json in the release directory.", + "attackVector": "Unauthorized write access to sbom.json or sbom-spdx.json in the release directory.", "attackFeasibility": "low", - "riskLevel": "high", + "risk": "high", "treatment": "mitigate", "mitigations": [ "fusaops sign — HMAC artifact signing", @@ -39,9 +42,9 @@ "operational": "moderate", "privacy": "negligible" }, - "attackPath": "Modification of .fusaops-evidence.json before certification review by a developer with repository write access.", + "attackVector": "Modification of .fusaops-evidence.json before certification review by a developer with repository write access.", "attackFeasibility": "medium", - "riskLevel": "critical", + "risk": "critical", "treatment": "mitigate", "mitigations": [ "HMAC signing (fusaops sign)", @@ -61,9 +64,9 @@ "operational": "major", "privacy": "negligible" }, - "attackPath": "Compromise of upstream tool registry, tampered container image, or PATH manipulation in CI.", + "attackVector": "Compromise of upstream tool registry, tampered container image, or PATH manipulation in CI.", "attackFeasibility": "very-low", - "riskLevel": "medium", + "risk": "medium", "treatment": "mitigate", "mitigations": [ "SLSA provenance verification (fusaops slsa)", @@ -83,9 +86,9 @@ "operational": "moderate", "privacy": "negligible" }, - "attackPath": "Direct file modification of report artefacts before or during review by a developer with local file access.", + "attackVector": "Direct file modification of report artefacts before or during review by a developer with local file access.", "attackFeasibility": "high", - "riskLevel": "high", + "risk": "high", "treatment": "mitigate", "mitigations": [ "Artifact signing (fusaops sign)", @@ -105,9 +108,9 @@ "operational": "moderate", "privacy": "negligible" }, - "attackPath": "Compromise of signing key stored in CI/CD secrets, developer workstation, or key management system.", + "attackVector": "Compromise of signing key stored in CI/CD secrets, developer workstation, or key management system.", "attackFeasibility": "low", - "riskLevel": "high", + "risk": "high", "treatment": "mitigate", "mitigations": [ "Hardware security modules for key storage", @@ -127,9 +130,9 @@ "operational": "moderate", "privacy": "negligible" }, - "attackPath": "Unauthorized commit to the repository or direct modification of .fusaops-trace.json artefact.", + "attackVector": "Unauthorized commit to the repository or direct modification of .fusaops-trace.json artefact.", "attackFeasibility": "medium", - "riskLevel": "high", + "risk": "high", "treatment": "mitigate", "mitigations": [ "PR-gated branch protection with mandatory review", @@ -149,9 +152,9 @@ "operational": "major", "privacy": "negligible" }, - "attackPath": "DoS against CI infrastructure, sabotage of build scripts, or removal of adapter tool binaries.", + "attackVector": "DoS against CI infrastructure, sabotage of build scripts, or removal of adapter tool binaries.", "attackFeasibility": "medium", - "riskLevel": "high", + "risk": "high", "treatment": "mitigate", "mitigations": [ "Required status checks for all pull requests", @@ -171,9 +174,9 @@ "operational": "moderate", "privacy": "negligible" }, - "attackPath": "Unauthorized commit modifying .fusaops.json to add scan exclusions or disable adapters.", + "attackVector": "Unauthorized commit modifying .fusaops.json to add scan exclusions or disable adapters.", "attackFeasibility": "medium", - "riskLevel": "critical", + "risk": "critical", "treatment": "mitigate", "mitigations": [ "PR review gate with code-owner approval", @@ -191,5 +194,5 @@ "coveragePct": 80, "assetInventoryMethod": "count of FuSaOps' own sci package's knownArtefacts evidence-artefact inventory (10 items, DOC-001..DOC-010); non-file assets (adapter tool binaries, the CI/CD pipeline itself, .fusaops.json config) are analyzed as threat scenarios here but excluded from this file-based denominator" }, - "hash": "sha256:b65ed1d23bb83e0e9cbebd24f76d61e879de66db6944da5d45bf637bffbd228f" + "hash": "sha256:e2d37b7d72ff0656f6059a3da49bc5b1097681bc77f80e1b96742be295bb3d59" } diff --git a/tara/tara.go b/tara/tara.go index 94fa792..bb0051b 100644 --- a/tara/tara.go +++ b/tara/tara.go @@ -96,9 +96,9 @@ type ThreatScenario struct { Threat string `json:"threat"` DamageScenario string `json:"damageScenario"` Impact ImpactRating `json:"impact"` - AttackPath string `json:"attackPath"` + AttackPath string `json:"attackVector"` Feasibility Feasibility `json:"attackFeasibility"` - RiskLevel RiskLevel `json:"riskLevel"` + RiskLevel RiskLevel `json:"risk"` Treatment TreatmentDecision `json:"treatment"` Controls []string `json:"mitigations"` // CWE is the applicable CWE id, when the threat maps to one (SHOULD when @@ -142,15 +142,19 @@ const AssetInventoryMethod = "count of FuSaOps' own sci package's knownArtefacts // //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"` - Threats []ThreatScenario `json:"threats"` - Summary Summary `json:"summary"` - Attestation *fusaops.Attestation `json:"attestation,omitempty"` - Hash string `json:"hash"` + // Common header, x-FuSa spec §3.1. + SchemaVersion string `json:"schemaVersion"` + Kind string `json:"kind"` + Language string `json:"language"` + 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"` + Attestation *fusaops.Attestation `json:"attestation,omitempty"` + Hash string `json:"hash"` } // HasCritical returns true when any scenario carries a critical risk level. @@ -341,11 +345,14 @@ var standardScenarios = []scenarioSpec{ //fusa:req REQ-FO-TARA002 func Build(root string) (*TARA, error) { t := &TARA{ - GeneratedAt: time.Now().UTC(), - ProjectRoot: root, - Tool: "fusaops", - ToolVersion: fusaops.Version, - Standard: "ISO 21434:2021 Chapter 9", + SchemaVersion: fusaops.SpecVersion, + Kind: "tara-report", + Language: "go", + GeneratedAt: time.Now().UTC(), + ProjectRoot: root, + Tool: "fusaops", + ToolVersion: fusaops.Version, + Standard: fusaops.CanonicalStandardID("ISO 21434"), // ISO/SAE 21434:2021 Chapter 9 } for _, spec := range standardScenarios { @@ -383,13 +390,19 @@ func Build(root string) (*TARA, error) { } // coveragePct returns 100*analyzed/total rounded to one decimal, or 100 when -// total is 0 (no denominator means nothing is uncovered). +// total is 0 (no denominator means nothing is uncovered). x-FuSa spec §9.2: +// coveragePct MUST NOT exceed 100 — clamped defensively even though analyzed +// cannot currently exceed total by construction. func coveragePct(analyzed, total int) float64 { if total == 0 { return 100 } pct := 100 * float64(analyzed) / float64(total) - return math.Round(pct*10) / 10 + pct = math.Round(pct*10) / 10 + if pct > 100 { + return 100 + } + return pct } func computeHash(t *TARA) string { diff --git a/tara/tara_riskmatrix_test.go b/tara/tara_riskmatrix_test.go index 653a9fd..d3d7ae6 100644 --- a/tara/tara_riskmatrix_test.go +++ b/tara/tara_riskmatrix_test.go @@ -29,3 +29,13 @@ func TestRiskMatrixUncoveredBranches(t *testing.T) { } } } + +// TestCoveragePctNeverExceeds100 verifies coveragePct clamps to 100 even when +// analyzed > total (x-FuSa spec §9.2: "coveragePct MUST NOT exceed 100"). +// +//fusa:test REQ-FO-TARA008 +func TestCoveragePctNeverExceeds100(t *testing.T) { + if got := coveragePct(12, 10); got != 100 { + t.Errorf("coveragePct(12, 10) = %v, want 100 (clamped)", got) + } +}