Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -19,3 +19,4 @@ a.txt

# Node / frontend dependencies
node_modules/
preferences.json
12 changes: 10 additions & 2 deletions Makefile
Original file line number Diff line number Diff line change
Expand Up @@ -42,12 +42,20 @@ build-gui:
go build -o ${GUI_BINARY} ./gui

# TypeScript browser GUI
# dev-gui-browser : build Go binary then start the Vite dev server + Go HTTP server
# dev-gui-browser : build Go binary + start sbom-utility serve + Vite dev server
# typecheck-gui-ts : run TypeScript type-check (no emit)
# lint-gui-ts : run ESLint over gui-ts/src
GUI_TS_DIR?=gui-ts

dev-gui-browser: build
cd ${GUI_TS_DIR} && npm run dev:browser:full

typecheck-gui-ts:
cd ${GUI_TS_DIR} && npm run typecheck

lint-gui-ts:
cd ${GUI_TS_DIR} && npm run lint

# General supported environments: https://go.dev/doc/install/source#environment
# See latest supported combinations using:
# $ go install golang.org/x/tools/cmd/goimports@latest
Expand Down Expand Up @@ -131,4 +139,4 @@ clean:
@if [ -f ${GUI_BINARY} ] ; then rm ${GUI_BINARY} ; fi
@if [ -d ${RELEASE_DIR} ] ; then rm -f ${RELEASE_DIR}/${BINARY}* ; rm -f ${RELEASE_DIR}/*.json ; rmdir ${RELEASE_DIR} ; fi

.PHONY: config clean build build-gui dev-gui-browser release test_clean test test_cmd unit_tests integration_tests format lint install
.PHONY: config clean build build-gui dev-gui-browser release test_clean test test_cmd unit_tests integration_tests format lint install build-gui-ts dist-gui-ts dev-gui-ts dev-gui-ts-full
70 changes: 47 additions & 23 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -30,14 +30,13 @@ same validate, license, component, resource, and vulnerability commands in a
point-and-click interface — no terminal required. They are independent of
each other and of the CLI; you can use one, both, or neither.

| | [Fyne GUI](gui/) | [TypeScript / Electron GUI](gui-ts/) |
| | [Fyne GUI](gui/) | [TypeScript Browser GUI](gui-ts/) |
|---|---|---|
| **Location** | [`gui/`](gui/) | [`gui-ts/`](gui-ts/) |
| **Language / framework** | Go · [Fyne](https://fyne.io) (BSD-3) | TypeScript · [Electron](https://www.electronjs.org) (MIT · OpenJS Foundation) · React |
| **Language / framework** | Go · [Fyne](https://fyne.io) (BSD-3) | TypeScript · React 18 · Vite 5 |
| **Build requirement** | Go toolchain + CGo (C compiler) | Node.js ≥ 20 · `npm ci` (no C compiler) |
| **Distribution** | `fyne package` → `.app` / `.exe` / `.tar.xz` | `npm run dist` → `.dmg` / NSIS `.exe` / AppImage |
| **Runtime** | Native binary | Browser tab backed by `sbom-utility serve` |
| **Theming** | Go `fyne.Theme` structs | CSS custom properties (`tokens.css`) — change any colour, font, or spacing without touching TypeScript |
| **Security** | Native binary, no network stack | Electron hardened defaults: `contextIsolation`, `sandbox`, strict CSP, IPC allowlist validation |
| **Full documentation** | [gui/README.md](gui/README.md) | [gui-ts/README.md](gui-ts/README.md) |

#### Fyne GUI (`gui/`)
Expand All @@ -56,33 +55,26 @@ make build-gui
> See [gui/README.md](gui/README.md) for full build instructions, theming
> reference, and distribution (fyne package / fyne-cross).

#### TypeScript / Electron GUI (`gui-ts/`)
#### TypeScript Browser GUI (`gui-ts/`)

A desktop application built with Electron (MIT · OpenJS Foundation), React 18,
Vite 5, and TypeScript 5. Visually polished with a dark sidebar, VS Code-style
BOM source viewer, and a fully documented CSS design-token system that lets
you retheme every colour, font, and spacing without touching any TypeScript.
A browser-based GUI backed by the local `sbom-utility serve` HTTP API.
Built with React 18, Vite 5, and TypeScript 5. No Electron, no C compiler,
no install step beyond `npm ci`.

```bash
# Install dependencies (verifies Node ≥ 20 and the CLI binary first)
./gui-ts/install.sh # macOS / Linux
.\gui-ts\install.ps1 # Windows PowerShell
# Step 1 — build the Go CLI binary (required by the server)
make build

# Launch in development mode (hot-reload)
cd gui-ts && npm run dev
# Step 2 — install npm dependencies (first time only)
cd gui-ts && npm ci

# Build a distributable installer
cd gui-ts && npm run dist:mac # → .dmg
cd gui-ts && npm run dist:win # → NSIS .exe
cd gui-ts && npm run dist:linux # → AppImage + .deb
# — or via Make (from repo root) —
make build-gui-ts # unpackaged build
make dist-gui-ts # full installer
# Step 3 — start the dev server (builds binary, starts serve + Vite)
make dev-gui-browser
# Then open http://localhost:5173 in your browser
```

> See [gui-ts/README.md](gui-ts/README.md) for the full feature inventory,
> security hardening checklist, style customisation guide with worked examples,
> and architecture documentation.
> style customisation guide with worked examples, and architecture documentation.

---

Expand Down Expand Up @@ -219,6 +211,38 @@ which returns `0` (zero) or "no error":

---

### Configuration and Preferences (`preferences.json`)

`sbom-utility` supports an optional profile file named `preferences.json` located in the current working directory (`./preferences.json`). This file is shared between the command-line utility and the browser GUI (`gui-ts`).

#### Configuration Precedence Order

When resolving configuration settings (such as custom schemas and license policies), `sbom-utility` applies settings using the following precedence:

1. **Explicit CLI Flags** (e.g., `--config-license <file>`, `--config-schema <file>`) — *highest precedence, always overrides file/default settings*.
2. **Configuration Profile** (`./preferences.json`) — applies project-level or directory-level defaults.
3. **Built-in Application Defaults** (embedded `config.json`, `license.json`) — *used when neither CLI flags nor profile settings are specified*.

#### Example `preferences.json`

```json
{
"ui": {
"defaultBomDirectory": "/path/to/boms",
"editorFontFamily": "ui-monospace, \"Cascadia Code\", monospace",
"editorFontSize": 13,
"autoValidateOnLoad": true
},
"cli": {
"configSchema": "custom-schemas.json",
"configLicense": "custom-license-policy.json",
"outputFormat": "json"
}
}
```

---

### Persistent flags

This section describes some of the important command line flags that apply to most of the utility's commands.
Expand Down
4 changes: 2 additions & 2 deletions cmd/diff.go
Original file line number Diff line number Diff line change
Expand Up @@ -86,14 +86,14 @@ func preRunTestForFiles(args []string) error {
baseFilename := utils.GlobalFlags.PersistentFlags.InputFile
if baseFilename == "" {
return getLogger().Errorf("Missing required argument(s): %s", FLAG_FILENAME_INPUT)
} else if _, err := os.Stat(baseFilename); err != nil { // lgtm[go/path-injection]
} else if _, err := os.Stat(baseFilename); err != nil { // lgtm[go/path-injection] -- CLI flag, not network input
Comment thread
github-advanced-security[bot] marked this conversation as resolved.
Fixed
return getLogger().Errorf("File not found: '%s'", baseFilename)
}

revisedFilename := utils.GlobalFlags.DiffFlags.RevisedFile
if revisedFilename == "" {
return getLogger().Errorf("Missing required argument(s): %s", FLAG_DIFF_FILENAME_REVISION)
} else if _, err := os.Stat(revisedFilename); err != nil { // lgtm[go/path-injection]
} else if _, err := os.Stat(revisedFilename); err != nil { // lgtm[go/path-injection] -- CLI flag, not network input
return getLogger().Errorf("File not found: '%s'", revisedFilename)
}

Expand Down
5 changes: 3 additions & 2 deletions cmd/diff_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -89,8 +89,9 @@ func NewDiffTestInfo(inputFile string, revisedFilename string) *DiffTestInfo {
var ti = new(DiffTestInfo)
ti.RevisedFilename = revisedFilename
var pCommon = &ti.CommonTestInfo
// Default format matches the CLI default: FORMAT_TEXT (line-prefixed +/-/space view).
pCommon.InitBasic(inputFile, FORMAT_TEXT, nil)
// Note: Diff default format is "unified" (standard ---/+++/@@ output via go-difflib).
// To test the legacy go-jsondiff text or JSON formats, set ti.OutputFormat explicitly.
pCommon.InitBasic(inputFile, FORMAT_UNIFIED, nil)
return ti
}

Expand Down
65 changes: 65 additions & 0 deletions cmd/preferences_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,65 @@
// SPDX-License-Identifier: Apache-2.0
/*
* Licensed to the Apache Software Foundation (ASF) under one or more
* contributor license agreements. See the NOTICE file distributed with
* this work for additional information regarding copyright ownership.
* The ASF licenses this file to You under the Apache License, Version 2.0
* (the "License"); you may not use this file except in compliance with
* the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/

package cmd

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

"github.com/CycloneDX/sbom-utility/utils"
)

func TestPreferencesPrecedence(t *testing.T) {
// Create a temporary preferences.json file in current working directory
tempPrefFile := filepath.Join(".", utils.DEFAULT_PREFERENCES_FILENAME)
defer os.Remove(tempPrefFile)

prefData := utils.AppPreferences{
CLI: utils.CLIPreferences{
ConfigLicensePolicyFile: "test/license/license-policy-test-empty.json",
},
}
encoded, err := json.Marshal(prefData)
if err != nil {
t.Fatalf("failed to marshal pref: %v", err)
}
if err := os.WriteFile(tempPrefFile, encoded, 0600); err != nil {
t.Fatalf("failed to write temp pref file: %v", err)
}

// 1. Test profile setting applied when no CLI flag is set
utils.GlobalFlags.ConfigLicensePolicyFile = ""
initConfigurations()

// Verify policy loaded from preferences.json is used
if LicensePolicyConfig == nil {
t.Fatalf("expected LicensePolicyConfig to be initialized")
}

// 2. Test explicit CLI flag overrides preferences.json
utils.GlobalFlags.ConfigLicensePolicyFile = "test/license/license-policy-test.json"
initConfigurations()

// Clean up global flag and restore defaults
utils.GlobalFlags.ConfigLicensePolicyFile = ""
_ = os.Remove(tempPrefFile)
initConfigurations()
}
20 changes: 19 additions & 1 deletion cmd/root.go
Original file line number Diff line number Diff line change
Expand Up @@ -245,16 +245,34 @@ func initConfigurations() {
// we leave the code below "in place" as we may still want to validate any
// input file as JSON SBOM document that matches a known format/version (TODO in the future)

// Load preferences from working directory (if present)
// Precedence order:
// 1. Explicit CLI flags (utils.GlobalFlags)
// 2. Profile settings in ./preferences.json
// 3. Built-in defaults (DEFAULT_SCHEMA_CONFIG, DEFAULT_LICENSE_POLICY_CONFIG)
prefResult := utils.LoadPreferencesFromWorkingDir()
if prefResult.Exists {
getLogger().Debugf("Loaded configuration profile from: '%s'", prefResult.Path)
}

// Load application configuration file (i.e., primarily SBOM supported Formats/Schemas)
var schemaConfigFile = utils.GlobalFlags.ConfigSchemaFile
if schemaConfigFile == "" && prefResult.Preferences.CLI.ConfigSchemaFile != "" {
schemaConfigFile = prefResult.Preferences.CLI.ConfigSchemaFile
getLogger().Debugf("Using schema config from preferences.json: '%s'", schemaConfigFile)
}
err := SupportedFormatConfig.LoadSchemaConfigFile(schemaConfigFile, DEFAULT_SCHEMA_CONFIG)
if err != nil {
getLogger().Error(err.Error())
os.Exit(ERROR_APPLICATION)
}

// License Policy Configuration (customizable via command line, with default config.)
// License Policy Configuration (customizable via command line / preferences.json, with default config.)
var licensePolicyFile = utils.GlobalFlags.ConfigLicensePolicyFile
if licensePolicyFile == "" && prefResult.Preferences.CLI.ConfigLicensePolicyFile != "" {
licensePolicyFile = prefResult.Preferences.CLI.ConfigLicensePolicyFile
getLogger().Debugf("Using license policy config from preferences.json: '%s'", licensePolicyFile)
}
LicensePolicyConfig = new(schema.LicensePolicyConfig)
err = LicensePolicyConfig.LoadHashPolicyConfigurationFile(licensePolicyFile, DEFAULT_LICENSE_POLICY_CONFIG)
if err != nil {
Expand Down
55 changes: 55 additions & 0 deletions cmd/serve.go
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,7 @@ const (
serveVulnPath = "/vulnerability/list"
serveDiffPath = "/diff"
servePatchPath = "/patch"
servePreferencesPath = "/preferences"
)

type serveRunResult struct {
Expand Down Expand Up @@ -129,6 +130,7 @@ func Serve(port int) error {
mux.HandleFunc(serveAPIPrefix+serveVulnPath, handleServeVulnerabilityList)
mux.HandleFunc(serveAPIPrefix+serveDiffPath, handleServeDiff)
mux.HandleFunc(serveAPIPrefix+servePatchPath, handleServePatch)
mux.HandleFunc(serveAPIPrefix+servePreferencesPath, handleServePreferences)

addr := "127.0.0.1:" + strconvItoa(port)
getLogger().Infof("Starting browser GUI API server on http://%s", addr)
Expand Down Expand Up @@ -543,6 +545,59 @@ func runServePatch(request servePatchRequest) (serveRunResult, error) {
return result, nil
}

func handleServePreferences(writer http.ResponseWriter, request *http.Request) {
switch request.Method {
case http.MethodGet:
result := utils.LoadPreferencesFromWorkingDir()
writeServeJSON(writer, http.StatusOK, result)

case http.MethodPost:
// Preserve existing file's CLI settings if updating from GUI
current := utils.LoadPreferencesFromWorkingDir()
var incoming utils.AppPreferences
if err := decodeServeJSON(request, &incoming); err != nil {
writeServeError(writer, http.StatusBadRequest, err)
return
}

// If CLI block was omitted, preserve whatever was on disk
if incoming.CLI.ConfigSchemaFile == "" && incoming.ConfigSchema == "" && current.Preferences.CLI.ConfigSchemaFile != "" {
incoming.CLI.ConfigSchemaFile = current.Preferences.CLI.ConfigSchemaFile
}
if incoming.CLI.ConfigLicensePolicyFile == "" && incoming.ConfigLicense == "" && current.Preferences.CLI.ConfigLicensePolicyFile != "" {
incoming.CLI.ConfigLicensePolicyFile = current.Preferences.CLI.ConfigLicensePolicyFile
}

incoming.Normalize()

data, err := json.MarshalIndent(incoming, "", " ")
if err != nil {
writeServeError(writer, http.StatusInternalServerError, err)
return
}

prefPath := filepath.Join(".", utils.DEFAULT_PREFERENCES_FILENAME)
if err := os.WriteFile(prefPath, data, 0600); err != nil {
writeServeError(writer, http.StatusInternalServerError, err)
return
}

absPath, err := filepath.Abs(prefPath)
if err != nil {
absPath = prefPath
}

writeServeJSON(writer, http.StatusOK, utils.PreferencesResult{
Path: absPath,
Exists: true,
Preferences: incoming,
})

default:
writeServeMethodNotAllowed(writer)
}
}

func decodeServeJSON(request *http.Request, value interface{}) error {
defer request.Body.Close()
return json.NewDecoder(request.Body).Decode(value)
Expand Down
Loading
Loading