Skip to content

feat(audit): SBOM export + scheduled signed reports - #38

Merged
DanFlannel merged 8 commits into
mainfrom
feat/phase4
Aug 19, 2026
Merged

feat(audit): SBOM export + scheduled signed reports#38
DanFlannel merged 8 commits into
mainfrom
feat/phase4

Conversation

@DanFlannel

@DanFlannel DanFlannel commented Aug 18, 2026

Copy link
Copy Markdown
Contributor

Summary

Phases D and E of the system-audit roadmap (#35, #36). Phase D exports the audit inventory as a CycloneDX 1.5 SBOM; Phase E turns the audit into a recurring, signed, sender-only posture feed with no server and no upload code.

Phase D: SBOM export (#35)

scuta doctor --audit --sbom cyclonedx emits the audit inventory (managed tools, plus system packages with --system) as a CycloneDX 1.5 JSON document. Pure projection of data Phases A-C already collect.

  • New lib/sbom: hand-rolled minimal document rather than a cyclonedx-go dependency (~180 lines for the subset scuta needs)
  • purl mapping: managed tools pkg:github, go modules pkg:golang, brew pkg:brew, mise pkg:generic; bom-ref falls back to origin-qualified manager:name@version so same-named packages cannot collide
  • Recorded install hashes exported as SHA-256; audit verdicts carried as scuta: properties (verified, drift, shadowed, manager, integrity) so the honest integrity states survive the export
  • --sbom requires --audit; unknown formats rejected; criticals still gate the exit code

Phase E: scheduled audits, signed local reports (#36, descoped)

Sender-only per the descope: scuta writes a local report, your own collector ships it. No ingestion server, no dashboard, no network code added.

  • doctor --audit --output <file>: atomic write (temp + rename) of the machine document; --sign-key: detached Ed25519 signature at <output>.sig reusing the admin keygen/sign/verify machinery; --if-changed: skip the write when the report's stable hash (generation time zeroed) matches the previous run
  • scuta monitor install|uninstall|status: schedules the audit with the OS scheduler (launchd agent on macOS, systemd user timer on Linux). No daemon or resident process. Windows returns an explicit not-supported error (Task Scheduler is a roadmap item). Activation failures keep the unit files and print manual instructions
  • Runs with critical findings exit non-zero, which launchd/systemd surface as a failed job
  • docs/FLEET.md: fleet aggregation with existing sinks (Splunk HEC, S3 + Athena, any webhook) and what to alert on

Design notes

  • lib/monitor injects all OS touchpoints (GOOS, home, uid, executable, fs ops, command runner), so launchd and systemd generation are both tested from any host; plist arguments are XML-escaped, systemd ExecStart is quoted per unit syntax
  • Report.StableHash() lives in lib/audit: SHA-256 over the report with generated_at zeroed, so unchanged findings hash identically across runs
  • The delta hash is recorded only after a successful write, so a failed write retries next run

Testing

  • 21 new tests: SBOM envelope/purl/property mapping and fallback refs, stable-hash invariance, launchd/systemd unit generation, XML escaping, ExecStart quoting, activation-failure fallback, idempotent uninstall, sub-minute interval rejection, unsupported-platform errors
  • go test -race ./... green (31 pkgs), darwin + windows/amd64 builds, golangci-lint 0 issues
  • Verified live end to end: SBOM run on a real machine (133 components: 9 go, 116 brew, 8 mise); keygen -> signed --output -> second run skips via --if-changed -> admin verify passes; all flag-combination guards exit 1 with clear messages; monitor status reports correctly

Also updates README (commands, Security section, roadmap), docs/SECURITY.md recommendations, and doctor help text.

Closes #35
Closes #36

A verified install means nothing if a different binary earlier on PATH is
what actually executes. doctor --audit now resolves every managed tool on
PATH (exec.LookPath semantics, PATHEXT-aware on windows, symlink-resolved,
case-insensitive compare on windows) and reports:

- shadowed-binary (critical): PATH resolves the tool name to a binary other
  than the one scuta verified
- bin-dir-not-in-path (warning): scuta's bin directory is not on PATH, so
  installed tools are not runnable by name

Report schema stays v1: new Tool fields effective_path and shadowed are
additive and omitempty. PathEnv injects PATH/PATHEXT/GOOS/Stat/EvalSymlinks
so both platforms are tested from any host.

Closes #32
…apter

doctor --audit --system extends the audit beyond scuta-managed tools: one
adapter per package manager, each answering where a package came from and
whether its integrity can be verified. Adapters report only what the
manager can prove — no integrity data means IntegrityNotVerifiable, never
a silent pass. Undetected managers are still listed so the report is
explicit about what was and was not checked.

First adapter: go install. Reads toolchain-embedded build metadata
(debug/buildinfo, no commands executed) and reports module origin,
version, and sum, flagging source-checkout builds (unpinned-build, info),
missing module sums (no-integrity-data, info), and dirty VCS working
trees (dirty-build, warning). No criticals: --system never gates CI yet.

Report schema stays v1: system section and summary count are additive
omitempty fields.

Closes #33
Extends --system audit with three new manager adapters:

- brew: reads Cellar INSTALL_RECEIPT.json directly (no shelling out).
  Flags third-party taps (third-party-source) and source builds
  (unpinned-build). Homebrew keeps no per-file manifest, so integrity
  is honestly reported as not-verifiable.
- mise: inventories $MISE_DATA_DIR/installs, skipping symlinked
  partial-version aliases. Checksums live in per-project mise.lock,
  so integrity is not-verifiable machine-wide.
- dpkg: counts packages from /var/lib/dpkg/status (summarized, not
  listed), runs a single 'dpkg --verify' pass. Non-conffile checksum
  mismatches are reported as binary-drift at critical severity: these
  are the first --system findings that can gate CI, deliberately, since
  a modified package binary is exactly what this audit exists to catch.
  Conffile changes are config-drift (info). Findings capped at 25 with
  an overflow summary.

New finding codes: third-party-source, config-drift,
inventory-summarized. Report schema stays v1 (all fields omitempty).

Closes #34
lookPath joined candidate paths with filepath.Join, which uses the host
separator. On a Windows runner that produced backslash paths, so the
injected-GOOS test environments (keyed with forward slashes) never
matched and all five cross-platform shadowing tests failed there.

Join with a forward slash instead, trimming trailing separators per the
injected GOOS. Every supported platform accepts forward slashes, so the
real check is unaffected; lookups now depend only on the injected GOOS,
not the machine running them.
scuta doctor --audit --sbom cyclonedx emits the audit inventory as a
CycloneDX 1.5 JSON document: managed tools, plus system packages when
combined with --system. Pure projection of data the audit already
collects; no additional inspection.

- New lib/sbom: hand-rolled minimal document (no new dependency),
  RFC 4122 v4 serial number, timestamp from the report
- purls: managed tools pkg:github/<repo>@<version>, go modules
  pkg:golang, brew pkg:brew, mise pkg:generic; bom-ref falls back to an
  origin-qualified name@version when no purl exists so same-named
  packages from different managers cannot collide
- Recorded install hashes exported as SHA-256; audit verdicts carried
  as scuta: properties (verified, drift, shadowed, manager, integrity)
  so not-verifiable stays visible in the export
- dpkg summarizes its inventory, so it contributes no components
- --sbom requires --audit; unknown formats are rejected; critical
  findings still gate the exit code, so CI can archive the SBOM and
  fail the build in one invocation

Closes #35
Turns the audit into a recurring posture feed without a server, an
agent, or any upload. Sender-only per the descope on #36: scuta writes
a local report and your own collector ships it.

doctor --audit gains file-output plumbing:
- --output: write the machine document (report, or SBOM with --sbom)
  atomically via temp file + rename, so collectors never see a
  half-written file
- --sign-key: detached Ed25519 signature at <output>.sig, reusing the
  admin keygen/sign/verify machinery, so posture claims are
  attributable to a machine
- --if-changed: skip the write (and re-sign) when the report's stable
  hash (generation time zeroed) matches the previous run

scuta monitor install|uninstall|status schedules the audit with the OS
scheduler: launchd agent on macOS, systemd user timer on Linux. No
daemon, no resident process. Windows returns an explicit not-supported
error (Task Scheduler support is a roadmap item). Activation failures
keep the unit files and print manual instructions. Runs with critical
findings exit non-zero, which the scheduler surfaces as a failed job.

docs/FLEET.md documents fleet aggregation with existing sinks (Splunk,
S3 + Athena, any webhook) and what to alert on. The upload-to-URL piece
of #36 stays deferred; no network code was added.

Closes #36
@DanFlannel DanFlannel changed the title feat(audit): SBOM export as CycloneDX 1.5 (Phase D) feat(audit): SBOM export + scheduled signed reports (Phases D-E) Aug 19, 2026
Same host leak as the Phase A PATH lookup: filepath.Join uses the host
separator, so on a Windows runner the launchd/systemd unit paths came
out with backslashes and the injected-GOOS tests never matched their
fake filesystems.

Use slash-based path.Join instead. The generated units only ever exist
on darwin and linux, where forward slashes are the native separator, so
real behavior is unchanged; construction now depends only on the
injected GOOS.
@DanFlannel DanFlannel changed the title feat(audit): SBOM export + scheduled signed reports (Phases D-E) feat(audit): SBOM export + scheduled signed reports Aug 19, 2026
@DanFlannel
DanFlannel merged commit 91bf445 into main Aug 19, 2026
8 checks passed
@DanFlannel
DanFlannel deleted the feat/phase4 branch August 19, 2026 17:02
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Phase E: continuous monitoring, report upload (sender-only) Phase D: SBOM export (CycloneDX)

1 participant