Skip to content
Merged
12 changes: 12 additions & 0 deletions .fusa-reqs.json
Original file line number Diff line number Diff line change
Expand Up @@ -2436,12 +2436,24 @@
"text": "Config accepts both go-FuSa's legacy .fusa.json shape (top-level \"version\", \"standard\"/\"asil\"/\"sil\" nested under \"project\") and the x-FuSa spec §1.2.1 shape (top-level \"configVersion\", top-level \"standard\"/\"asil\"/\"sil\"/\"dal\", and the spec's legacy flat \"project\": \"name\" string). Load and Save normalise whichever fields are present into both locations, canonicalising the standard id case-insensitively onto go-FuSa's internal enum, so a spec-compliant .fusa.json written by another x-FuSa tool loads without error and go-FuSa's own init output satisfies both shapes at once.",
"standard": "x-FuSa"
},
{
"id": "REQ-CFG010",
"title": "Standard.CanonicalID maps the internal enum back to the x-FuSa spec §2.4.1 canonical lowercase id",
"text": "Standard.CanonicalID() is the inverse of the internal canonicalStandard mapping: it returns the x-FuSa spec §2.4.1 canonical lowercase standard id (e.g. \"iso26262\") for go-FuSa's internal enum value (e.g. StandardISO26262), so JSON envelope fields (check/report's top-level \"standard\", §3.2) never leak the internal uppercase, no-space enum spelling. StandardGeneric and an empty Standard both map to the empty string so callers omit the field rather than emit a non-canonical value.",
"standard": "x-FuSa spec section 2.4.1"
},
{
"id": "REQ-QUALIFY010",
"title": "Per-case qualification result status enum (PASS/FAIL/SKIP/ERROR)",
"text": "Each qualification case result carries a results[].result status string that is one of PASS, FAIL, SKIP, or ERROR, per x-FuSa spec §6. Infrastructure failures (unable to set up or run the synthetic case) report ERROR; a case that runs to completion but doesn't match its expectation reports FAIL; a matching case reports PASS.",
"standard": "x-FuSa"
},
{
"id": "REQ-QUALIFY011",
"title": "gofusa qualify accepts --dir and --format flags per x-FuSa spec §6",
"text": "gofusa qualify defines --dir <path> (project root directory; affects the default --output location, mirroring how other commands resolve projectRoot) and --format text|json (both write the same qualification report; the flag is accepted without error) per the CLI surface documented in x-FuSa spec §6, instead of erroring with \"flag provided but not defined\" when either is passed.",
"standard": "x-FuSa spec section 6"
},
{
"id": "REQ-ATT001",
"title": "Attestation struct for x-FuSa spec section 1.6.2",
Expand Down
34 changes: 34 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,40 @@ Dates reference the merged commit timestamp.

## [Unreleased]

## v0.46.0 — 2026-07-28 (deep-audit round 2: standard ids, SARIF, tara/qualify/audit-pack)

### Fixed
- **SARIF `tool.driver.name` is now the canonical `"go-FuSa"`** (x-FuSa spec
§2.9 MUST), not the `"gofusa"` binary name — the one output format whose
tool identity had diverged from every other JSON document (go-FuSa#81).
- **Standards gap-report `standard` field is now the §2.4.1 canonical
lowercase id** in all six commands (go-FuSa#77). `iso26262`/`do178`/
`iec61508`/`iso21434`/`unece` no longer bake the ASIL/SIL/DAL/CAL rating
into the id (`"ISO 26262 ASIL-B"` → `"iso26262"`, etc.), and `iec62443`
now carries the required part suffix (`"iec62443"` → `"iec62443-4-2"`).
- **`check`/`report`'s JSON envelope `standard` field is now the canonical
lowercase id** instead of go-FuSa's internal uppercase/no-space enum
spelling (e.g. `"ISO26262"`) — new `config.Standard.CanonicalID()`
(go-FuSa#78).
- **`tara --output-dir` now auto-creates the target directory**, matching
the identical `--output-dir` handling `fmea`/`safety-case` already had
(go-FuSa#83).
- **`qualify` now accepts the documented `--dir`/`--format` flags** (x-FuSa
spec §6) instead of erroring with "flag provided but not defined"
(go-FuSa#82).
- **`audit-pack` no longer silently drops `tara.json`/`tara.md`/
`cyber-report.json` and other evidence types** added after its
`EvidenceFiles` list was last updated — extended with the missing §1.2/
§1.3 filenames plus a glob for the open-ended
`<standard>-gap-report.json` family (go-FuSa#79).
- **`tara`'s `threats[]` no longer includes `_test.go` fixtures** as "asset
under threat" entries (x-FuSa spec §1.6 rule 4 MUST) — `tara.Scan` now
excludes test-fixture-sourced CYBER findings the same way
`CountProjectFiles` already excludes them from the `assetsInProject`
denominator, so `summary.assetsAnalyzed`/`coveragePct` are now
internally consistent with the report's own `assetInventoryMethod` text
(go-FuSa#80).

## v0.45.0 — 2026-07-28 (declare x-FuSa spec v1.15.0 conformance)

### Changed
Expand Down
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -285,7 +285,7 @@ docker build -t go-fusa .
docker run --rm -v "$(pwd)":/project go-fusa check
```

Published tags: `latest`, `0.45`, `0.45.0` (and matching semver for every release).
Published tags: `latest`, `0.46`, `0.46.0` (and matching semver for every release).

## Standards coverage

Expand Down
69 changes: 68 additions & 1 deletion auditpack/auditpack.go
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ import (
"io"
"os"
"path/filepath"
"sort"
"time"

fusa "github.com/SoundMatt/go-FuSa"
Expand Down Expand Up @@ -57,16 +58,31 @@ type AuditManifest struct {
Files []AuditManifestEntry `json:"files"`
}

// EvidenceFiles is the ordered list of evidence file names that Pack collects.
// EvidenceFiles is the ordered list of evidence file names that Pack collects
// — every x-FuSa spec §1.2 input file and §1.3 generated-evidence file that
// has a fixed name (everything except the `<standard>-gap-report.json`
// family, which Pack discovers separately via a glob since the set of
// standards is open-ended — see gapReportGlob).
//
//fusa:req REQ-AUDIT001
var EvidenceFiles = []string{
// §1.2 input/config files.
".fusa.json",
".fusa-reqs.json",
".fusa-hara.json",
".fusa-evidence.json",
".fusa-dispositions.json",
".fusa-problems.json",
".fusa-model-trace.json",
// §1.3 generated evidence.
"check-report.json",
"fmea.json",
"fmea.csv",
"tara.json",
"tara.md",
"cyber-report.json",
"coupling-report.json",
"comp-report.json",
"boundary.mermaid",
"boundary.dot",
"safety-case.json",
Expand All @@ -79,6 +95,13 @@ var EvidenceFiles = []string{
"vuln.json",
}

// gapReportGlob matches the open-ended §1.3 `<standard>-gap-report.json`
// family (e.g. iso26262-gap-report.json, slsa-gap-report.json,
// iec62443-4-2-gap-report.json, misra-c-gap-report.json) — a fixed name list
// can't enumerate every standard id a future tool version might add, so Pack
// discovers these by pattern instead.
const gapReportGlob = "*-gap-report.json"

// Pack bundles all present evidence files from projectRoot into a ZIP archive
// at outputPath. It returns the AuditManifest describing what was packed.
//
Expand All @@ -104,6 +127,7 @@ func Pack(projectRoot, outputPath string) (*AuditManifest, error) {
path string
}
var present []fileEntry
seen := make(map[string]bool, len(EvidenceFiles))
for _, name := range EvidenceFiles {
path := filepath.Join(projectRoot, name)
entry, err := hashFile(path, name)
Expand All @@ -115,6 +139,31 @@ func Pack(projectRoot, outputPath string) (*AuditManifest, error) {
}
manifest.Files = append(manifest.Files, entry)
present = append(present, fileEntry{name: name, path: path})
seen[name] = true
}

// §1.3 `<standard>-gap-report.json` files (open-ended set of standards —
// see gapReportGlob doc comment). Sorted for deterministic manifest
// ordering across filesystems/platforms.
gapReportNames, globErr := discoverGapReports(projectRoot)
if globErr != nil {
return nil, fmt.Errorf("auditpack: glob gap reports: %w", globErr)
}
for _, name := range gapReportNames {
if seen[name] {
continue
}
path := filepath.Join(projectRoot, name)
entry, hashErr := hashFile(path, name)
if hashErr != nil {
if os.IsNotExist(hashErr) {
continue
}
return nil, fmt.Errorf("auditpack: hash %s: %w", name, hashErr)
}
manifest.Files = append(manifest.Files, entry)
present = append(present, fileEntry{name: name, path: path})
seen[name] = true
}

// Create ZIP
Expand Down Expand Up @@ -156,6 +205,24 @@ func Pack(projectRoot, outputPath string) (*AuditManifest, error) {

// ─── helpers ─────────────────────────────────────────────────────────────────

// discoverGapReports returns the base names (not full paths) of every
// `<standard>-gap-report.json` file present directly under projectRoot,
// sorted lexicographically for deterministic manifest/ZIP ordering.
//
//fusa:req REQ-AUDIT001
func discoverGapReports(projectRoot string) ([]string, error) {
matches, err := filepath.Glob(filepath.Join(projectRoot, gapReportGlob))
if err != nil {
return nil, err
}
names := make([]string, 0, len(matches))
for _, m := range matches {
names = append(names, filepath.Base(m))
}
sort.Strings(names)
return names, nil
}

func hashFile(path, name string) (AuditManifestEntry, error) {
f, err := os.Open(path)
if err != nil {
Expand Down
67 changes: 67 additions & 0 deletions auditpack/auditpack_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -193,6 +193,73 @@ func TestPack_IncludesFusaConfig(t *testing.T) {
}
}

// TestPack_IncludesNewerEvidenceTypes is a regression test for the audit-pack
// silently dropping tara.json/tara.md/cyber-report.json and other evidence
// types added after EvidenceFiles was last updated (x-FuSa spec §8 MUST:
// "every §1.2 input file and every §1.3 generated file that exists at the
// project root").
//
//fusa:test REQ-AUDIT001
func TestPack_IncludesNewerEvidenceTypes(t *testing.T) {
files := testutil.MinimalProject()
for _, name := range []string{
// §1.2 input files not previously collected.
".fusa-hara.json",
".fusa-dispositions.json",
".fusa-problems.json",
".fusa-model-trace.json",
// §1.3 generated evidence not previously collected.
"tara.json",
"tara.md",
"cyber-report.json",
"coupling-report.json",
"comp-report.json",
// §1.3 open-ended <standard>-gap-report.json family.
"iso26262-gap-report.json",
"slsa-gap-report.json",
} {
files[name] = `{"kind":"test"}`
}
dir := testutil.ProjectDir(t, files)
outPath := filepath.Join(t.TempDir(), auditpack.AuditPackFile)

manifest, err := auditpack.Pack(dir, outPath)
if err != nil {
t.Fatalf("Pack: %v", err)
}

packed := make(map[string]bool, len(manifest.Files))
for _, entry := range manifest.Files {
packed[entry.Path] = true
}
names := zipFileNames(t, outPath)
inZip := make(map[string]bool, len(names))
for _, n := range names {
inZip[n] = true
}

for _, want := range []string{
".fusa-hara.json",
".fusa-dispositions.json",
".fusa-problems.json",
".fusa-model-trace.json",
"tara.json",
"tara.md",
"cyber-report.json",
"coupling-report.json",
"comp-report.json",
"iso26262-gap-report.json",
"slsa-gap-report.json",
} {
if !packed[want] {
t.Errorf("manifest missing %q", want)
}
if !inZip[want] {
t.Errorf("ZIP missing %q", want)
}
}
}

// ─── engine rule ─────────────────────────────────────────────────────────────

func runEngine(t *testing.T, files map[string]string) []fusa.Finding {
Expand Down
4 changes: 3 additions & 1 deletion cmd/gofusa/cmd_check.go
Original file line number Diff line number Diff line change
Expand Up @@ -73,7 +73,9 @@ func runCheck(args []string, stdout, stderr io.Writer) int {

rep := report.New(projectRoot, result.Findings)
rep.NoSummary = *noSummary
rep.Standard = string(cfg.Project.Standard)
// §2.4.1: the envelope's "standard" is a canonical lowercase id, never
// go-FuSa's internal uppercase/no-space enum spelling.
rep.Standard = cfg.Project.Standard.CanonicalID()
switch cfg.Project.Standard {
case "IEC61508":
rep.SIL = cfg.Project.ASIL
Expand Down
86 changes: 86 additions & 0 deletions cmd/gofusa/cmd_envelope_standard_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,86 @@
package main

import (
"bytes"
"encoding/json"
"path/filepath"
"testing"

"github.com/SoundMatt/go-FuSa/config"
)

// ─── check/report envelope "standard" field (x-FuSa spec §2.4.1) ─────────────
//
// Regression coverage for: the JSON envelope's top-level "standard" field
// must be the canonical lowercase id (e.g. "iso26262"), never go-FuSa's
// internal uppercase/no-space Standard enum spelling (e.g. "ISO26262").

//fusa:test REQ-CLI005
func TestRunCheck_JSONFormat_CanonicalStandardID(t *testing.T) {
dir := t.TempDir()
cfg := config.Default("github.com/x/y", "y")
cfg.Standard = config.StandardISO26262
cfg.Project.Standard = config.StandardISO26262
if err := config.Save(filepath.Join(dir, config.ConfigFile), cfg); err != nil {
t.Fatalf("Save config: %v", err)
}

var out, errBuf bytes.Buffer
code := runCheck([]string{"--dir", dir, "--format", "json"}, &out, &errBuf)
if code != 0 && code != 1 {
t.Fatalf("unexpected exit %d: %s", code, errBuf.String())
}

var doc map[string]interface{}
if err := json.Unmarshal(out.Bytes(), &doc); err != nil {
t.Fatalf("JSON parse: %v\n%s", err, out.String())
}
if doc["standard"] != "iso26262" {
t.Errorf("check envelope standard = %v, want canonical id \"iso26262\" (not \"ISO26262\")", doc["standard"])
}
}

//fusa:test REQ-CLI005
func TestRunReport_JSONFormat_CanonicalStandardID(t *testing.T) {
dir := t.TempDir()
cfg := config.Default("github.com/x/y", "y")
cfg.Standard = config.StandardDO178C
cfg.Project.Standard = config.StandardDO178C
if err := config.Save(filepath.Join(dir, config.ConfigFile), cfg); err != nil {
t.Fatalf("Save config: %v", err)
}

var out, errBuf bytes.Buffer
code := runReport([]string{"--dir", dir, "--format", "json"}, &out, &errBuf)
if code != 0 && code != 1 {
t.Fatalf("unexpected exit %d: %s", code, errBuf.String())
}

var doc map[string]interface{}
if err := json.Unmarshal(out.Bytes(), &doc); err != nil {
t.Fatalf("JSON parse: %v\n%s", err, out.String())
}
if doc["standard"] != "do178c" {
t.Errorf("report envelope standard = %v, want canonical id \"do178c\" (not \"DO178C\")", doc["standard"])
}
}

//fusa:test REQ-CLI005
func TestRunCheck_JSONFormat_GenericStandardOmitted(t *testing.T) {
dir := t.TempDir()
// config.Default already uses StandardGeneric; no .fusa.json written so
// config.Default("", ...) applies via ErrNoConfig fallback.
var out, errBuf bytes.Buffer
code := runCheck([]string{"--dir", dir, "--format", "json"}, &out, &errBuf)
if code != 0 && code != 1 {
t.Fatalf("unexpected exit %d: %s", code, errBuf.String())
}

var doc map[string]interface{}
if err := json.Unmarshal(out.Bytes(), &doc); err != nil {
t.Fatalf("JSON parse: %v\n%s", err, out.String())
}
if v, ok := doc["standard"]; ok && v != "" {
t.Errorf("check envelope standard for generic project = %v, want omitted/empty", v)
}
}
Loading
Loading