From a3ad25d569047da1803dbec1a106351de31fc34c Mon Sep 17 00:00:00 2001 From: SoundMatt Date: Thu, 30 Jul 2026 12:45:29 -0700 Subject: [PATCH 1/2] =?UTF-8?q?fix:=20v1.148.0=20=E2=80=94=20ISO=2026262?= =?UTF-8?q?=20Table=204=20ASIL=20bug=20+=20unauth=20arbitrary-file-read=20?= =?UTF-8?q?+=2013=20other=20audit=20findings?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit An external audit's findings were each independently reconfirmed against live source before patching. Highlights: - hara.DetermineASIL mis-implemented ISO 26262-3:2018 Table 4 (S2/S3 cells inflated 1-2 ASIL levels, S1 correct); hara_test.go locked in the wrong values. Replaced with the canonical additive S+E+C table and rewrote tests from a 36-cell truth table. Regenerated .fusa-hara.json (max hazard ASIL is now B, not C) and .fusa.json (ASIL-B, canonical configVersion/standard/asil shape). - serve ran unauthenticated by default with /api/v1/diff?baseline= taking an unvalidated filesystem path — sandboxed to project root. - Basic Auth credential compare was not constant-time. - qualify.hash included the live generatedAt timestamp and unsorted components, so it was never reproducible run-to-run. - sas never wrote its sas.md companion, only JSON. - Plus: vv self-attestation discounting, atomic history writes, webhook timeout, decision-coverage mislabeling, diff fingerprint language qualification, scheduler stop-channel leak, SOURCE_DATE_EPOCH support, configurable skip-dirs, EndLine/EndColumn, two invented standard clause citations, doc/spec-version drift, non-gating CI steps. Deliberately not patched (violates std-lib-only, or needs live registry lookups not derivable offline): Unicode NFC normalization; image/Action digest pinning. Signed-off-by: SoundMatt --- .fusa-hara.json | 16 ++--- .fusa.json | 9 +-- .github/workflows/ci.yml | 27 +++++-- .github/workflows/tools-monitor.yml | 10 ++- CHANGELOG.md | 48 +++++++++++++ Dockerfile | 12 ++++ Makefile | 4 +- README.md | 6 +- adapter/adapter.go | 30 +++++--- auditpack/auditpack.go | 17 ++++- cmd/fusaops/cmd_sas.go | 21 ++++++ cmd/fusaops/cmd_serve.go | 7 +- cmd/fusaops/cmd_v147_v150_test.go | 4 +- comp/comp.go | 4 +- config/config.go | 2 +- conform/conform.go | 2 +- coverage/coverage.go | 6 +- diff/diff.go | 17 ++++- docs/qualification.md | 9 ++- fusaops.go | 8 ++- fusaops_test.go | 17 +++++ hara/hara.go | 46 ++++++------ hara/hara_test.go | 82 +++++++++++++++++++-- history/history.go | 37 ++++++++-- mcdc/mcdc.go | 2 +- qualify/qualify.go | 24 +++++-- report/report.go | 18 +++++ server/server.go | 107 +++++++++++++++++++++++----- server/server_test.go | 8 +-- tara/tara.go | 8 +-- vv/vv.go | 10 ++- 31 files changed, 497 insertions(+), 121 deletions(-) diff --git a/.fusa-hara.json b/.fusa-hara.json index e1be9dc..1c09e73 100644 --- a/.fusa-hara.json +++ b/.fusa-hara.json @@ -22,7 +22,7 @@ "description": "Dropped finding — FuSaOps loses or hides a finding produced by a bundled x-FuSa tool during normalisation", "source": "Parser error swallowing findings, or an unknown severity being discarded instead of normalised", "situations": ["OS-001", "OS-002", "OS-003"], - "risk": { "severity": "S2", "exposure": "E4", "controllability": "C2", "asil": "ASIL-C" }, + "risk": { "severity": "S2", "exposure": "E4", "controllability": "C2", "asil": "ASIL-B" }, "safetyGoals": ["SG-001"] }, { @@ -30,7 +30,7 @@ "description": "Masked coverage gap — an applicable language tool does not run and FuSaOps reports PASS without surfacing that the language was unchecked", "source": "Adapter silently skipped, tool not installed, or detection miss treated as success", "situations": ["OS-002", "OS-003"], - "risk": { "severity": "S2", "exposure": "E4", "controllability": "C2", "asil": "ASIL-C" }, + "risk": { "severity": "S2", "exposure": "E4", "controllability": "C2", "asil": "ASIL-B" }, "safetyGoals": ["SG-002"] }, { @@ -38,7 +38,7 @@ "description": "Silent failure — fusaops check exits 0 when the aggregate contains ERROR-severity findings", "source": "Summary mis-count or exit-code logic returning success despite errors", "situations": ["OS-002"], - "risk": { "severity": "S2", "exposure": "E3", "controllability": "C1", "asil": "ASIL-A" }, + "risk": { "severity": "S2", "exposure": "E3", "controllability": "C1", "asil": "QM" }, "safetyGoals": ["SG-003"] }, { @@ -54,7 +54,7 @@ "description": "Stale evidence — the served dashboard or cached aggregate report no longer reflects the current source under review", "source": "Cache not refreshed before an audit or gating decision", "situations": ["OS-002", "OS-003"], - "risk": { "severity": "S2", "exposure": "E3", "controllability": "C2", "asil": "ASIL-B" }, + "risk": { "severity": "S2", "exposure": "E3", "controllability": "C2", "asil": "ASIL-A" }, "safetyGoals": ["SG-005"] } ], @@ -63,7 +63,7 @@ "id": "SG-001", "description": "FuSaOps shall preserve every finding emitted by a bundled tool, normalising unknown severities to INFO rather than dropping them", "hazards": ["H-001"], - "asil": "ASIL-C", + "asil": "ASIL-B", "safeState": "Surface a decode error as a failed/skipped component rather than an empty pass", "fssrRefs": ["REQ-FO-ADP006", "REQ-FO-ADP005"] }, @@ -71,7 +71,7 @@ "id": "SG-002", "description": "FuSaOps shall record every applicable-but-uninstalled or failed tool as an explicit skipped component so coverage gaps are visible", "hazards": ["H-002"], - "asil": "ASIL-C", + "asil": "ASIL-B", "safeState": "Emit a skipped component with reason; never omit an applicable language silently", "fssrRefs": ["REQ-FO-ORC003", "REQ-FO-RPT003"] }, @@ -79,7 +79,7 @@ "id": "SG-003", "description": "FuSaOps shall exit non-zero whenever any component produces an ERROR-severity finding, or a WARNING under --strict", "hazards": ["H-003"], - "asil": "ASIL-A", + "asil": "QM", "safeState": "Exit code 1", "fssrRefs": ["REQ-FO-CLI008", "REQ-FO-RPT006", "REQ-FO-ERR005"] }, @@ -95,7 +95,7 @@ "id": "SG-005", "description": "FuSaOps shall recompute the aggregate report on demand so a gating or audit decision can be made against current source", "hazards": ["H-005"], - "asil": "ASIL-B", + "asil": "ASIL-A", "safeState": "Provide an explicit refresh that recomputes before serving", "fssrRefs": ["REQ-FO-SRV003", "REQ-FO-SRV004"] } diff --git a/.fusa.json b/.fusa.json index 2a8e7f7..723ca45 100644 --- a/.fusa.json +++ b/.fusa.json @@ -1,11 +1,12 @@ { - "version": "1", + "configVersion": "1.0", "project": { "name": "FuSaOps", - "module": "github.com/SoundMatt/FuSaOps", - "standard": "ISO26262", - "asil": "ASIL-C" + "version": "0.1.0", + "module": "github.com/SoundMatt/FuSaOps" }, + "standard": "iso26262", + "asil": "ASIL-B", "rules": {}, "report": { "format": "text" diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index abcd4a0..80569f8 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -39,8 +39,23 @@ jobs: shell: bash run: go test -race -count=1 -coverprofile=coverage.out ./... - - name: Coverage gate (>= 80%) - if: matrix.os == 'ubuntu-latest' && matrix.go == '1.22' + # ── Coverage gate (>= 80%) ──────────────────────────────────────────────── + # Standalone required job so the gate is independent of the OS/Go test matrix: + # renaming/skipping a matrix cell can never silently remove the coverage check. + coverage: + name: Coverage gate (>= 80%) + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v5 + - uses: actions/setup-go@v6 + with: + go-version: "1.22" + + - name: go test (coverage) + shell: bash + run: go test -count=1 -coverprofile=coverage.out ./... + + - name: Enforce coverage threshold shell: bash run: | pct=$(go tool cover -func=coverage.out | awk '/^total:/{gsub(/%/,"",$3); print $3}') @@ -48,7 +63,6 @@ jobs: awk -v p="$pct" 'BEGIN{ if (p+0 < 80) { print "FAIL: coverage " p "% below 80% threshold"; exit 1 } print "PASS" }' - name: Upload coverage - if: matrix.os == 'ubuntu-latest' && matrix.go == '1.22' uses: actions/upload-artifact@v4 with: name: coverage @@ -88,7 +102,8 @@ jobs: run: gofusa check --format json --output gofusa-check-report.json - name: gofusa report (evidence) - run: gofusa report --format html --output gofusa-report.html || true + continue-on-error: true + run: gofusa report --format html --output gofusa-report.html - name: Upload go-FuSa evidence if: always() @@ -116,8 +131,10 @@ jobs: - name: Install gofusa run: go install github.com/SoundMatt/go-FuSa/cmd/gofusa@latest - name: Generate SARIF - run: gofusa check --format sarif --output results.sarif || true + continue-on-error: true + run: gofusa check --format sarif --output results.sarif - name: Upload SARIF to GitHub Code Scanning + if: hashFiles('results.sarif') != '' uses: github/codeql-action/upload-sarif@v3 with: sarif_file: results.sarif diff --git a/.github/workflows/tools-monitor.yml b/.github/workflows/tools-monitor.yml index 951aef2..98dd752 100644 --- a/.github/workflows/tools-monitor.yml +++ b/.github/workflows/tools-monitor.yml @@ -33,10 +33,14 @@ jobs: - uses: actions/checkout@v5 - name: Report trigger + env: + EVENT_NAME: ${{ github.event_name }} + TOOL: ${{ github.event.client_payload.tool }} + VERSION: ${{ github.event.client_payload.version }} run: | - echo "event: ${{ github.event_name }}" - echo "tool: ${{ github.event.client_payload.tool }}" - echo "version: ${{ github.event.client_payload.version }}" + echo "event: $EVENT_NAME" + echo "tool: $TOOL" + echo "version: $VERSION" - name: Log in to GHCR uses: docker/login-action@v3 diff --git a/CHANGELOG.md b/CHANGELOG.md index 660bff8..689f09d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,54 @@ and the project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0. ## [Unreleased] +## [1.148.0] — 2026-07-30 + +### fix: independently-verified third-party audit — ISO 26262 Table 4 ASIL bug, unauth arbitrary-file-read, and 13 other findings + +An external audit (`FusaOps_Audit.zip`) was checked finding-by-finding against +this repo's live source before any patch was applied — every claim below was +independently reconfirmed, not taken on the audit's word. + +- **Critical: `hara.DetermineASIL` mis-implemented ISO 26262-3:2018 Table 4.** + Every S2/S3 cell (S1 was already correct) was inflated by one to two ASIL + levels versus the standard's additive S+E+C derivation — e.g. `S3/E1/C2` + returned `ASIL-B` instead of the correct `QM`. `hara_test.go` hard-coded the + wrong values as "known-good," so a correct fix would have broken CI. Fixed + the lookup table and rewrote the test from the canonical 36-cell truth + table. The project's own `.fusa-hara.json` was regenerated: every affected + hazard/safety-goal ASIL dropped (max is now **ASIL-B**, not C), and + `.fusa.json`'s self-classification was corrected to match plus rewritten + into the canonical `configVersion`/top-level `standard`+`asil` shape + (was legacy-nested `"ISO26262"` with no `configVersion`). +- **Medium/security: unauthenticated `serve` + arbitrary file read.** + `/api/v1/diff?baseline=` took a filesystem path straight from the + query string with no validation, and `serve` runs without auth unless an + operator explicitly configures credentials. Sandboxed the baseline path to + the project root. +- **Low/security: non-constant-time Basic Auth comparison.** Credential + compare used `==`; switched to a constant-time compare. +- **Medium: non-reproducible `qualify.hash`.** The §6 integrity hash included + the live `generatedAt` timestamp and hashed components in registry order + rather than sorted — changed every run. Now blanks the timestamp and sorts + before hashing. +- **Low: `sas` never wrote its `sas.md` companion**, only the JSON — now + emitted alongside it. +- Also fixed: `vv.AchievableASIL` didn't discount reviewer/executor==author + self-attestation; history writes weren't atomic; Basic-Auth webhook fires + had no timeout; `DecisionNote` mislabeled a block-coverage proxy as true + decision coverage; diff fingerprint matching didn't qualify by language; + the scheduler's stop channel could leak; `auditpack` ignored + `SOURCE_DATE_EPOCH`; dead no-op code and an unescaped Prometheus label; + `adapter.DefaultSkipDirs` was hard-coded and unconfigurable; `Location` + had no `EndLine`/`EndColumn`; two invented DO-178C/ISO 21434 clause + citations (§6.3.4 for cyclomatic complexity; "Chapter 9" for TARA, correct + is Clause 15); README requirement-count and spec-version-label drift; CI's + self-scan SARIF step and the coverage gate were both non-gating. +- Deliberately not patched (documented in the audit's own manifest): Unicode + NFC normalization in `ComputeFingerprint` would require `golang.org/x/text`, + violating this repo's std-lib-only rule; pinning tool base images/GitHub + Actions to digests/SHAs needs live registry lookups not derivable offline. + ## [1.147.0] — 2026-07-29 ### fix: ada-FuSa ecosystem-integration gaps found by a support-surface audit diff --git a/Dockerfile b/Dockerfile index 22fde64..05b3fc7 100644 --- a/Dockerfile +++ b/Dockerfile @@ -42,6 +42,14 @@ RUN CGO_ENABLED=0 GOOS=linux go build \ # needed by pyfusa. Java is added via openjdk21-jre-headless for jfusa. FROM python:3.12-alpine +# OCI image metadata (source, description, licensing) for provenance tooling. +LABEL org.opencontainers.image.title="FuSaOps" \ + org.opencontainers.image.description="All-in-one multi-language functional-safety orchestration image bundling the x-FuSa toolchain." \ + org.opencontainers.image.source="https://github.com/SoundMatt/FuSaOps" \ + org.opencontainers.image.documentation="https://github.com/SoundMatt/FuSaOps/blob/main/README.md" \ + org.opencontainers.image.vendor="SoundMatt" \ + org.opencontainers.image.licenses="Apache-2.0" + # git + ca-certificates back the tools' provenance / vulnerability features. # libstdc++ is pre-staged so cpp-FuSa drops in without a base change. # openjdk21-jre-headless provides the `java` binary required by jfusa. @@ -66,6 +74,10 @@ COPY --from=jfusa /usr/local/bin/jfusa /usr/local/bin/jfusa # ada-FuSa: statically-linked musl binary, no extra runtime packages needed COPY --from=adafusa /usr/local/bin/adafusa /usr/local/bin/adafusa +# Run as an unprivileged user; /project is the caller-mounted workspace. +RUN addgroup -S fusa && adduser -S -G fusa -h /home/fusa fusa +USER fusa + WORKDIR /project EXPOSE 8080 ENTRYPOINT ["fusaops"] diff --git a/Makefile b/Makefile index 4edfc40..345fd44 100644 --- a/Makefile +++ b/Makefile @@ -21,7 +21,9 @@ lint: cover: go test -coverprofile=coverage.out ./... - go tool cover -func=coverage.out | tail -1 + @pct=$$(go tool cover -func=coverage.out | awk '/^total:/{gsub(/%/,"",$$3); print $$3}'); \ + printf "Coverage: %s%%\n" "$$pct"; \ + awk -v p="$$pct" 'BEGIN{ if (p+0 < 80) { print "FAIL: coverage " p "% below 80% threshold"; exit 1 } print "PASS: coverage >= 80%" }' scan: build ./$(BINARY) scan diff --git a/README.md b/README.md index 241d35c..2242bf9 100644 --- a/README.md +++ b/README.md @@ -132,7 +132,7 @@ fusaops release --output-dir dist/ # cross-language SBOM + provenance + ar fusaops safety-case --standard "DO-178C" # assemble a structured safety argument from evidence fusaops sci # Software Configuration Index (DO-178C §11.16) fusaops sas --level DAL-B # Software Accomplishment Summary (DO-178C §11.20) -fusaops tara # Threat Analysis and Risk Assessment (ISO 21434 Ch. 9) +fusaops tara # Threat Analysis and Risk Assessment (ISO 21434 Clause 15) fusaops fmea # Design FMEA of the orchestration pipeline fusaops vuln # scan dependency manifests for known vulnerabilities fusaops template --standards "ISO 26262" # generate safety documentation templates @@ -195,7 +195,7 @@ everything in parallel: ### Spec conformance (v0.3 / updated v0.4) - **`fusaops conform `** — validates any x-FuSa tool binary against the - spec v1.9 schema and behavioural invariants. Per spec §16 step 7, this is a + spec v1.15 schema and behavioural invariants. Per spec §16 step 7, this is a **MUST** gate for onboarding a new language tool. See [`docs/conformance.md`](docs/conformance.md). @@ -656,7 +656,7 @@ go-FuSa-grade evidence set. It aggregates evidence relevant to **ISO 26262, IEC 61508, ISO 21434, and DO-178C** across the languages it orchestrates. -- **Requirements** — [`.fusa-reqs.json`](.fusa-reqs.json) (215 requirements); +- **Requirements** — [`.fusa-reqs.json`](.fusa-reqs.json) (449 requirements); `gofusa trace` reports them all traced **and** tested. - **HARA** — [`.fusa-hara.json`](.fusa-hara.json) (tool-failure hazards + safety goals). - **Tool Safety Manual** — [docs/tool-safety-manual.md](docs/tool-safety-manual.md) diff --git a/adapter/adapter.go b/adapter/adapter.go index 1efffb6..3419177 100644 --- a/adapter/adapter.go +++ b/adapter/adapter.go @@ -186,9 +186,11 @@ type toolFinding struct { Severity string `json:"severity"` Message string `json:"message"` Location struct { - File string `json:"file"` - Line int `json:"line"` - Column int `json:"column"` + File string `json:"file"` + Line int `json:"line"` + Column int `json:"column"` + EndLine int `json:"endLine"` + EndColumn int `json:"endColumn"` } `json:"location"` Category string `json:"category"` Disposition string `json:"disposition"` @@ -216,7 +218,7 @@ func parseToolReport(data []byte, lang fusaops.Language, tool string) ([]fusaops RuleID: f.RuleID, Severity: normaliseSeverity(f.Severity), Message: f.Message, - Location: fusaops.Location{File: f.Location.File, Line: f.Location.Line, Column: f.Location.Column}, + Location: fusaops.Location{File: f.Location.File, Line: f.Location.Line, Column: f.Location.Column, EndLine: f.Location.EndLine, EndColumn: f.Location.EndColumn}, Category: normaliseCategory(f.Category), Disposition: f.Disposition, Standard: f.Standard, @@ -259,14 +261,22 @@ func normaliseCategory(c string) string { return "other" } +// DefaultSkipDirs is the set of directory names skipped during language +// detection. It is a package-level variable rather than a hard-coded switch so +// a caller can tailor the skip-list (e.g. remove "out"/"dist"/"target" when a +// project legitimately keeps sources there, or add project-specific excludes) +// before running detection. +// +//fusa:req REQ-FO-ADP004 +var DefaultSkipDirs = map[string]bool{ + ".git": true, ".hg": true, ".svn": true, "node_modules": true, + "vendor": true, "build": true, "dist": true, ".cache": true, + "target": true, "out": true, ".idea": true, ".vscode": true, +} + // skipDir reports whether a directory should be skipped during detection. func skipDir(name string) bool { - switch name { - case ".git", ".hg", ".svn", "node_modules", "vendor", "build", "dist", - ".cache", "target", "out", ".idea", ".vscode": - return true - } - return false + return DefaultSkipDirs[name] } // Registry holds a set of registered adapters keyed by tool name. diff --git a/auditpack/auditpack.go b/auditpack/auditpack.go index 5e5ad76..8d3d390 100644 --- a/auditpack/auditpack.go +++ b/auditpack/auditpack.go @@ -15,6 +15,7 @@ import ( "fmt" "os" "sort" + "strconv" "time" fusaops "github.com/SoundMatt/FuSaOps" @@ -61,10 +62,22 @@ type Manifest struct { // deterministic, reproducible bundle. A source whose content cannot be read is a // hard error: an audit pack must never silently omit evidence. // +// buildTime returns the timestamp stamped into the manifest and every zip +// entry. When SOURCE_DATE_EPOCH is set to a valid Unix timestamp it is honoured, +// so the archive is byte-for-byte reproducible; otherwise the current UTC time +// is used. +func buildTime() time.Time { + if v := os.Getenv("SOURCE_DATE_EPOCH"); v != "" { + if secs, err := strconv.ParseInt(v, 10, 64); err == nil { + return time.Unix(secs, 0).UTC() + } + } + return time.Now().UTC() +} + //fusa:req REQ-FO-PCK004 func Pack(dest, project string, sources []Source) (*Manifest, error) { sort.Slice(sources, func(i, j int) bool { return sources[i].ArchivePath < sources[j].ArchivePath }) - // Resolve content and hashes first so the manifest is complete before we // start writing the archive. type resolved struct { @@ -73,7 +86,7 @@ func Pack(dest, project string, sources []Source) (*Manifest, error) { } items := make([]resolved, 0, len(sources)) manifest := &Manifest{ - GeneratedAt: time.Now().UTC(), + GeneratedAt: buildTime(), Tool: "FuSaOps", Version: fusaops.Version, Project: project, diff --git a/cmd/fusaops/cmd_sas.go b/cmd/fusaops/cmd_sas.go index 74b8b53..4769481 100644 --- a/cmd/fusaops/cmd_sas.go +++ b/cmd/fusaops/cmd_sas.go @@ -6,6 +6,7 @@ import ( "io" "os" "path/filepath" + "strings" fusaops "github.com/SoundMatt/FuSaOps" "github.com/SoundMatt/FuSaOps/qualitybar" @@ -80,6 +81,26 @@ func runSAS(args []string, stdout, stderr io.Writer) int { return 1 } fmt.Fprintf(stdout, "\nSAS written to %s\n", outPath) + + // MUST-197 (§9.3): also emit the human-readable sas.md companion alongside + // the JSON so an assessor gets a readable Software Accomplishment Summary. + mdPath := strings.TrimSuffix(outPath, filepath.Ext(outPath)) + ".md" + if mdFile, mdErr := os.Create(mdPath); mdErr != nil { + fmt.Fprintf(stderr, "fusaops sas: write companion %s: %v\n", mdPath, mdErr) + return 1 + } else { + renderErr := sas.Render(mdFile, s, "text") + closeErr := mdFile.Close() + if renderErr != nil { + fmt.Fprintf(stderr, "fusaops sas: render companion: %v\n", renderErr) + return 1 + } + if closeErr != nil { + fmt.Fprintf(stderr, "fusaops sas: write companion %s: %v\n", mdPath, closeErr) + return 1 + } + fmt.Fprintf(stdout, "SAS companion written to %s\n", mdPath) + } fmt.Fprintf(stdout, "Integrity hash: %s\n", s.Hash) exit := 0 diff --git a/cmd/fusaops/cmd_serve.go b/cmd/fusaops/cmd_serve.go index 80897e5..cb8069f 100644 --- a/cmd/fusaops/cmd_serve.go +++ b/cmd/fusaops/cmd_serve.go @@ -49,7 +49,7 @@ func runServe(args []string, stdout, stderr io.Writer) int { } // Validate shared flags. - rwUser, rwPass, authOK := "", "", true + rwUser, rwPass := "", "" if *auth != "" { var ok bool rwUser, rwPass, ok = strings.Cut(*auth, ":") @@ -57,7 +57,6 @@ func runServe(args []string, stdout, stderr io.Writer) int { fmt.Fprintln(stderr, "fusaops serve: --auth must be in user:pass format") return 1 } - authOK = true } roUser, roPass := "", "" if *authRO != "" { @@ -81,12 +80,14 @@ func runServe(args []string, stdout, stderr io.Writer) int { return 1 } } - _ = authOK scheme := "http" if *tlsCert != "" { scheme = "https" } + if scheme == "http" && (*auth != "" || *authRO != "") { + fmt.Fprintln(stderr, "fusaops serve: warning: --auth enabled without TLS; credentials travel in cleartext (use --tls-cert/--tls-key)") + } // Multi-project mode. if *projectsCfg != "" { diff --git a/cmd/fusaops/cmd_v147_v150_test.go b/cmd/fusaops/cmd_v147_v150_test.go index 2ebc4cb..6c49880 100644 --- a/cmd/fusaops/cmd_v147_v150_test.go +++ b/cmd/fusaops/cmd_v147_v150_test.go @@ -786,8 +786,8 @@ func TestHaraASIL(t *testing.T) { if code != 0 { t.Fatalf("hara asil: code=%d err=%q", code, errb) } - if !strings.Contains(out, "ASIL-B") { - t.Errorf("hara asil output: %q, want ASIL-B", out) + if !strings.Contains(out, "ASIL-A") { + t.Errorf("hara asil output: %q, want ASIL-A", out) } } diff --git a/comp/comp.go b/comp/comp.go index 7efa1e1..45ce064 100644 --- a/comp/comp.go +++ b/comp/comp.go @@ -76,7 +76,9 @@ func New(root, project string, components []ComponentComp) *Aggregate { func (a *Aggregate) HasViolations() bool { return a.Violations > 0 } // DALThreshold returns the McCabe V(G) threshold for the given DAL string -// (DAL-A → 4, DAL-B → 10, DAL-C → 15, DAL-D → 20) per DO-178C §6.3.4. +// (DAL-A → 4, DAL-B → 10, DAL-C → 15, DAL-D → 20). These are a project/McCabe +// convention, not a normative DO-178C mandate — DO-178C does not prescribe +// specific cyclomatic-complexity limits. // Returns 0 (no threshold) for an unrecognised or empty DAL. // //fusa:req REQ-FO-COMP001 diff --git a/config/config.go b/config/config.go index d00ee2e..733f446 100644 --- a/config/config.go +++ b/config/config.go @@ -223,7 +223,7 @@ func Validate(cfg *Config) error { return fmt.Errorf("%w: missing project.name", fusaops.ErrInvalidConfig) } switch cfg.Report.Format { - case "", "text", "json", "html", "sarif": + case "", "text", "json", "html", "sarif", "junit", "csv", "markdown": default: return fmt.Errorf("%w: unsupported report.format %q", fusaops.ErrInvalidConfig, cfg.Report.Format) } diff --git a/conform/conform.go b/conform/conform.go index 1fff961..bcca3d3 100644 --- a/conform/conform.go +++ b/conform/conform.go @@ -1,4 +1,4 @@ -// Package conform runs the x-FuSa spec v1.10 conformance checks against a tool binary. +// Package conform runs the x-FuSa spec v1.15 conformance checks against a tool binary. // // It creates a temporary project, invokes each required command, and validates the // output shapes and invariants defined in the spec. No real binary is needed in unit diff --git a/coverage/coverage.go b/coverage/coverage.go index d0b7897..ba12e9b 100644 --- a/coverage/coverage.go +++ b/coverage/coverage.go @@ -152,7 +152,9 @@ func Analyse(blocks []Block, dal DAL) *Report { sort.Slice(rep.Files, func(i, j int) bool { return rep.Files[i].File < rep.Files[j].File }) sort.Strings(rep.Gaps) - // Decision coverage: approximate from block-level hit ratio. + // Decision coverage: this is NOT true decision (branch true/false) coverage. + // It is the block-hit ratio (an upper-bound proxy), surfaced here only as an + // indicator. Real DC/MC-DC evidence requires the --mcdc path below. totalBlocks, coveredBlocks := 0, 0 for _, b := range blocks { totalBlocks++ @@ -162,7 +164,7 @@ func Analyse(blocks []Block, dal DAL) *Report { } if totalBlocks > 0 { rep.DecisionPct = float64(coveredBlocks) * 100 / float64(totalBlocks) - rep.DecisionNote = "approximated from block coverage — use '-covermode=atomic' for best accuracy" + rep.DecisionNote = "block-coverage proxy — NOT true decision coverage; provide MC/DC data for certification-grade DC evidence" } if rep.MCDCRequired { diff --git a/diff/diff.go b/diff/diff.go index f390dc2..2fc513c 100644 --- a/diff/diff.go +++ b/diff/diff.go @@ -156,18 +156,29 @@ func fp(f fusaops.Finding) string { return fusaops.ComputeFingerprint(f) } +// matchKey is the cross-language aggregate matching key. The bare §4.2 +// fingerprint is only tool-local (RuleID+File+message), so two findings from +// different languages that share a ruleId/file/message collide to one +// fingerprint; qualifying by Language keeps them distinct so a genuinely new +// finding cannot be masked by a same-fingerprint baseline finding from another +// language. +func matchKey(f fusaops.Finding) string { + return string(f.Language) + "\x1f" + fp(f) +} + // Compare returns the diff between baseline and current findings. -// Findings are matched by fingerprint; if absent, ComputeFingerprint is used. +// Findings are matched by (language, fingerprint); if the fingerprint is absent, +// ComputeFingerprint is used. // //fusa:req REQ-FO-DIF002 func Compare(baseline *Baseline, current []fusaops.Finding) *Result { baseSet := make(map[string]fusaops.Finding, len(baseline.Findings)) for _, f := range baseline.Findings { - baseSet[fp(f)] = f + baseSet[matchKey(f)] = f } currSet := make(map[string]fusaops.Finding, len(current)) for _, f := range current { - currSet[fp(f)] = f + currSet[matchKey(f)] = f } var res Result diff --git a/docs/qualification.md b/docs/qualification.md index e2e8298..d4e5452 100644 --- a/docs/qualification.md +++ b/docs/qualification.md @@ -37,7 +37,7 @@ posture (see `.fusa.json`, `.fusa-hara.json`). |---|---| | Faithful aggregation | Unit tests assert every tool finding survives normalisation; unknown severities normalise to INFO, never dropped (SG-001) | | Visible coverage gaps | Applicable-but-uninstalled or failed tools are recorded as skipped components, never omitted (SG-002) | -| Correct gating | `fusaops check` exits non-zero on any ERROR finding; tested for exit-code invariance (SG-003) | +| Correct gating | `fusaops check` exits non-zero on any ERROR finding (except findings already dispositioned `accepted`/`deferred` upstream — see §4.1); tested for exit-code invariance (SG-003) | | Correct attribution | Findings carry originating language + tool through aggregation (SG-004) | | Requirement traceability | `gofusa trace` reports every requirement traced **and** tested | | Self-check | go-FuSa runs `gofusa check` on FuSaOps's own Go source in CI | @@ -67,5 +67,8 @@ Record the outputs as qualification evidence alongside the committed bundled tool is a false negative in FuSaOps. Qualify each tool separately. - A language with no installed adapter is reported as a **skipped** component, not a failure — treat skipped components as coverage gaps in your assessment. -- The bundled image pins tool versions at build time; verify the bundled - versions (`fusaops adapters`, each tool's `version`) match your qualified set. +- The bundled image tracks each tool's `:latest` image and is periodically + rebuilt (see `.github/workflows/tools-monitor.yml`), so the tool set is **not** + pinned by tag. Resolve and record the concrete image digests and each tool's + reported `version` (`fusaops adapters`, each tool's `version`) at build time, + and treat any change to the resolved digests as a re-qualification trigger. diff --git a/fusaops.go b/fusaops.go index 742ecfc..01e8f7d 100644 --- a/fusaops.go +++ b/fusaops.go @@ -20,7 +20,7 @@ import ( ) // Version is the current release of FuSaOps. -const Version = "1.147.0" +const Version = "1.148.0" // SpecVersion is the x-FuSa specification version this release targets. // @@ -195,4 +195,10 @@ type Location struct { File string `json:"file"` Line int `json:"line,omitempty"` Column int `json:"column,omitempty"` + // EndLine/EndColumn carry the §4 region extent so a multi-line/column + // finding round-trips (e.g. into SARIF regions) instead of being collapsed + // to a single point on ingest. Both are omitempty (a point finding omits + // them entirely). + EndLine int `json:"endLine,omitempty"` + EndColumn int `json:"endColumn,omitempty"` } diff --git a/fusaops_test.go b/fusaops_test.go index 897ef94..628446e 100644 --- a/fusaops_test.go +++ b/fusaops_test.go @@ -189,3 +189,20 @@ func TestFindingQualifiedRuleID(t *testing.T) { t.Errorf("QualifiedRuleID() = %q, want %q", got, "go/LINT001") } } + +//fusa:test REQ-FO-CORE010 +func TestCanonicalStandardID(t *testing.T) { + cases := []struct{ in, want string }{ + {"ISO 26262", "iso26262"}, + {"DO-178C", "do178c"}, + {"IEC 61508", "iec61508"}, + {"ISO 21434", "iso21434"}, + {"iso26262", "iso26262"}, + {"Nonstandard Value", "Nonstandard Value"}, // passthrough + } + for _, c := range cases { + if got := CanonicalStandardID(c.in); got != c.want { + t.Errorf("CanonicalStandardID(%q) = %q, want %q", c.in, got, c.want) + } + } +} diff --git a/hara/hara.go b/hara/hara.go index 5990741..4bca62b 100644 --- a/hara/hara.go +++ b/hara/hara.go @@ -221,32 +221,32 @@ func DetermineASIL(s Severity, e Exposure, c Controllability) ASIL { {SeverityS2, ExposureE1, ControllabilityC3}: ASILQM, {SeverityS2, ExposureE2, ControllabilityC0}: ASILQM, {SeverityS2, ExposureE2, ControllabilityC1}: ASILQM, - {SeverityS2, ExposureE2, ControllabilityC2}: ASILA, - {SeverityS2, ExposureE2, ControllabilityC3}: ASILB, + {SeverityS2, ExposureE2, ControllabilityC2}: ASILQM, + {SeverityS2, ExposureE2, ControllabilityC3}: ASILA, {SeverityS2, ExposureE3, ControllabilityC0}: ASILQM, - {SeverityS2, ExposureE3, ControllabilityC1}: ASILA, - {SeverityS2, ExposureE3, ControllabilityC2}: ASILB, - {SeverityS2, ExposureE3, ControllabilityC3}: ASILC, - {SeverityS2, ExposureE4, ControllabilityC0}: ASILA, - {SeverityS2, ExposureE4, ControllabilityC1}: ASILB, - {SeverityS2, ExposureE4, ControllabilityC2}: ASILC, - {SeverityS2, ExposureE4, ControllabilityC3}: ASILD, + {SeverityS2, ExposureE3, ControllabilityC1}: ASILQM, + {SeverityS2, ExposureE3, ControllabilityC2}: ASILA, + {SeverityS2, ExposureE3, ControllabilityC3}: ASILB, + {SeverityS2, ExposureE4, ControllabilityC0}: ASILQM, + {SeverityS2, ExposureE4, ControllabilityC1}: ASILA, + {SeverityS2, ExposureE4, ControllabilityC2}: ASILB, + {SeverityS2, ExposureE4, ControllabilityC3}: ASILC, // S3 {SeverityS3, ExposureE1, ControllabilityC0}: ASILQM, - {SeverityS3, ExposureE1, ControllabilityC1}: ASILA, - {SeverityS3, ExposureE1, ControllabilityC2}: ASILB, - {SeverityS3, ExposureE1, ControllabilityC3}: ASILC, - {SeverityS3, ExposureE2, ControllabilityC0}: ASILA, - {SeverityS3, ExposureE2, ControllabilityC1}: ASILB, - {SeverityS3, ExposureE2, ControllabilityC2}: ASILC, - {SeverityS3, ExposureE2, ControllabilityC3}: ASILD, - {SeverityS3, ExposureE3, ControllabilityC0}: ASILB, - {SeverityS3, ExposureE3, ControllabilityC1}: ASILC, - {SeverityS3, ExposureE3, ControllabilityC2}: ASILD, - {SeverityS3, ExposureE3, ControllabilityC3}: ASILD, - {SeverityS3, ExposureE4, ControllabilityC0}: ASILC, - {SeverityS3, ExposureE4, ControllabilityC1}: ASILD, - {SeverityS3, ExposureE4, ControllabilityC2}: ASILD, + {SeverityS3, ExposureE1, ControllabilityC1}: ASILQM, + {SeverityS3, ExposureE1, ControllabilityC2}: ASILQM, + {SeverityS3, ExposureE1, ControllabilityC3}: ASILA, + {SeverityS3, ExposureE2, ControllabilityC0}: ASILQM, + {SeverityS3, ExposureE2, ControllabilityC1}: ASILQM, + {SeverityS3, ExposureE2, ControllabilityC2}: ASILA, + {SeverityS3, ExposureE2, ControllabilityC3}: ASILB, + {SeverityS3, ExposureE3, ControllabilityC0}: ASILQM, + {SeverityS3, ExposureE3, ControllabilityC1}: ASILA, + {SeverityS3, ExposureE3, ControllabilityC2}: ASILB, + {SeverityS3, ExposureE3, ControllabilityC3}: ASILC, + {SeverityS3, ExposureE4, ControllabilityC0}: ASILQM, + {SeverityS3, ExposureE4, ControllabilityC1}: ASILB, + {SeverityS3, ExposureE4, ControllabilityC2}: ASILC, {SeverityS3, ExposureE4, ControllabilityC3}: ASILD, } if a, ok := table[key{s, e, c}]; ok { diff --git a/hara/hara_test.go b/hara/hara_test.go index 0f6c2f5..2712483 100644 --- a/hara/hara_test.go +++ b/hara/hara_test.go @@ -73,17 +73,19 @@ func TestDetermineASILE0AlwaysQM(t *testing.T) { //fusa:test REQ-FO-HARA004 func TestDetermineASILKnownValues(t *testing.T) { + // Corrected against ISO 26262-3:2018 Table 4. C0 (controllable in general) + // maps to QM; C1..C3 follow the canonical table. cases := []struct { s hara.Severity e hara.Exposure c hara.Controllability want hara.ASIL }{ - {hara.SeverityS2, hara.ExposureE3, hara.ControllabilityC2, hara.ASILB}, + {hara.SeverityS2, hara.ExposureE3, hara.ControllabilityC2, hara.ASILA}, {hara.SeverityS3, hara.ExposureE4, hara.ControllabilityC3, hara.ASILD}, {hara.SeverityS1, hara.ExposureE1, hara.ControllabilityC0, hara.ASILQM}, - {hara.SeverityS3, hara.ExposureE1, hara.ControllabilityC3, hara.ASILC}, - {hara.SeverityS2, hara.ExposureE4, hara.ControllabilityC0, hara.ASILA}, + {hara.SeverityS3, hara.ExposureE1, hara.ControllabilityC3, hara.ASILA}, + {hara.SeverityS2, hara.ExposureE4, hara.ControllabilityC0, hara.ASILQM}, } for _, tc := range cases { got := hara.DetermineASIL(tc.s, tc.e, tc.c) @@ -93,6 +95,53 @@ func TestDetermineASILKnownValues(t *testing.T) { } } +//fusa:test REQ-FO-HARA004 +func TestDetermineASILAll36Cells(t *testing.T) { + // Independent oracle for ISO 26262-3:2018 Table 4: for C1..C3 the ASIL is a + // function of the additive index S+E+C (S1=1..S3=3, E1=1..E4=4, C1=1..C3=3): + // <=6 QM, 7 A, 8 B, 9 C, 10 D. C0 is always QM. + sVals := []struct { + s hara.Severity + n int + }{{hara.SeverityS1, 1}, {hara.SeverityS2, 2}, {hara.SeverityS3, 3}} + eVals := []struct { + e hara.Exposure + n int + }{{hara.ExposureE1, 1}, {hara.ExposureE2, 2}, {hara.ExposureE3, 3}, {hara.ExposureE4, 4}} + cVals := []struct { + c hara.Controllability + n int + }{{hara.ControllabilityC1, 1}, {hara.ControllabilityC2, 2}, {hara.ControllabilityC3, 3}} + oracle := func(t int) hara.ASIL { + switch { + case t >= 10: + return hara.ASILD + case t == 9: + return hara.ASILC + case t == 8: + return hara.ASILB + case t == 7: + return hara.ASILA + default: + return hara.ASILQM + } + } + for _, sv := range sVals { + for _, ev := range eVals { + for _, cv := range cVals { + want := oracle(sv.n + ev.n + cv.n) + if got := hara.DetermineASIL(sv.s, ev.e, cv.c); got != want { + t.Errorf("DetermineASIL(%s,%s,%s)=%s, want %s", sv.s, ev.e, cv.c, got, want) + } + } + // C0 always QM. + if got := hara.DetermineASIL(sv.s, ev.e, hara.ControllabilityC0); got != hara.ASILQM { + t.Errorf("DetermineASIL(%s,%s,C0)=%s, want QM", sv.s, ev.e, got) + } + } + } +} + //fusa:test REQ-FO-HARA002 func TestMaxASIL(t *testing.T) { hazards := []hara.Hazard{ @@ -125,12 +174,12 @@ func TestValidateComplete(t *testing.T) { Hazards: []hara.Hazard{ { ID: "H-001", Description: "test hazard", - Risk: hara.RiskRating{Severity: hara.SeverityS2, Exposure: hara.ExposureE3, Controllability: hara.ControllabilityC2, ASIL: hara.ASILB}, + Risk: hara.RiskRating{Severity: hara.SeverityS2, Exposure: hara.ExposureE3, Controllability: hara.ControllabilityC2, ASIL: hara.ASILA}, SafetyGoals: []string{"SG-001"}, }, }, SafetyGoals: []hara.SafetyGoal{ - {ID: "SG-001", Description: "test goal", ASIL: hara.ASILB, FssrRefs: []string{"REQ-FO-HARA001"}}, + {ID: "SG-001", Description: "test goal", ASIL: hara.ASILA, FssrRefs: []string{"REQ-FO-HARA001"}}, }, } findings := hara.Validate(h) @@ -477,3 +526,26 @@ func TestMaxASILUnknownASIL(t *testing.T) { t.Errorf("MaxASIL with unknown ASIL: got %q, want %q", got, hara.ASILQM) } } + +//fusa:test REQ-FO-HARA007 +func TestValidateFssrRefs(t *testing.T) { + h := &hara.HARA{ + SafetyGoals: []hara.SafetyGoal{ + {ID: "SG-01", FssrRefs: []string{"REQ-1", "REQ-2"}}, + {ID: "SG-02", FssrRefs: []string{"REQ-MISSING"}}, + }, + } + reqIDs := map[string]bool{"REQ-1": true, "REQ-2": true} + got := hara.ValidateFssrRefs(h, reqIDs) + if len(got) != 1 { + t.Fatalf("expected 1 dangling-ref finding, got %d: %+v", len(got), got) + } + if got[0].SafetyGoalID != "SG-02" { + t.Errorf("dangling ref should be on SG-02, got %q", got[0].SafetyGoalID) + } + // All-valid case yields no findings. + h2 := &hara.HARA{SafetyGoals: []hara.SafetyGoal{{ID: "SG-03", FssrRefs: []string{"REQ-1"}}}} + if n := len(hara.ValidateFssrRefs(h2, reqIDs)); n != 0 { + t.Errorf("expected no findings for valid refs, got %d", n) + } +} diff --git a/history/history.go b/history/history.go index 9dc68a9..636766d 100644 --- a/history/history.go +++ b/history/history.go @@ -12,6 +12,7 @@ import ( "encoding/json" "os" "path/filepath" + "sync" "time" fusaops "github.com/SoundMatt/FuSaOps" @@ -26,6 +27,11 @@ const ( MaxSnapshots = 100 ) +// storeMu serialises the non-atomic read-modify-write of the history file so +// concurrent compute paths (startup, scheduler, /refresh) cannot race and lose +// or corrupt snapshots. +var storeMu sync.Mutex + // LanguageSummary records per-language finding counts in a snapshot. // //fusa:req REQ-FO-HST001 @@ -61,7 +67,7 @@ func FromReport(rep *report.AggregateReport) Snapshot { Warnings: rep.Summary.Warnings, Infos: rep.Summary.Infos, } - if rep.Summary.Errors > 0 { + if rep.HasErrors() { s.Status = "FAIL" } for _, c := range rep.Components { @@ -88,6 +94,8 @@ func FromReport(rep *report.AggregateReport) Snapshot { // //fusa:req REQ-FO-HST002 func Store(dir string, snap Snapshot) error { + storeMu.Lock() + defer storeMu.Unlock() path := filepath.Join(dir, Filename) existing, _ := loadAll(path) @@ -146,6 +154,8 @@ func loadAll(path string) ([]Snapshot, error) { // //fusa:req REQ-FO-HST005 func Prune(dir string, keep int) (int, error) { + storeMu.Lock() + defer storeMu.Unlock() if keep <= 0 { keep = MaxSnapshots } @@ -161,17 +171,34 @@ func Prune(dir string, keep int) (int, error) { return removed, writeAll(path, all[removed:]) } +// writeAll writes snaps atomically: it encodes to a temp file in the same +// directory (mode 0640, matching the tool's other state files rather than the +// world-readable 0644 of os.Create) then renames it over path, so a crash or a +// concurrent read never observes a truncated history file. func writeAll(path string, snaps []Snapshot) error { - f, err := os.Create(path) + tmp, err := os.CreateTemp(filepath.Dir(path), ".fusaops-history-*.tmp") if err != nil { return err } - defer f.Close() - enc := json.NewEncoder(f) + tmpName := tmp.Name() + defer func() { + tmp.Close() + os.Remove(tmpName) // no-op after a successful rename + }() + if err := tmp.Chmod(0o640); err != nil { + return err + } + enc := json.NewEncoder(tmp) for _, s := range snaps { if err := enc.Encode(s); err != nil { return err } } - return nil + if err := tmp.Sync(); err != nil { + return err + } + if err := tmp.Close(); err != nil { + return err + } + return os.Rename(tmpName, path) } diff --git a/mcdc/mcdc.go b/mcdc/mcdc.go index 2a2bebc..17a0008 100644 --- a/mcdc/mcdc.go +++ b/mcdc/mcdc.go @@ -1,7 +1,7 @@ // Package mcdc decodes and aggregates Modified Condition/Decision Coverage (MC/DC) // reports produced by x-FuSa tools per §9.4 of the x-FuSa spec. // -// MC/DC is required for DO-178C Level A software and IS0 26262 ASIL D. Each +// MC/DC is required for DO-178C Level A software and ISO 26262 ASIL D. Each // x-FuSa tool that supports the --mcdc flag emits a structured MC/DC report; // FuSaOps collects those per-tool reports and rolls them up into a single // cross-language MCDCAggregate. diff --git a/qualify/qualify.go b/qualify/qualify.go index 4f10d88..2c69e21 100644 --- a/qualify/qualify.go +++ b/qualify/qualify.go @@ -15,6 +15,7 @@ import ( "io" "os" "runtime" + "sort" "sync" "time" @@ -294,11 +295,21 @@ 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. +// 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). Per x-FuSa spec MUST-145/148 the volatile generatedAt +// timestamp is blanked before hashing (otherwise the integrity hash changes on +// every run and cannot anchor tamper/regression detection); per MUST-146 the +// components are hashed in a name-sorted order independent of registry order. func computeHash(r *Report) string { + comps := make([]ComponentResult, len(r.Components)) + copy(comps, r.Components) + sort.Slice(comps, func(i, j int) bool { + if comps[i].Language != comps[j].Language { + return comps[i].Language < comps[j].Language + } + return comps[i].Tool < comps[j].Tool + }) data, _ := json.Marshal(struct { GeneratedAt time.Time `json:"generatedAt"` QualificationType string `json:"qualificationType,omitempty"` @@ -313,10 +324,11 @@ func computeHash(r *Report) string { Failed int `json:"failed"` Components []ComponentResult `json:"components"` }{ - r.GeneratedAt, r.QualificationType, r.QualificationRecordUri, + // generatedAt deliberately left as the zero time so the hash is stable. + time.Time{}, r.QualificationType, r.QualificationRecordUri, r.QualificationMethod, r.QualifierIdentity, r.ImplementationAuthor, r.IndependentReviewer, r.AchievableASIL, - r.Total, r.Passed, r.Failed, r.Components, + r.Total, r.Passed, r.Failed, comps, }) canon, err := fusaops.Canonicalize(data) if err != nil { diff --git a/report/report.go b/report/report.go index 7bacf13..0dff5b9 100644 --- a/report/report.go +++ b/report/report.go @@ -266,6 +266,24 @@ func (r *AggregateReport) HasErrors() bool { return false } +// Status returns the overall PASS/WARN/FAIL verdict for the aggregate report +// using the *gating* error count (x-FuSa spec §4.1): findings an upstream tool +// already dispositioned "accepted"/"deferred" are counted in Summary.Errors for +// display but MUST NOT drive a FAIL verdict. Badge, dashboard, metrics and +// history status therefore agree with `fusaops check`'s exit code (HasErrors). +// +//fusa:req REQ-FO-RPT002 +func (r *AggregateReport) Status() string { + switch { + case r.HasErrors(): + return "FAIL" + case r.Summary.Warnings > 0: + return "WARN" + default: + return "PASS" + } +} + // Render writes r to w in the requested format. // //fusa:req REQ-FO-RPT007 diff --git a/server/server.go b/server/server.go index 6d56c5c..5d3369b 100644 --- a/server/server.go +++ b/server/server.go @@ -10,6 +10,7 @@ package server import ( "context" + "crypto/subtle" "crypto/tls" "encoding/json" "fmt" @@ -203,7 +204,7 @@ func (s *Server) compute(ctx context.Context) { s.cached, s.err = rep, err newStatus := "" if err == nil && rep != nil { - newStatus = rep.Summary.Status() + newStatus = rep.Status() s.prevStatus = newStatus } s.mu.Unlock() @@ -293,6 +294,17 @@ func (s *Server) Handler() http.Handler { // are rejected with 403 Forbidden on these paths. var mutatingPaths = map[string]bool{"/refresh": true} +// credEqual compares a supplied username/password pair against the expected +// values in constant time (crypto/subtle), so neither field's length nor prefix +// leaks via response timing. Both fields are always compared (no early-out). +// +//fusa:req REQ-FO-AUTH001 +func credEqual(gotUser, wantUser, gotPass, wantPass string) bool { + uOK := subtle.ConstantTimeCompare([]byte(gotUser), []byte(wantUser)) == 1 + pOK := subtle.ConstantTimeCompare([]byte(gotPass), []byte(wantPass)) == 1 + return uOK && pOK +} + // authMiddleware wraps h with HTTP Basic Auth and optional role gating. // Unauthenticated requests receive 401; ro credentials on mutating paths get 403. // @@ -305,9 +317,9 @@ func (s *Server) authMiddleware(h http.Handler) http.Handler { u, p, ok := r.BasicAuth() role := "" if ok { - if s.authUser != "" && u == s.authUser && p == s.authPass { + if s.authUser != "" && credEqual(u, s.authUser, p, s.authPass) { role = "rw" - } else if s.authROUser != "" && u == s.authROUser && p == s.authROPass { + } else if s.authROUser != "" && credEqual(u, s.authROUser, p, s.authROPass) { role = "ro" } } @@ -605,6 +617,28 @@ func exportMIME(format string) (contentType, ext string) { } } +// baselineWithinRoot reports whether a user-supplied baseline path resolves to +// a location inside root. Absolute paths and paths escaping root via ".." are +// rejected. root is treated as "." when empty. +func baselineWithinRoot(root, p string) bool { + if filepath.IsAbs(p) { + return false + } + if root == "" { + root = "." + } + absRoot, err := filepath.Abs(root) + if err != nil { + return false + } + target := filepath.Clean(filepath.Join(absRoot, p)) + rel, err := filepath.Rel(absRoot, target) + if err != nil { + return false + } + return rel != ".." && !strings.HasPrefix(rel, ".."+string(filepath.Separator)) +} + // handleAPIDiff compares the cached report against a baseline and returns the // delta. The baseline path is taken from ?baseline= (required unless // --baseline was given at startup). ?strict=true causes a 409 response when @@ -624,6 +658,7 @@ func (s *Server) handleAPIDiff(w http.ResponseWriter, r *http.Request) { return } baselinePath := r.URL.Query().Get("baseline") + fromQuery := baselinePath != "" if baselinePath == "" { baselinePath = s.baselineFile } @@ -631,6 +666,23 @@ func (s *Server) handleAPIDiff(w http.ResponseWriter, r *http.Request) { http.Error(w, "baseline path required: set ?baseline= or configure --baseline", http.StatusBadRequest) return } + // A ?baseline= value is attacker-controlled, so sandbox it to the project + // root to prevent arbitrary file reads (e.g. ?baseline=/etc/passwd or + // ../../secret). The trusted --baseline value configured at startup is not + // subject to this restriction. + if fromQuery { + if !baselineWithinRoot(s.root, baselinePath) { + http.Error(w, "baseline path must be within the project root", http.StatusForbidden) + return + } + // Resolve the (validated) relative path against the project root so it + // is loaded from the sandbox, not the server's working directory. + root := s.root + if root == "" { + root = "." + } + baselinePath = filepath.Join(root, baselinePath) + } bl, err := diff.LoadBaseline(baselinePath) if err != nil { http.Error(w, "load baseline: "+err.Error(), http.StatusBadRequest) @@ -945,26 +997,35 @@ func (s *Server) Serve(ln net.Listener) error { ctx, cancel := context.WithTimeout(context.Background(), 5*time.Minute) s.compute(ctx) cancel() + stop := make(chan struct{}) if s.refreshInterval > 0 { - go s.runScheduler() + go s.runScheduler(stop) } srv := &http.Server{ Handler: s.Handler(), ReadHeaderTimeout: 10 * time.Second, } - return srv.Serve(ln) + err := srv.Serve(ln) + close(stop) // signal the scheduler goroutine to exit when serving stops + return err } -// runScheduler fires periodic rescans on s.refreshInterval until the process exits. +// runScheduler fires periodic rescans on s.refreshInterval until stop is closed +// (i.e. until Serve returns / the listener is closed). // //fusa:req REQ-FO-SCHD001 -func (s *Server) runScheduler() { +func (s *Server) runScheduler(stop <-chan struct{}) { ticker := time.NewTicker(s.refreshInterval) defer ticker.Stop() - for range ticker.C { - ctx, cancel := context.WithTimeout(context.Background(), 5*time.Minute) - s.compute(ctx) - cancel() + for { + select { + case <-stop: + return + case <-ticker.C: + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Minute) + s.compute(ctx) + cancel() + } } } @@ -980,7 +1041,7 @@ func (s *Server) handleBadge(w http.ResponseWriter, _ *http.Request) { if cErr != nil { status, color = "error", "#e05d44" } else if rep != nil { - switch rep.Summary.Status() { + switch rep.Status() { case "PASS": status, color = "pass", "#4c1" case "WARN": @@ -1358,8 +1419,17 @@ func svgBadge(label, message, color string) string { //fusa:req REQ-FO-HOOK002 func fireWebhook(url, prev, current string, errors int) { body := fmt.Sprintf(`{"status":%q,"prev":%q,"errors":%d}`+"\n", current, prev, errors) + // Bound every attempt: the default http client has no timeout, so a + // slow/black-hole endpoint would otherwise block this goroutine forever + // and leak a goroutine per status change. + client := &http.Client{Timeout: 10 * time.Second} for i := range 2 { - resp, err := http.Post(url, "application/json", strings.NewReader(body)) //nolint:noctx + req, err := http.NewRequest(http.MethodPost, url, strings.NewReader(body)) + if err != nil { + return + } + req.Header.Set("Content-Type", "application/json") + resp, err := client.Do(req) if err == nil { _ = resp.Body.Close() return @@ -1408,7 +1478,7 @@ func buildMetrics(rep *report.AggregateReport, rErr error, project string, cd *c if rErr != nil { status = "error" } else if rep != nil { - status = rep.Summary.Status() + status = rep.Status() errors = rep.Summary.Errors warnings = rep.Summary.Warnings infos = rep.Summary.Total - errors - warnings @@ -1428,12 +1498,13 @@ func buildMetrics(rep *report.AggregateReport, rErr error, project string, cd *c plabel := "" if project != "" { - plabel = `project="` + project + `",` + // Escape the label value per the OpenMetrics text format (backslash, + // double-quote, newline) so a project name containing " or \ cannot + // produce malformed output. + esc := strings.NewReplacer(`\`, `\\`, `"`, `\"`, "\n", `\n`).Replace(project) + plabel = `project="` + esc + `",` } plabelTrimmed := strings.TrimSuffix(plabel, ",") - if plabelTrimmed == "" { - plabelTrimmed = "" - } var b strings.Builder b.WriteString("# HELP fusaops_findings_total Total findings by severity\n") diff --git a/server/server_test.go b/server/server_test.go index 4c90176..7077a14 100644 --- a/server/server_test.go +++ b/server/server_test.go @@ -901,12 +901,12 @@ func TestAPIDiffNoBaseline(t *testing.T) { //fusa:test REQ-FO-SRV007 func TestAPIDiffWithBaseline(t *testing.T) { s := newTestServer(t) - bl := filepath.Join(t.TempDir(), "baseline.json") + bl := filepath.Join(s.root, "baseline.json") if err := diff.SaveBaseline(bl, []fusaops.Finding{}); err != nil { t.Fatalf("save baseline: %v", err) } rec := httptest.NewRecorder() - s.Handler().ServeHTTP(rec, httptest.NewRequest(http.MethodGet, "/api/v1/diff?baseline="+bl, nil)) + s.Handler().ServeHTTP(rec, httptest.NewRequest(http.MethodGet, "/api/v1/diff?baseline=baseline.json", nil)) if rec.Code != http.StatusOK { t.Errorf("status: got %d, want 200", rec.Code) } @@ -926,12 +926,12 @@ func TestAPIDiffStrictConflict(t *testing.T) { }}) s := New(t.TempDir(), orchestrator.New(reg), orchestrator.Options{}) s.compute(context.Background()) - bl := filepath.Join(t.TempDir(), "baseline.json") + bl := filepath.Join(s.root, "baseline.json") if err := diff.SaveBaseline(bl, []fusaops.Finding{}); err != nil { t.Fatalf("save baseline: %v", err) } rec := httptest.NewRecorder() - s.Handler().ServeHTTP(rec, httptest.NewRequest(http.MethodGet, "/api/v1/diff?baseline="+bl+"&strict=true", nil)) + s.Handler().ServeHTTP(rec, httptest.NewRequest(http.MethodGet, "/api/v1/diff?baseline=baseline.json&strict=true", nil)) if rec.Code != http.StatusConflict { t.Errorf("status: got %d, want 409", rec.Code) } diff --git a/tara/tara.go b/tara/tara.go index bb0051b..ee29043 100644 --- a/tara/tara.go +++ b/tara/tara.go @@ -1,5 +1,5 @@ // Package tara generates a Threat Analysis and Risk Assessment (TARA) per -// ISO 21434:2021 Chapter 9 for the FuSaOps software development toolchain. +// ISO 21434:2021 Clause 15 for the FuSaOps software development toolchain. // // Build produces a fixed set of cybersecurity threat scenarios relevant to a // multi-language safety-analysis pipeline. Each scenario carries an impact @@ -61,7 +61,7 @@ const ( RiskLow RiskLevel = "low" ) -// TreatmentDecision is the recommended risk treatment per ISO 21434 §9.4. +// TreatmentDecision is the recommended risk treatment per ISO 21434 §15.9. // //fusa:req REQ-FO-TARA001 type TreatmentDecision string @@ -240,7 +240,7 @@ type scenarioSpec struct { } // standardScenarios covers cybersecurity threats to a multi-language -// safety-analysis toolchain per ISO 21434:2021 Chapter 9. +// safety-analysis toolchain per ISO 21434:2021 Clause 15. var standardScenarios = []scenarioSpec{ { id: "TS-001", @@ -352,7 +352,7 @@ func Build(root string) (*TARA, error) { ProjectRoot: root, Tool: "fusaops", ToolVersion: fusaops.Version, - Standard: fusaops.CanonicalStandardID("ISO 21434"), // ISO/SAE 21434:2021 Chapter 9 + Standard: fusaops.CanonicalStandardID("ISO 21434"), // ISO/SAE 21434:2021 Clause 15 } for _, spec := range standardScenarios { diff --git a/vv/vv.go b/vv/vv.go index 839f823..f78648e 100644 --- a/vv/vv.go +++ b/vv/vv.go @@ -43,10 +43,16 @@ type Declaration struct { // //fusa:req REQ-FO-VV002 func IndependenceLevel(d Declaration) int { + // A reviewer or test executor who is the same person as the implementation + // author provides no independence, so they must not raise the level (and, + // via AchievableASIL, must not inflate the certified ASIL). This mirrors the + // distinctness guard already enforced in attestation.go and Validate. + reviewerIndependent := d.IndependentReviewer != "" && d.IndependentReviewer != d.ImplementationAuthor + executorIndependent := d.IndependentTestExecutor != "" && d.IndependentTestExecutor != d.ImplementationAuthor switch { - case d.IndependentReviewer != "" && d.IndependentTestExecutor != "": + case reviewerIndependent && executorIndependent: return 2 - case d.IndependentReviewer != "": + case reviewerIndependent: return 1 default: return 0 From 2c5664d0a04411b314f831847a95d8509e6fde86 Mon Sep 17 00:00:00 2001 From: SoundMatt Date: Thu, 30 Jul 2026 12:49:55 -0700 Subject: [PATCH 2/2] fix: use http.NewRequestWithContext in fireWebhook (golangci-lint noctx) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Follow-up to the prior commit's webhook timeout fix — the linter correctly flagged the new http.NewRequest call for not carrying a context. The http.Client.Timeout already bounds every attempt, so context.Background() is sufficient here. Signed-off-by: SoundMatt --- server/server.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/server/server.go b/server/server.go index 5d3369b..4bd98d2 100644 --- a/server/server.go +++ b/server/server.go @@ -1424,7 +1424,7 @@ func fireWebhook(url, prev, current string, errors int) { // and leak a goroutine per status change. client := &http.Client{Timeout: 10 * time.Second} for i := range 2 { - req, err := http.NewRequest(http.MethodPost, url, strings.NewReader(body)) + req, err := http.NewRequestWithContext(context.Background(), http.MethodPost, url, strings.NewReader(body)) if err != nil { return }