diff --git a/README.md b/README.md index 647244ed..6477bb8c 100644 --- a/README.md +++ b/README.md @@ -362,7 +362,6 @@ The following tests are not yet implemented and therefore missing: - Recommended Test 6.2.31 - Recommended Test 6.2.38 - Recommended Test 6.2.39.1 -- Recommended Test 6.2.46 - Recommended Test 6.2.50.1 - Recommended Test 6.2.50.2 - Recommended Test 6.2.50.3 @@ -532,6 +531,7 @@ export const recommendedTest_6_2_42: DocumentTest export const recommendedTest_6_2_43: DocumentTest export const recommendedTest_6_2_44: DocumentTest export const recommendedTest_6_2_45: DocumentTest +export const recommendedTest_6_2_46: DocumentTest export const recommendedTest_6_2_47: DocumentTest export const recommendedTest_6_2_48: DocumentTest export const recommendedTest_6_2_49: DocumentTest diff --git a/csaf_2_1/recommendedTests.js b/csaf_2_1/recommendedTests.js index 4e74f84a..265900bf 100644 --- a/csaf_2_1/recommendedTests.js +++ b/csaf_2_1/recommendedTests.js @@ -59,6 +59,7 @@ export { recommendedTest_6_2_42 } from './recommendedTests/recommendedTest_6_2_4 export { recommendedTest_6_2_43 } from './recommendedTests/recommendedTest_6_2_43.js' export { recommendedTest_6_2_44 } from './recommendedTests/recommendedTest_6_2_44.js' export { recommendedTest_6_2_45 } from './recommendedTests/recommendedTest_6_2_45.js' +export { recommendedTest_6_2_46 } from './recommendedTests/recommendedTest_6_2_46.js' export { recommendedTest_6_2_47 } from './recommendedTests/recommendedTest_6_2_47.js' export { recommendedTest_6_2_48 } from './recommendedTests/recommendedTest_6_2_48.js' export { recommendedTest_6_2_49 } from './recommendedTests/recommendedTest_6_2_49.js' diff --git a/csaf_2_1/recommendedTests/recommendedTest_6_2_46.js b/csaf_2_1/recommendedTests/recommendedTest_6_2_46.js new file mode 100644 index 00000000..dbecf4b8 --- /dev/null +++ b/csaf_2_1/recommendedTests/recommendedTest_6_2_46.js @@ -0,0 +1,246 @@ +import { Ajv } from 'ajv/dist/jtd.js' +import { parse } from '#lib/spdx/spdx.js' +import { exceptions, licenses } from '@secvisogram/license-deprecation-list' +/** @typedef {import('@secvisogram/license-deprecation-list').LicenseEntry} LicenseEntry */ +import { + containsOneNoteWithTitleAndCategory, + getTranslationInDocumentLang, + isLangSpecifiedAndNotEnglish, +} from '../../lib/shared/languageSpecificTranslation.js' + +const ajv = new Ajv() + +/* + This is the jtd schema that needs to match the input document so that the + test is activated. If this schema doesn't match it normally means that the input + document does not validate against the csaf json schema or optional fields that + the test checks are not present. + */ +const inputSchema = /** @type {const} */ ({ + additionalProperties: true, + properties: { + document: { + additionalProperties: true, + properties: { + license_expression: { + type: 'string', + }, + }, + optionalProperties: { + lang: { + type: 'string', + }, + notes: { + elements: { + additionalProperties: true, + optionalProperties: { + category: { + type: 'string', + }, + title: { + type: 'string', + }, + }, + }, + }, + }, + }, + }, +}) + +const validateSchema = ajv.compile(inputSchema) + +const SCANCODE_LICENSE_REF_PREFIX = 'scancode-' +const ABOUT_CODE_EXCEPTION_REF_PREFIX = 'AdditionRef-scancode-' + +/** + * Check whether the ref contains a license not listed in the SPDX license list or AboutCode's "ScanCode LicenseDB" + * @param {string} licenseRefToCheck + * @return {boolean} + */ +function isLicenseReferenceListed(licenseRefToCheck) { + if (licenseRefToCheck.startsWith(SCANCODE_LICENSE_REF_PREFIX)) { + const licenseKey = licenseRefToCheck.substring( + SCANCODE_LICENSE_REF_PREFIX.length + ) + const license = licenses.get(licenseKey) + return license?.source === 'aboutCode' + } else { + const license = licenses.get(licenseRefToCheck) + return license?.source === 'spdx' + } +} + +/** + * Check whether license identifiers are listed in SPDX license list. + * @param {string} licenseToCheck + * @return {boolean} + */ +function isSpdxLicense(licenseToCheck) { + /** @type {LicenseEntry | undefined} */ + const license = licenses.get(licenseToCheck) + return license?.source === 'spdx' +} + +/** + * Check whether an exception identifier is listed in the SPDX exception list + * @param {string} exceptionId + * @return {boolean} + */ +function isSpdxException(exceptionId) { + /** @type {LicenseEntry | undefined} */ + const exception = exceptions.get(exceptionId) + return exception?.source === 'spdx' +} + +/** + * Check whether an AdditionRef-scancode-* identifier is listed in AboutCode's + * "ScanCode LicenseDB" exceptions + * @param {string} additionRefToCheck - full identifier, e.g. "AdditionRef-scancode-autoconf-exception-2.0" + * @return {boolean} + */ +function isExceptionReferenceListed(additionRefToCheck) { + if (additionRefToCheck.startsWith(ABOUT_CODE_EXCEPTION_REF_PREFIX)) { + const licenseKey = additionRefToCheck.substring( + ABOUT_CODE_EXCEPTION_REF_PREFIX.length + ) + const license = exceptions.get(licenseKey) + return license?.source === 'aboutCode' + } else { + const license = exceptions.get(additionRefToCheck) + return license?.source === 'spdx' + } +} + +/** + * Recursively checks if a parsed license expression contains not listed licenses + * or exceptions. + * + * @param {import('#lib/spdx/spdx.js').ParseResult} parsedExpression - The parsed license expression + * @returns {Array} all deprecated licenses and exceptions found + */ +function notListedLicenses(parsedExpression) { + /** @type {Array} */ + const notListed = [] + if (parsedExpression.type === 'SIMPLE_EXPRESSION') { + // Check the license identifier (LicenseRef-* that is not in AboutCode) + if ( + parsedExpression.value?.type === 'LICENSE_REF' && + parsedExpression.value.keyword === 'LicenseRef' + ) { + if (!isLicenseReferenceListed(parsedExpression.value.value)) { + notListed.push('LicenseRef-' + parsedExpression.value.value) + } + } + + if (parsedExpression.value?.type === 'LICENSE') { + if (!isSpdxLicense(parsedExpression.value?.value)) { + notListed.push(parsedExpression.value?.value) + } + } + + // Check the WITH clause exception identifier. + const withClause = parsedExpression.with + if (withClause) { + if (withClause.type === 'EXCEPTION') { + if (!isSpdxException(withClause.value)) { + notListed.push(withClause.value) + } + } else if (withClause.type === 'ADDITION_REF') { + // Full identifier: e.g. "AdditionRef-scancode-autoconf-exception-2.0" + const fullAdditionRef = withClause.keyword + '-' + withClause.value + // isAboutCodeException handles the prefix check internally (line 115) + const exception = isExceptionReferenceListed(fullAdditionRef) + if (!exception) { + notListed.push(fullAdditionRef) + } + } + } + } else { + notListed.push(...notListedLicenses(parsedExpression.left)) + notListed.push(...notListedLicenses(parsedExpression.right)) + } + + return notListed +} +/** + * Check if the license_expression contains license identifiers or exceptions + * contains license identifiers or exceptions that are not listed in the + * SPDX license list or AboutCode's "ScanCode LicenseDB" + * When the license expression is not valid SPDX the check is skipped + * @param {string | null | undefined} licenseToCheck - The license expression to check + * @returns {Array} all not listed licenses and exceptions found, + * empty array when the SPDX expression is not valid + */ +export function getNotListedLicenses(licenseToCheck) { + // Validate ensures that no invalid SPDX licenses are present + + if (licenseToCheck) { + try { + const parseResult = parse(licenseToCheck) + return notListedLicenses(parseResult) + } catch (e) { + return [] + } + } else { + return [] + } +} + +/** + * If the document language is specified but not English, and the license_expression contains license + * identifiers or exceptions that are not listed in the SPDX license list or Aboutcode's "ScanCode LicenseDB", + * it MUST be tested that exactly one item in document notes exists that has the language specific translation + * of the term License as title. The category of this item MUST be legal_disclaimer. + * If no language-specific translation has been recorded, the test MUST be skipped + * and output information to the user that no such translation is known. + * + * @param {unknown} doc + */ +export function recommendedTest_6_2_46(doc) { + /* + The `ctx` variable holds the state that is accumulated during the test run and is + finally returned by the function. + */ + const ctx = { + warnings: + /** @type {Array<{ instancePath: string; message: string }>} */ ([]), + } + + if (!validateSchema(doc)) { + return ctx + } + + const licenseInDocLang = getTranslationInDocumentLang(doc, 'license') + if (!licenseInDocLang) { + return ctx + } + + const licenseToCheck = doc.document.license_expression + if (isLangSpecifiedAndNotEnglish(doc.document.lang)) { + const notListedLicenses = getNotListedLicenses(licenseToCheck) + if (notListedLicenses.length > 0) { + const notes = doc.document.notes + if ( + !notes || + !containsOneNoteWithTitleAndCategory( + notes, + licenseInDocLang, + 'legal_disclaimer' + ) + ) { + ctx.warnings.push({ + instancePath: '/document/notes', + message: + `The license_expression contains contains the following license identifiers that ` + + `are nor listed in Aboutcode or SPDX license list: ` + + `"${notListedLicenses.join(', ')}". ` + + `Therefore exactly one note with ` + + `title "License" and category "legal_disclaimer" must exist`, + }) + } + } + } + + return ctx +} diff --git a/tests/csaf_2_1/oasis.js b/tests/csaf_2_1/oasis.js index a65b2ea6..35506080 100644 --- a/tests/csaf_2_1/oasis.js +++ b/tests/csaf_2_1/oasis.js @@ -20,7 +20,6 @@ const excluded = [ '6.2.24', '6.2.31', '6.2.39.1', - '6.2.46', '6.2.50.1', '6.2.50.2', '6.2.50.3', diff --git a/tests/csaf_2_1/recommendedTest_6_2_46.js b/tests/csaf_2_1/recommendedTest_6_2_46.js new file mode 100644 index 00000000..173ec020 --- /dev/null +++ b/tests/csaf_2_1/recommendedTest_6_2_46.js @@ -0,0 +1,135 @@ +import { + getNotListedLicenses, + recommendedTest_6_2_46, +} from '../../csaf_2_1/recommendedTests/recommendedTest_6_2_46.js' + +describe('recommendedTest_6_2_46', function () { + it('only runs on relevant documents', function () { + expect(recommendedTest_6_2_46({}).warnings.length).to.eql(0) + }) + + it('check license expressions', function () { + expect(getNotListedLicenses('GPL-3.0+')).to.eql([]) + expect(getNotListedLicenses('GPL-3.0-only')).to.be.eql([]) + expect(getNotListedLicenses('MIT OR (Apache-2.0 AND 0BSD)')).to.be.eql([]) + expect(getNotListedLicenses('Invalid-license-expression')).to.be.eql([ + 'Invalid-license-expression', + ]) + expect(getNotListedLicenses('GPL-2.0 OR BSD-3-Clause')).to.be.eql([]) + expect(getNotListedLicenses('LGPL-2.1 OR BSD-3-Clause AND MIT')).to.be.eql( + [] + ) + expect( + getNotListedLicenses('(MIT AND (LGPL-2.1+ AND BSD-3-Clause))') + ).to.eql([]) + expect( + getNotListedLicenses('MIT OR Apache-2.0 WITH Autoconf-exception-2.0'), + 'Exception associated with unrelated license' + ).to.eql([]) + expect( + getNotListedLicenses('MIT WITH Unknown-exception'), + 'Unknown SPDX exception' + ).to.eql(['Unknown-exception']) + expect( + getNotListedLicenses('3dslicer-1.0'), + 'SPDX License List matching guidelines' + ).to.eql(['3dslicer-1.0']) + + expect( + getNotListedLicenses('LicenseRef-www.example.com-no-work-pd'), + 'Valid SPDX expression with License Ref' + ).to.eql(['LicenseRef-www.example.com-no-work-pd']) + + expect( + getNotListedLicenses( + 'LicenseRef-www.example.com-no-work-pd OR BSD-3-Clause AND MIT' + ), + 'Valid SPDX expression with compound-expression and License Ref' + ).to.eql(['LicenseRef-www.example.com-no-work-pd']) + + expect(getNotListedLicenses('wxWindows'), 'Deprecated License').to.eql([]) + + expect( + getNotListedLicenses('DocumentRef-X:LicenseRef-Y AND LicenseRef-X'), + 'DocumentRef in License with compound-expression' + ).to.eql(['LicenseRef-Y', 'LicenseRef-X']) + + expect( + getNotListedLicenses( + 'DocumentRef-some-document-reference:LicenseRef-www.example.org-Example-CSAF-License-2.0' + ), + 'DocumentRef in License' + ).to.eql(['LicenseRef-www.example.org-Example-CSAF-License-2.0']) + + expect( + getNotListedLicenses( + 'LicenseRef-www.example.org-Example-CSAF-License-3.0+' + ), + 'LicenseRef in License with trailing + (invalid expression)' + ).to.eql([]) + expect( + getNotListedLicenses('LicenseRef-scancode-acroname-bdk'), + 'LicenseRef in with About Code Prefix and listed license' + ).to.eql([]) + + expect( + getNotListedLicenses( + 'LicenseRef-scancode-www.example.org-Example-CSAF-License-3.0' + ), + 'LicenseRef in with About Code Prefix and not listed license' + ).to.eql(['LicenseRef-scancode-www.example.org-Example-CSAF-License-3.0']) + + expect( + getNotListedLicenses( + 'MIT WITH AdditionRef-scancode-autoconf-exception-2.0' + ), + 'AboutCode AdditionRef exception' + ).to.eql([]) + expect( + getNotListedLicenses('MIT WITH AdditionRef-example'), + 'Unknown AdditionRef exception' + ).to.eql(['AdditionRef-example']) + expect( + getNotListedLicenses( + 'MIT OR (Apache-2.0 WITH AdditionRef-scancode-autoconf-exception-2.0)' + ), + 'AboutCode AdditionRef exception in compound expression' + ).to.eql([]) + expect(getNotListedLicenses(null), 'Missing license expression').to.eql([]) + }) + + it('skips documents without a supported language translation', function () { + expect( + recommendedTest_6_2_46({ + document: { + license_expression: 'Invalid-license-expression', + }, + }).warnings + ).to.eql([]) + expect( + recommendedTest_6_2_46({ + document: { + lang: 'fr', + license_expression: 'Invalid-license-expression', + }, + }).warnings + ).to.eql([]) + }) + + it('warns when a non-English document has unlisted licenses but no matching disclaimer', function () { + expect( + recommendedTest_6_2_46({ + document: { + lang: 'de', + license_expression: 'Invalid-license-expression', + }, + }).warnings + ).to.eql([ + { + instancePath: '/document/notes', + message: + 'The license_expression contains contains the following license identifiers that are nor listed in Aboutcode or SPDX license list: "Invalid-license-expression". Therefore exactly one note with title "License" and category "legal_disclaimer" must exist', + }, + ]) + }) +})