diff --git a/docs/_static_html/README.md b/docs/_static_html/README.md new file mode 100644 index 00000000..6256ec42 --- /dev/null +++ b/docs/_static_html/README.md @@ -0,0 +1,40 @@ +# CyTRICS Single-Page SBOM Editor + +This is a single-file, browser-based SBOM HTML editor. + +## PURL builder placement + +The PURL builder remains collapsed by default. After selecting a software entry and clicking **Build Related PURL**, the builder opens immediately below the entry title/action row and above the editable software fields. Selecting another software entry closes it automatically. + +## How to use + +1. Open `sbom-workbench.html` in a browser. +2. Load a CyTRICS JSON SBOM. +3. Search the software section by filename, product name, UUID, SHA-1, SHA-256, MD5, install path, or PURL. +4. Select a source software/file entry. +5. Use **Create related PURL component** to manually build a Package URL. +6. Click **Create software entry + relationship**. +7. Export the updated SBOM. + +## What the PURL action does + +For the selected source software entry, the tool creates a new CyTRICS `software[]` component: + +- `UUID`: newly generated UUID +- `softwareType`: `["software package"]` +- `name[]`: includes a product name and a `package URL (purl)` entry +- `version`: the manually entered PURL version, if provided +- `notHashable`: `true` +- `relationshipAssertion`: `Root` + +It also creates a new `relationships[]` entry: + +- `xUUID`: selected source file/software UUID +- `yUUID`: newly generated PURL software UUID +- `relationship`: selected relationship value, such as `Uses`, `Contains`, `Downloads`, or `Mounted on` + +## Notes + +- The full software list is intentionally not rendered until you search, which is better for large SBOMs. +- Export normalizes the document toward CyTRICS 1.0.1 shape and removes legacy top-level fields such as `properties`, `systems`, `analysisData`, `observations`, and `starRelationships`. +- This single page uses CDN-hosted React/Babel dependencies like the uploaded HTML prototype, so it needs browser access to those CDNs unless you vendor the dependencies locally. diff --git a/docs/_static_html/sbom-workbench.html b/docs/_static_html/sbom-workbench.html index 764d3356..570308c1 100644 --- a/docs/_static_html/sbom-workbench.html +++ b/docs/_static_html/sbom-workbench.html @@ -3,7 +3,7 @@ -SBOM Workbench +CyTRICS SBOM Co-editor @@ -468,6 +468,72 @@ font-family: var(--mono); } + /* Schema-driven PURL builder */ + .purl-definition-card { + background: var(--bg-2); + border: 1px solid var(--border); + border-radius: 3px; + padding: 10px; + } + .purl-definition-title { + display: flex; + align-items: baseline; + gap: 8px; + flex-wrap: wrap; + margin-bottom: 4px; + } + .purl-requirement { + font-family: var(--mono); + font-size: 9px; + text-transform: uppercase; + letter-spacing: 0.05em; + padding: 1px 5px; + border-radius: 2px; + border: 1px solid var(--border-2); + } + .purl-required { color: var(--danger); border-color: rgba(255,84,104,.4); background: rgba(255,84,104,.08); } + .purl-optional { color: var(--accent-3); border-color: rgba(132,195,66,.35); background: rgba(132,195,66,.08); } + .purl-prohibited { color: var(--fg-3); border-color: var(--border); background: var(--bg); } + .purl-validation { + border-radius: 3px; + padding: 8px 10px; + font-size: 11px; + margin-top: 8px; + } + .purl-validation.error { border: 1px solid rgba(255,84,104,.45); background: rgba(255,84,104,.08); color: #ff9aa7; } + .purl-validation.warn { border: 1px solid rgba(255,121,0,.4); background: rgba(255,121,0,.08); color: #ffb36b; } + .purl-validation.ok { border: 1px solid rgba(132,195,66,.35); background: rgba(132,195,66,.08); color: #b7dd8d; } + .purl-example { + display: block; + width: 100%; + text-align: left; + font-family: var(--mono); + font-size: 10px; + color: var(--fg-2); + background: var(--bg); + border: 1px solid var(--border); + padding: 5px 7px; + margin-top: 4px; + border-radius: 2px; + cursor: pointer; + word-break: break-all; + } + .purl-example:hover { border-color: var(--accent); color: var(--fg); } + .purl-qualifier-row { + display: grid; + grid-template-columns: minmax(120px, .7fr) minmax(180px, 1.3fr) 28px; + gap: 6px; + align-items: start; + margin-bottom: 6px; + } + .purl-help { + font-size: 10px; + color: var(--fg-3); + margin-top: 3px; + line-height: 1.35; + } + .purl-toolbar { display:flex; gap:6px; align-items:center; flex-wrap:wrap; } + /* Wave/blink */ @keyframes blink { 50% { opacity: 0.3; } } .blink { animation: blink 1.2s infinite; } @@ -853,6 +919,351 @@ try { return await navigator.storage.estimate(); } catch { return null; } } +const PURL_TYPE_DEFINITION_SCHEMA_ID = 'https://packageurl.org/schemas/purl-type-definition.schema-1.0.json'; +const PURL_TYPE_PATTERN = /^[a-z][a-z0-9.-]+$/; +const PURL_QUALIFIER_KEY_PATTERN = /^[a-z][a-z0-9._-]*$/; + +function makeBuiltinPurlDefinition({ type, typeName, description, namespaceRequirement='optional', namespaceNativeName='namespace', nameNativeName='name', qualifiers=[] , examples=[] }) { + return { + $schema: PURL_TYPE_DEFINITION_SCHEMA_ID, + $id: `https://packageurl.org/types/${type}-definition.json`, + type, + type_name: typeName, + description, + repository: { use_repository: false }, + namespace_definition: { + requirement: namespaceRequirement, + case_sensitive: true, + native_name: namespaceNativeName, + }, + name_definition: { + requirement: 'required', + case_sensitive: true, + native_name: nameNativeName, + }, + version_definition: { requirement: 'optional', case_sensitive: true, native_name: 'version' }, + qualifiers_definition: qualifiers, + subpath_definition: { requirement: 'optional', case_sensitive: true, native_name: 'subpath' }, + examples: examples.length ? examples : [`pkg:${type}/example@1.0.0`], + }; +} + +// Starter definitions keep the page useful before authoritative type-definition +// files are imported. Imported definitions replace a starter with the same type. +const BUILTIN_PURL_TYPE_DEFINITIONS = { + generic: makeBuiltinPurlDefinition({ + type: 'generic', + typeName: 'Generic Package', + description: 'Fallback package type for software that does not have a more specific Package-URL ecosystem type.', + examples: ['pkg:generic/acme/widget@1.2.3', 'pkg:generic/widget@1.2.3?arch=x86_64'], + }), + maven: makeBuiltinPurlDefinition({ + type: 'maven', + typeName: 'Apache Maven', + description: 'Starter definition for Maven coordinates. Import an authoritative definition to replace this template.', + namespaceRequirement: 'required', + namespaceNativeName: 'groupId', + nameNativeName: 'artifactId', + qualifiers: [ + { key:'type', requirement:'optional', description:'Maven artifact type or packaging.', default_value:'jar' }, + { key:'classifier', requirement:'optional', description:'Maven classifier.' }, + { key:'repository_url', requirement:'optional', description:'Repository URL used to resolve the package.' }, + ], + examples: ['pkg:maven/org.apache.xmlgraphics/batik-anim@1.9.1?type=jar'], + }), + npm: makeBuiltinPurlDefinition({ + type: 'npm', + typeName: 'npm Package', + description: 'Starter definition for npm packages. The namespace represents an optional npm scope.', + namespaceRequirement: 'optional', + namespaceNativeName: 'scope', + nameNativeName: 'package name', + examples: ['pkg:npm/%40angular/animation@12.3.1', 'pkg:npm/left-pad@1.3.0'], + }), + pypi: makeBuiltinPurlDefinition({ + type: 'pypi', + typeName: 'Python Package', + description: 'Starter definition for Python packages distributed through Python package repositories.', + namespaceRequirement: 'prohibited', + nameNativeName: 'distribution name', + examples: ['pkg:pypi/django@4.2.11'], + }), + nuget: makeBuiltinPurlDefinition({ + type: 'nuget', + typeName: 'NuGet Package', + description: 'Starter definition for NuGet packages.', + namespaceRequirement: 'prohibited', + nameNativeName: 'package ID', + examples: ['pkg:nuget/EnterpriseLibrary.Common@6.0.1304'], + }), + golang: makeBuiltinPurlDefinition({ + type: 'golang', + typeName: 'Go Module', + description: 'Starter definition for Go modules.', + namespaceRequirement: 'required', + namespaceNativeName: 'module path namespace', + nameNativeName: 'module name', + examples: ['pkg:golang/google.golang.org/genproto@abcdef123456'], + }), + github: makeBuiltinPurlDefinition({ + type: 'github', + typeName: 'GitHub Repository', + description: 'Starter definition for software identified by GitHub owner and repository.', + namespaceRequirement: 'required', + namespaceNativeName: 'owner', + nameNativeName: 'repository', + examples: ['pkg:github/package-url/purl-spec@244fd47e07d1004f0aed9c'], + }), +}; + +function encodePurlPart(value) { + return encodeURIComponent(String(value ?? '').trim()); +} +function decodePurlPart(value) { + try { return decodeURIComponent(value); } catch { return value; } +} +function normalizePurlComponent(value, definition) { + let out = String(value ?? '').trim(); + if (definition && definition.case_sensitive === false) out = out.toLowerCase(); + return out; +} +function componentRequirement(definition, component, fallback='optional') { + return definition?.[`${component}_definition`]?.requirement || fallback; +} +function componentDefinition(definition, component) { + return definition?.[`${component}_definition`] || {}; +} +function requirementClass(requirement) { + return `purl-requirement purl-${requirement || 'optional'}`; +} +function validateDefinitionComponent(name, value, allowedRequirements, errors) { + if (!value || typeof value !== 'object' || Array.isArray(value)) { + errors.push(`${name} must be an object.`); + return; + } + if (!allowedRequirements.includes(value.requirement)) { + errors.push(`${name}.requirement must be ${allowedRequirements.join(' or ')}.`); + } + if (value.case_sensitive !== undefined && typeof value.case_sensitive !== 'boolean') { + errors.push(`${name}.case_sensitive must be boolean.`); + } + if (value.normalization_rules !== undefined && (!Array.isArray(value.normalization_rules) || value.normalization_rules.some(x => typeof x !== 'string'))) { + errors.push(`${name}.normalization_rules must be an array of strings.`); + } + if (value.permitted_characters) { + try { new RegExp(value.permitted_characters); } + catch (err) { errors.push(`${name}.permitted_characters is not a valid ECMA-262 regular expression: ${err.message}`); } + } +} +function validatePurlTypeDefinition(definition) { + const errors = []; + if (!definition || typeof definition !== 'object' || Array.isArray(definition)) return ['Definition must be a JSON object.']; + if (definition.$id === PURL_TYPE_DEFINITION_SCHEMA_ID && definition.title === 'Package-URL Type Definition') { + return ['This file is the type-definition schema itself, not a concrete PURL type definition. Load files such as maven-definition.json or pypi-definition.json.']; + } + const allowed = new Set(['$schema','$id','type','type_name','description','repository','namespace_definition','name_definition','version_definition','qualifiers_definition','subpath_definition','examples','note','reference_urls']); + for (const key of Object.keys(definition)) if (!allowed.has(key)) errors.push(`Unknown top-level property: ${key}`); + for (const key of ['$id','type','type_name','description','repository','namespace_definition','name_definition','examples']) { + if (definition[key] === undefined || definition[key] === null) errors.push(`Missing required property: ${key}`); + } + if (definition.$schema !== undefined && definition.$schema !== PURL_TYPE_DEFINITION_SCHEMA_ID) errors.push(`$schema must be ${PURL_TYPE_DEFINITION_SCHEMA_ID}`); + if (definition.$id && !/^https:\/\/packageurl\.org\/types\/[a-z0-9-]+-definition\.json$/.test(definition.$id)) errors.push('$id does not match the Package-URL type-definition URI pattern.'); + if (!PURL_TYPE_PATTERN.test(String(definition.type || ''))) errors.push('type must match ^[a-z][a-z0-9.-]+$.'); + if (typeof definition.type_name !== 'string' || !definition.type_name.trim()) errors.push('type_name must be a non-empty string.'); + if (typeof definition.description !== 'string' || !definition.description.trim()) errors.push('description must be a non-empty string.'); + if (!definition.repository || typeof definition.repository !== 'object' || Array.isArray(definition.repository)) { + errors.push('repository must be an object.'); + } else if (typeof definition.repository.use_repository !== 'boolean') { + errors.push('repository.use_repository must be boolean.'); + } + validateDefinitionComponent('namespace_definition', definition.namespace_definition, ['required','optional','prohibited'], errors); + validateDefinitionComponent('name_definition', definition.name_definition, ['required'], errors); + if (definition.version_definition !== undefined) validateDefinitionComponent('version_definition', definition.version_definition, ['optional'], errors); + if (definition.subpath_definition !== undefined) validateDefinitionComponent('subpath_definition', definition.subpath_definition, ['optional'], errors); + if (!Array.isArray(definition.examples) || !definition.examples.length) { + errors.push('examples must be a non-empty array.'); + } else { + const seen = new Set(); + for (const example of definition.examples) { + if (typeof example !== 'string' || !/^pkg:[a-z][a-z0-9.-]+\/.+/.test(example)) errors.push(`Invalid example PURL: ${String(example)}`); + if (seen.has(example)) errors.push(`Duplicate example PURL: ${example}`); + seen.add(example); + } + } + if (definition.qualifiers_definition !== undefined) { + if (!Array.isArray(definition.qualifiers_definition)) errors.push('qualifiers_definition must be an array.'); + else { + const keys = new Set(); + for (const [i, q] of definition.qualifiers_definition.entries()) { + if (!q || typeof q !== 'object' || Array.isArray(q)) { errors.push(`qualifiers_definition[${i}] must be an object.`); continue; } + if (!q.key || typeof q.key !== 'string') errors.push(`qualifiers_definition[${i}].key is required.`); + else { + const key = q.key.toLowerCase(); + if (!PURL_QUALIFIER_KEY_PATTERN.test(key)) errors.push(`Invalid qualifier key: ${q.key}`); + if (keys.has(key)) errors.push(`Duplicate qualifier key: ${q.key}`); + keys.add(key); + } + if (!q.description || typeof q.description !== 'string') errors.push(`qualifiers_definition[${i}].description is required.`); + if (q.requirement !== undefined && !['required','optional'].includes(q.requirement)) errors.push(`qualifier ${q.key || i} requirement must be required or optional.`); + } + } + } + return errors; +} +function normalizePurlTypeDefinition(definition) { + const out = deepClone(definition); + out.type = String(out.type || '').trim().toLowerCase(); + out.namespace_definition ||= { requirement:'optional' }; + out.name_definition ||= { requirement:'required' }; + out.version_definition ||= { requirement:'optional' }; + out.subpath_definition ||= { requirement:'optional' }; + out.qualifiers_definition = Array.isArray(out.qualifiers_definition) ? out.qualifiers_definition : []; + out.examples = Array.isArray(out.examples) ? out.examples : []; + return out; +} +function loadStoredPurlDefinitions() { + try { + const parsed = JSON.parse(localStorage.getItem('cytrics.purlTypeDefinitions.v1') || '{}'); + const valid = {}; + for (const def of Object.values(parsed || {})) { + const errors = validatePurlTypeDefinition(def); + if (!errors.length) valid[def.type] = normalizePurlTypeDefinition(def); + } + return valid; + } catch { return {}; } +} +function persistPurlDefinitions(definitions) { + try { + const imported = {}; + for (const [type, def] of Object.entries(definitions || {})) { + if (!BUILTIN_PURL_TYPE_DEFINITIONS[type] || !deepEqual(def, BUILTIN_PURL_TYPE_DEFINITIONS[type])) imported[type] = def; + } + localStorage.setItem('cytrics.purlTypeDefinitions.v1', JSON.stringify(imported)); + } catch {} +} +function parseQualifierText(text) { + const q = {}; + for (const line of String(text || '').split(/\r?\n|,/)) { + const trimmed = line.trim(); + if (!trimmed) continue; + const idx = trimmed.indexOf('='); + if (idx <= 0) throw new Error(`Qualifier must use key=value: ${trimmed}`); + const key = trimmed.slice(0, idx).trim().toLowerCase(); + const value = trimmed.slice(idx + 1).trim(); + if (!PURL_QUALIFIER_KEY_PATTERN.test(key)) throw new Error(`Invalid qualifier key: ${key}`); + if (Object.prototype.hasOwnProperty.call(q, key)) throw new Error(`Duplicate qualifier key: ${key}`); + q[key] = value; + } + return q; +} +function normalizeSubpath(value) { + const raw = String(value || '').trim().replace(/^#+/, '').replace(/^\/+|\/+$/g, ''); + if (!raw) return ''; + const segments = raw.split('/'); + if (segments.some(x => !x || x === '.' || x === '..')) throw new Error('Subpath cannot contain empty, ".", or ".." segments.'); + return segments.join('/'); +} +function validatePermittedCharacters(value, definition, label, errors) { + if (!value || !definition?.permitted_characters) return; + try { + const pattern = new RegExp(definition.permitted_characters); + if (!pattern.test(value)) errors.push(`${label} does not match permitted_characters: ${definition.permitted_characters}`); + } catch (err) { + errors.push(`${label} definition has an invalid permitted_characters expression: ${err.message}`); + } +} +function canonicalizePurlFields(fields, definition) { + const namespaceDef = componentDefinition(definition, 'namespace'); + const nameDef = componentDefinition(definition, 'name'); + const versionDef = componentDefinition(definition, 'version'); + const subpathDef = componentDefinition(definition, 'subpath'); + return { + type: String(fields.type || '').trim().toLowerCase(), + namespace: normalizePurlComponent(fields.namespace, namespaceDef).replace(/^\/+|\/+$/g, ''), + name: normalizePurlComponent(fields.name, nameDef), + version: normalizePurlComponent(fields.version, versionDef), + qualifiers: Object.fromEntries(Object.entries(fields.qualifiers || {}).map(([k,v]) => [String(k).trim().toLowerCase(), String(v ?? '').trim()])), + subpath: normalizePurlComponent(normalizeSubpath(fields.subpath), subpathDef), + }; +} +function validatePurlFields(fields, definition) { + const errors = [], warnings = []; + const f = canonicalizePurlFields(fields, definition); + if (!PURL_TYPE_PATTERN.test(f.type)) errors.push('Type must match ^[a-z][a-z0-9.-]+$.'); + const namespaceReq = componentRequirement(definition, 'namespace', 'optional'); + if (namespaceReq === 'required' && !f.namespace) errors.push('Namespace is required for this PURL type.'); + if (namespaceReq === 'prohibited' && f.namespace) errors.push('Namespace is prohibited for this PURL type.'); + if (!f.name) errors.push('Name is required.'); + validatePermittedCharacters(f.namespace, componentDefinition(definition, 'namespace'), 'Namespace', errors); + validatePermittedCharacters(f.name, componentDefinition(definition, 'name'), 'Name', errors); + validatePermittedCharacters(f.version, componentDefinition(definition, 'version'), 'Version', errors); + validatePermittedCharacters(f.subpath, componentDefinition(definition, 'subpath'), 'Subpath', errors); + + const qualifierDefs = new Map((definition?.qualifiers_definition || []).map(q => [q.key.toLowerCase(), q])); + for (const qDef of qualifierDefs.values()) { + const value = f.qualifiers[qDef.key.toLowerCase()]; + if ((qDef.requirement || 'optional') === 'required' && !value && qDef.default_value === undefined) errors.push(`Qualifier ${qDef.key} is required.`); + } + for (const [key, value] of Object.entries(f.qualifiers)) { + if (!PURL_QUALIFIER_KEY_PATTERN.test(key)) errors.push(`Invalid qualifier key: ${key}`); + if (!value) errors.push(`Qualifier ${key} has an empty value.`); + if (definition && definition.type !== 'generic' && !qualifierDefs.has(key)) warnings.push(`Qualifier ${key} is not declared by the selected type definition.`); + } + if (!definition) warnings.push('No loaded type definition matches this type; only base PURL syntax is being checked.'); + return { fields:f, errors, warnings }; +} +function buildPackageUrl({ type, namespace, name, version, qualifiers, subpath }) { + const t = String(type || '').trim().toLowerCase(); + const n = String(name || '').trim(); + if (!PURL_TYPE_PATTERN.test(t)) throw new Error('PURL type is missing or invalid.'); + if (!n) throw new Error('PURL name is required.'); + let purl = `pkg:${t}/`; + const ns = String(namespace || '').trim().replace(/^\/+|\/+$/g, ''); + if (ns) purl += ns.split('/').map(encodePurlPart).join('/') + '/'; + purl += encodePurlPart(n); + if (String(version || '').trim()) purl += '@' + encodePurlPart(version); + const q = qualifiers || {}; + const keys = Object.keys(q).filter(k => q[k] !== undefined && q[k] !== null && String(q[k]).trim() !== '').sort(); + if (keys.length) purl += '?' + keys.map(k => `${encodePurlPart(k.toLowerCase())}=${encodePurlPart(q[k])}`).join('&'); + const sp = normalizeSubpath(subpath); + if (sp) purl += '#' + sp.split('/').map(encodePurlPart).join('/'); + return purl; +} +function parsePackageUrl(value) { + const input = String(value || '').trim(); + if (!input.startsWith('pkg:')) throw new Error('PURL must begin with pkg:.'); + let rest = input.slice(4), subpath = '', query = ''; + const hashAt = rest.indexOf('#'); + if (hashAt >= 0) { subpath = rest.slice(hashAt + 1); rest = rest.slice(0, hashAt); } + const queryAt = rest.indexOf('?'); + if (queryAt >= 0) { query = rest.slice(queryAt + 1); rest = rest.slice(0, queryAt); } + const slashAt = rest.indexOf('/'); + if (slashAt <= 0) throw new Error('PURL must contain a type followed by /.'); + const type = rest.slice(0, slashAt).toLowerCase(); + let pathVersion = rest.slice(slashAt + 1), version = ''; + const at = pathVersion.lastIndexOf('@'); + if (at >= 0) { version = decodePurlPart(pathVersion.slice(at + 1)); pathVersion = pathVersion.slice(0, at); } + const segments = pathVersion.split('/').filter(Boolean).map(decodePurlPart); + if (!segments.length) throw new Error('PURL name is missing.'); + const name = segments.pop(); + const namespace = segments.join('/'); + const qualifiers = {}; + if (query) { + for (const pair of query.split('&')) { + if (!pair) continue; + const eq = pair.indexOf('='); + const key = decodePurlPart(eq >= 0 ? pair.slice(0, eq) : pair).toLowerCase(); + const val = decodePurlPart(eq >= 0 ? pair.slice(eq + 1) : ''); + if (Object.prototype.hasOwnProperty.call(qualifiers, key)) throw new Error(`Duplicate qualifier key: ${key}`); + qualifiers[key] = val; + } + } + return { type, namespace, name, version, qualifiers, subpath: subpath.split('/').map(decodePurlPart).join('/') }; +} +function packageNameDisplay(namespace, name) { + return [namespace, name].filter(Boolean).join('/') || name || 'PURL package'; +} + /* ========================================================================= * * 2. ANCHOR SYSTEM * * ========================================================================= */ @@ -941,6 +1352,7 @@ SW_ADD: 'sw.add', SW_REMOVE: 'sw.remove', SW_SET_FIELD: 'sw.setField', + SW_ADD_PURL_REL: 'sw.addPurlRelationship', // Hardware HW_ADD: 'hw.add', HW_REMOVE: 'hw.remove', @@ -984,6 +1396,25 @@ next.software.push(sw); return { sbom: next, status: 'applied' }; } + case OP.SW_ADD_PURL_REL: { + const sourceUUID = resolveAnchor(op.srcAnchor, index); + if (!sourceUUID) return { sbom: s, status: 'unresolved', message: 'source software anchor not found' }; + const sw = clone(op.software); + if (!sw.UUID) sw.UUID = genUUID(); + const next = clone(s); + next.software = next.software || []; + next.relationships = next.relationships || []; + const alreadyPresent = op.anchor && resolveAnchor(op.anchor, index); + const targetUUID = alreadyPresent || sw.UUID; + if (!alreadyPresent && !next.software.some(x => x.UUID === sw.UUID)) { + next.software.push(sw); + } + const relType = op.relationship || 'Uses'; + if (!next.relationships.some(r => r.xUUID === sourceUUID && r.yUUID === targetUUID && r.relationship === relType)) { + next.relationships.push({ xUUID: sourceUUID, yUUID: targetUUID, relationship: relType }); + } + return { sbom: next, status: 'applied' }; + } case OP.SW_REMOVE: { const uuid = resolveAnchor(op.anchor, index); if (!uuid) return { sbom: s, status: 'unresolved', message: 'anchor not found' }; @@ -1875,6 +2306,13 @@ }, [list, state.searchTerm, state.softwareFilter, baseByUUID]); const selected = list.find(s => s.UUID === state.selectedSoftwareUUID); + const [showPurlBuilder, setShowPurlBuilder] = useState(false); + + // Keep the entry form uncluttered. The builder opens only on request and + // closes when the user selects another software entry. + useEffect(() => { + setShowPurlBuilder(false); + }, [state.selectedSoftwareUUID]); function addSoftware() { const sw = { @@ -1962,7 +2400,9 @@
{selected ? + emit={emit} state={state} peerName={peerName} + showPurlBuilder={showPurlBuilder} + onTogglePurlBuilder={() => setShowPurlBuilder(value => !value)} /> :
Select a software entry, or click + to add one.
} @@ -1971,7 +2411,360 @@ ); } -function SoftwareDetail({ sw, base, emit, state, peerName }) { +function RequirementBadge({ requirement='optional' }) { + return {requirement}; +} + +function PurlComponentGuidance({ definition, component }) { + const def = componentDefinition(definition, component); + if (!def || !Object.keys(def).length) return null; + const notes = []; + if (def.native_name) notes.push(`Native name: ${def.native_name}`); + if (def.case_sensitive === false) notes.push('Canonical value will be lowercased.'); + if (def.permitted_characters) notes.push(`Pattern: ${def.permitted_characters}`); + if (def.note) notes.push(def.note); + return notes.length ?
{notes.join(' ยท ')}
: null; +} + +function PurlBuilderPanel({ sourceSw, emit }) { + const [typeDefinitions, setTypeDefinitions] = useState(() => ({ + ...deepClone(BUILTIN_PURL_TYPE_DEFINITIONS), + ...loadStoredPurlDefinitions(), + })); + const [pType, setPType] = useState('generic'); + const [pNamespace, setPNamespace] = useState(''); + const [pName, setPName] = useState(''); + const [pVersion, setPVersion] = useState(''); + const [pSubpath, setPSubpath] = useState(''); + const [qualifierValues, setQualifierValues] = useState({}); + const [extraQualifiers, setExtraQualifiers] = useState([]); + const [relationship, setRelationship] = useState('Uses'); + const [built, setBuilt] = useState(''); + const [parseInput, setParseInput] = useState(''); + const [definitionMessages, setDefinitionMessages] = useState([]); + + const normalizedType = pType.trim().toLowerCase(); + const selectedDefinition = typeDefinitions[normalizedType] || null; + const qualifierDefinitions = selectedDefinition?.qualifiers_definition || []; + const typeOptions = useMemo(() => Object.values(typeDefinitions).sort((a,b) => a.type.localeCompare(b.type)), [typeDefinitions]); + + useEffect(() => { + setBuilt(''); + }, [sourceSw?.UUID]); + + useEffect(() => { + setQualifierValues(prev => { + const next = {}; + for (const q of qualifierDefinitions) next[q.key] = prev[q.key] ?? ''; + return next; + }); + if (componentRequirement(selectedDefinition, 'namespace', 'optional') === 'prohibited') setPNamespace(''); + }, [normalizedType, selectedDefinition]); + + function collectQualifiers() { + const out = {}; + for (const qDef of qualifierDefinitions) { + const raw = String(qualifierValues[qDef.key] ?? '').trim(); + const value = raw || (qDef.default_value !== undefined ? String(qDef.default_value) : ''); + if (value) out[qDef.key.toLowerCase()] = value; + } + for (const row of extraQualifiers) { + const key = String(row.key || '').trim().toLowerCase(); + const value = String(row.value || '').trim(); + if (!key && !value) continue; + if (!PURL_QUALIFIER_KEY_PATTERN.test(key)) throw new Error(`Invalid qualifier key: ${key || '(blank)'}`); + if (!value) throw new Error(`Qualifier ${key} requires a value.`); + if (Object.prototype.hasOwnProperty.call(out, key)) throw new Error(`Duplicate qualifier key: ${key}`); + out[key] = value; + } + return out; + } + + const validation = useMemo(() => { + try { + return validatePurlFields({ + type: pType, + namespace: pNamespace, + name: pName, + version: pVersion, + qualifiers: collectQualifiers(), + subpath: pSubpath, + }, selectedDefinition); + } catch (err) { + return { fields:null, errors:[err.message], warnings:[] }; + } + }, [pType, pNamespace, pName, pVersion, pSubpath, qualifierValues, extraQualifiers, selectedDefinition]); + + const livePreview = useMemo(() => { + if (!validation.fields || validation.errors.length) return ''; + try { return buildPackageUrl(validation.fields); } catch { return ''; } + }, [validation]); + + function build() { + const qualifiers = collectQualifiers(); + const checked = validatePurlFields({ + type: pType, + namespace: pNamespace, + name: pName, + version: pVersion, + qualifiers, + subpath: pSubpath, + }, selectedDefinition); + if (checked.errors.length) throw new Error(checked.errors.join('\n')); + const purl = buildPackageUrl(checked.fields); + setBuilt(purl); + return { purl, fields:checked.fields, warnings:checked.warnings }; + } + + function applyParsedPurl(value) { + const parsed = parsePackageUrl(value); + setPType(parsed.type); + setPNamespace(parsed.namespace); + setPName(parsed.name); + setPVersion(parsed.version); + setPSubpath(parsed.subpath); + const def = typeDefinitions[parsed.type]; + const known = new Set((def?.qualifiers_definition || []).map(q => q.key.toLowerCase())); + const definedValues = {}, extras = []; + for (const [key, val] of Object.entries(parsed.qualifiers)) { + if (known.has(key)) definedValues[key] = val; + else extras.push({ id:genUUID(), key, value:val }); + } + setQualifierValues(definedValues); + setExtraQualifiers(extras); + setParseInput(value); + setBuilt(value); + } + + async function importTypeDefinitionFiles(files) { + const messages = [], next = { ...typeDefinitions }; + for (const file of Array.from(files || [])) { + try { + const parsed = JSON.parse(await file.text()); + const candidates = Array.isArray(parsed) ? parsed : Array.isArray(parsed.types) ? parsed.types : [parsed]; + for (const candidate of candidates) { + const errors = validatePurlTypeDefinition(candidate); + if (errors.length) { + messages.push({ level:'error', text:`${file.name}: ${errors.join(' ')}` }); + continue; + } + const normalized = normalizePurlTypeDefinition(candidate); + next[normalized.type] = normalized; + messages.push({ level:'success', text:`Loaded ${normalized.type_name} (${normalized.type}).` }); + } + } catch (err) { + messages.push({ level:'error', text:`${file.name}: ${err.message}` }); + } + } + setTypeDefinitions(next); + persistPurlDefinitions(next); + setDefinitionMessages(messages); + } + + function removeImportedDefinition(type) { + const next = { ...typeDefinitions }; + if (BUILTIN_PURL_TYPE_DEFINITIONS[type]) next[type] = deepClone(BUILTIN_PURL_TYPE_DEFINITIONS[type]); + else delete next[type]; + setTypeDefinitions(next); + persistPurlDefinitions(next); + if (normalizedType === type && !next[type]) setPType('generic'); + } + + function applyQualifierDefaults() { + const next = { ...qualifierValues }; + for (const q of qualifierDefinitions) if (!String(next[q.key] || '').trim() && q.default_value !== undefined) next[q.key] = String(q.default_value); + setQualifierValues(next); + } + + async function createRelatedComponent() { + try { + const { purl, fields, warnings } = build(); + const displayName = packageNameDisplay(fields.namespace, fields.name); + const sw = { + UUID: genUUID(), + softwareType: ['software package'], + name: [ + { nameValue: displayName, nameType: 'product name' }, + { nameValue: purl, nameType: 'package URL (purl)' }, + ], + version: fields.version || null, + description: `PURL software component generated for ${softwareLabel(sourceSw)}.`, + notHashable: true, + relationshipAssertion: 'Root', + metadata: [{ + generatedBy: 'SBOM Workbench PURL builder', + generatedAt: isoNow(), + purlTypeDefinition: selectedDefinition?.$id || null, + purlValidationWarnings: warnings.length ? warnings : undefined, + }], + supplementaryFiles: null, + }; + await emit({ + type: OP.SW_ADD_PURL_REL, + xUUID: sourceSw.UUID, + software: sw, + relationship, + }); + setBuilt(purl); + } catch (err) { + alert('Could not create PURL component: ' + err.message); + } + } + + if (!sourceSw) return null; + const namespaceRequirement = componentRequirement(selectedDefinition, 'namespace', 'optional'); + const nameRequirement = componentRequirement(selectedDefinition, 'name', 'required'); + const versionRequirement = componentRequirement(selectedDefinition, 'version', 'optional'); + const subpathRequirement = componentRequirement(selectedDefinition, 'subpath', 'optional'); + + return ( +
+
+
Create related PURL component
+ xUUID source + {sourceSw.UUID} + type-definition schema 1.0 +
+
+ Builds and validates a Package URL, creates a new CyTRICS software entry with notHashable: true, + stores the PURL in name[], and creates a relationship from the selected file to the new component. + Imported type definitions are checked against the supplied Package-URL type-definition schema structure and saved in this browser. +
+ +
+ Type-definition registry and PURL parser +
+
+
Load concrete type-definition JSON
+ importTypeDefinitionFiles(e.target.files)} /> +
+ A type-definition schema describes the definition format; load concrete package-type definitions here. Load one or more concrete definition files to replace the built-in starter templates. +
+ {definitionMessages.map((m,i) =>
{m.text}
)} +
+
+
Parse an existing PURL into the builder
+
+ setParseInput(e.target.value)} placeholder="pkg:maven/org.example/widget@1.2.3?type=jar" /> + +
+
+
+
+ +
+ + + setPType(e.target.value)} placeholder="generic, pypi, npm, maven, nuget" /> + {typeOptions.map(def => )} + + + + + + Namespace } hint={componentDefinition(selectedDefinition, 'namespace').native_name || ''}> + setPNamespace(e.target.value)} placeholder={namespaceRequirement === 'prohibited' ? 'prohibited for selected type' : 'organization/scope/group'} /> + + + Name } hint={componentDefinition(selectedDefinition, 'name').native_name || ''}> + setPName(e.target.value)} placeholder="package name" /> + + + Version } hint={componentDefinition(selectedDefinition, 'version').native_name || ''}> + setPVersion(e.target.value)} placeholder="optional package version" /> + + +
+ +
+
+
+
Qualifiers
+
Defined qualifiers are rendered from the selected type definition. Additional qualifiers are allowed but flagged when not declared by that definition.
+
+
+ {qualifierDefinitions.some(q=>q.default_value !== undefined) && } + +
+
+ {qualifierDefinitions.length === 0 && extraQualifiers.length === 0 &&
No type-specific qualifiers are declared.
} + {qualifierDefinitions.map(q => ( +
+
+
{q.key} {q.native_name && {q.native_name}}
+
{q.description}{q.default_value !== undefined ? ` Default: ${q.default_value}` : ''}
+
+ setQualifierValues({...qualifierValues, [q.key]:e.target.value})} placeholder={q.default_value !== undefined ? `default: ${q.default_value}` : 'qualifier value'} /> +
+ ))} + {extraQualifiers.map((row, idx) => ( +
+ setExtraQualifiers(extraQualifiers.map(x=>x.id===row.id ? {...x,key:e.target.value} : x))} placeholder="qualifier_key" /> + setExtraQualifiers(extraQualifiers.map(x=>x.id===row.id ? {...x,value:e.target.value} : x))} placeholder="value" /> + +
+ ))} +
+ +
+ Subpath } hint={componentDefinition(selectedDefinition, 'subpath').native_name || ''}> + setPSubpath(e.target.value)} placeholder="optional/path/inside/package" /> + + +
+ {selectedDefinition ? <> +
+ {selectedDefinition.type_name} + {selectedDefinition.type} + {BUILTIN_PURL_TYPE_DEFINITIONS[selectedDefinition.type] && deepEqual(selectedDefinition, BUILTIN_PURL_TYPE_DEFINITIONS[selectedDefinition.type]) + ? starter template + : imported definition} +
+
{selectedDefinition.description}
+ {selectedDefinition.repository?.use_repository &&
Repository: {selectedDefinition.repository.default_repository_url || 'type uses a public repository'}
} + {selectedDefinition.note &&
{selectedDefinition.note}
} + {(selectedDefinition.namespace_definition?.normalization_rules || selectedDefinition.name_definition?.normalization_rules || selectedDefinition.version_definition?.normalization_rules || selectedDefinition.subpath_definition?.normalization_rules) && +
Normalization rules +
    + {['namespace','name','version','subpath'].flatMap(component => (componentDefinition(selectedDefinition, component).normalization_rules || []).map((rule,i)=>
  • {component}: {rule}
  • ))} +
+
} + {selectedDefinition.examples?.length > 0 &&
+
Examples โ€” click to load
+ {selectedDefinition.examples.slice(0,5).map(ex => )} +
} + {!BUILTIN_PURL_TYPE_DEFINITIONS[selectedDefinition.type] || !deepEqual(selectedDefinition, BUILTIN_PURL_TYPE_DEFINITIONS[selectedDefinition.type]) ? + : null} + : <> + No matching type definition loaded +
The builder will validate base PURL syntax only. Import a concrete type-definition JSON file for component and qualifier rules.
+ } +
+
+ + {validation.errors.length > 0 &&
Validation errors
    {validation.errors.map((x,i)=>
  • {x}
  • )}
} + {validation.warnings.length > 0 &&
Warnings
    {validation.warnings.map((x,i)=>
  • {x}
  • )}
} + {!validation.errors.length && !validation.warnings.length && pName.trim() &&
PURL components satisfy the currently loaded type definition.
} + +
+
Canonical PURL preview
+
{livePreview || built || Complete the required fields to generate a preview.}
+
+
+ + +
+
+ ); +} + +function SoftwareDetail({ sw, base, emit, state, peerName, showPurlBuilder=false, onTogglePurlBuilder }) { // Quick edits emit a setField op against the entry's current anchor. const [anchor, setAnchor] = useState(null); useEffect(() => { @@ -2001,13 +2794,27 @@ {sw.UUID} {!base && added} -
+
+
+ {showPurlBuilder && ( +
+ +
+ )} +
s.UUID === partial.xUUID); + const srcHw = (cur.currentSBOM?.hardware || []).find(h => h.UUID === partial.xUUID); + if (!srcSw && !srcHw) { dispatch({type:'TOAST', level:'error', msg:'source entry not found'}); return; } + payload.srcAnchor = srcSw ? await computeSoftwareAnchor(srcSw) : await computeHardwareAnchor(srcHw); + payload.anchor = await computeSoftwareAnchor(partial.software); + delete payload.xUUID; } else if (partial.type === OP.HW_ADD) { payload.anchor = await computeHardwareAnchor(partial.hardware); } else if ([OP.REL_ADD, OP.REL_REMOVE, OP.REL_SET_COMMENTS].includes(partial.type)) {