|
| 1 | +// SPDX-FileCopyrightText: LoopBack Contributors |
| 2 | +// SPDX-License-Identifier: MIT |
| 3 | + |
| 4 | +import path from 'path'; |
| 5 | +import glob from 'glob'; |
| 6 | +import Ajv2020 from 'ajv/dist/2020'; |
| 7 | +import addFormats from 'ajv-formats'; |
| 8 | +import osvSchema from '../../vendors/osv-schema/validation/schema.json'; |
| 9 | + |
| 10 | +const osvDocumentGlob = '../../advisories/*.osv.json'; |
| 11 | + |
| 12 | +console.log(`Validating OSV 1.2.0 documents... (Glob: ${osvDocumentGlob})`); |
| 13 | + |
| 14 | +interface ValidationResult { |
| 15 | + isValid: boolean; |
| 16 | + errors: { |
| 17 | + instancePath: string; |
| 18 | + message?: string; |
| 19 | + }[]; |
| 20 | +} |
| 21 | + |
| 22 | +glob(path.resolve(__dirname, osvDocumentGlob), async (err, matches) => { |
| 23 | + if (err) throw Error; |
| 24 | + |
| 25 | + let errorCount = 0; |
| 26 | + |
| 27 | + for (const filePath of matches) { |
| 28 | + process.stdout.write( |
| 29 | + ` L Validating: ${path.relative(process.cwd(), filePath)}...`, |
| 30 | + ); |
| 31 | + const fileContents = require(filePath); |
| 32 | + const validationResults: Record<string, ValidationResult> = { |
| 33 | + jsonSchema: validateJsonSchema(fileContents), |
| 34 | + schemaVersion: validateSchemaVersion(fileContents), |
| 35 | + csaf20Sync: validateCSAF20Sync(filePath, fileContents), |
| 36 | + }; |
| 37 | + |
| 38 | + const errors = Object.values(validationResults).flatMap(x => x.errors); |
| 39 | + const isValid = errors.length < 1; |
| 40 | + |
| 41 | + if (isValid) console.log('Done!'); |
| 42 | + else { |
| 43 | + errorCount += errors.length; |
| 44 | + console.log(`${errors.length} error(s) found:`); |
| 45 | + for (let i = 0; i < errors.length; i++) { |
| 46 | + console.log(` L Error #${i + 1}`); |
| 47 | + console.log(` L Instance path : ${errors[i].instancePath}`); |
| 48 | + console.log(` L Message : ${errors[i].message ?? 'N/A'}`); |
| 49 | + } |
| 50 | + } |
| 51 | + } |
| 52 | + |
| 53 | + if (matches.length === 0) console.log('No OSV 1.2.0 documents found!'); |
| 54 | + |
| 55 | + if (errorCount > 0) { |
| 56 | + console.log(`${errorCount} error(s) found.`); |
| 57 | + process.exit(1); |
| 58 | + } |
| 59 | + |
| 60 | + console.log('OSV 1.2.0 validation done.'); |
| 61 | +}); |
| 62 | + |
| 63 | +function validateJsonSchema(fileContents: any): ValidationResult { |
| 64 | + const validate = addFormats( |
| 65 | + new Ajv2020({strict: false, allErrors: true}), |
| 66 | + ).compile(osvSchema); |
| 67 | + const isValid = validate(fileContents); |
| 68 | + |
| 69 | + return { |
| 70 | + isValid, |
| 71 | + errors: validate.errors ?? [], |
| 72 | + }; |
| 73 | +} |
| 74 | + |
| 75 | +function validateSchemaVersion(fileContents: any): ValidationResult { |
| 76 | + const errors: ValidationResult['errors'] = []; |
| 77 | + |
| 78 | + if (fileContents.schema_version !== '1.2.0') { |
| 79 | + errors.push({ |
| 80 | + instancePath: '/schema_version', |
| 81 | + message: 'schema_version must be `1.2.0`.', |
| 82 | + }); |
| 83 | + } |
| 84 | + |
| 85 | + return { |
| 86 | + isValid: errors.length < 1, |
| 87 | + errors, |
| 88 | + }; |
| 89 | +} |
| 90 | + |
| 91 | +function validateCSAF20Sync( |
| 92 | + filePath: string, |
| 93 | + osvDocument: any, |
| 94 | +): ValidationResult { |
| 95 | + const csaf20Document = require(filePath.replace('.osv.json', '.csaf.json')); |
| 96 | + const errors: ValidationResult['errors'] = []; |
| 97 | + |
| 98 | + // ID sync |
| 99 | + const csaf20ID = csaf20Document.document.tracking.id; |
| 100 | + const osvID = osvDocument.id; |
| 101 | + |
| 102 | + if (osvID !== csaf20ID) { |
| 103 | + errors.push({ |
| 104 | + instancePath: '/id', |
| 105 | + message: 'id must match CSAF 2.0 `/document/tracking/id`.', |
| 106 | + }); |
| 107 | + } |
| 108 | + |
| 109 | + // Summary sync |
| 110 | + const csaf20Summary = csaf20Document.document.notes.find( |
| 111 | + x => x.category === 'summary', |
| 112 | + ).text; |
| 113 | + const osvSummary = osvDocument.summary; |
| 114 | + |
| 115 | + if (csaf20Summary !== osvSummary) { |
| 116 | + errors.push({ |
| 117 | + instancePath: '/summary', |
| 118 | + message: 'summary must match CSAF 2.0 `/document/notes` instance.', |
| 119 | + }); |
| 120 | + } |
| 121 | + |
| 122 | + // Description / Details sync |
| 123 | + const csaf2SDescription = csaf20Document.document.notes.find( |
| 124 | + x => x.category === 'description', |
| 125 | + ).text; |
| 126 | + const osvDetails = osvDocument.details; |
| 127 | + |
| 128 | + if (csaf2SDescription !== osvDetails) { |
| 129 | + errors.push({ |
| 130 | + instancePath: '/details', |
| 131 | + message: 'details must match CSAF 2.0 `/document/notes` instance.', |
| 132 | + }); |
| 133 | + } |
| 134 | + |
| 135 | + // CVE sync |
| 136 | + const csaf20CVE = csaf20Document.vulnerabilities[0].cve; |
| 137 | + const osvCVE = osvDocument.aliases.find(x => x.startsWith('CVE-')); |
| 138 | + |
| 139 | + if (csaf20CVE !== osvCVE) { |
| 140 | + errors.push({ |
| 141 | + instancePath: '/aliases', |
| 142 | + message: 'alises must match CSAF `/vulnerabilities/0/cve`.', |
| 143 | + }); |
| 144 | + } |
| 145 | + |
| 146 | + // CVSS V3 sync |
| 147 | + const csaf20CVSS3 = |
| 148 | + csaf20Document.vulnerabilities[0].scores[0].cvss_v3?.vectorString; |
| 149 | + const osvCVSS3Index = osvDocument.severity.findIndex( |
| 150 | + x => x.type === 'CVSS_V3', |
| 151 | + ); |
| 152 | + const osvCVSS3 = |
| 153 | + osvCVSS3Index > -1 ? osvDocument.severity[osvCVSS3Index].score : undefined; |
| 154 | + |
| 155 | + if (csaf20CVSS3 !== osvCVSS3) { |
| 156 | + errors.push({ |
| 157 | + instancePath: `/severity/score/${osvCVSS3Index}`, |
| 158 | + message: |
| 159 | + 'score must match CSAF 2.0 `/vulnerabilities/0/scores/0/cvss_v3/attackVector`.', |
| 160 | + }); |
| 161 | + } |
| 162 | + |
| 163 | + // CWE sync |
| 164 | + const csaf20CWE = csaf20Document.vulnerabilities[0].cwe.id; |
| 165 | + const osvCWE = osvDocument.database_specific.CWE; |
| 166 | + |
| 167 | + if (csaf20CWE !== osvCWE) { |
| 168 | + errors.push({ |
| 169 | + instancePath: '/database_specific/cwe', |
| 170 | + message: 'cwe must match CSAF 2.0 `/vulnerabilities/0/cwe/id`.', |
| 171 | + }); |
| 172 | + } |
| 173 | + |
| 174 | + // References sync |
| 175 | + const csaf20References = csaf20Document.document.references.map(x => x.url); |
| 176 | + const osvReferences = osvDocument.references.map(x => x.url); |
| 177 | + |
| 178 | + if (osvReferences.length >= csaf20References.length) { |
| 179 | + for (let i = 0; i < osvReferences.length; i++) { |
| 180 | + if (!csaf20References.includes(osvReferences[i])) { |
| 181 | + errors.push({ |
| 182 | + instancePath: `/references/${i}`, |
| 183 | + message: `entry \`${osvReferences[i]}\` not found in CSAF 2.0 \`/document/references\`.`, |
| 184 | + }); |
| 185 | + } |
| 186 | + } |
| 187 | + } else { |
| 188 | + for (let i = 0; i < csaf20References.length; i++) { |
| 189 | + if (!osvReferences.includes(csaf20References[i])) { |
| 190 | + errors.push({ |
| 191 | + instancePath: '/references', |
| 192 | + message: `references missing \`${csaf20References[i]}\` from CSAF 2.0 \`/document/references\`.`, |
| 193 | + }); |
| 194 | + } |
| 195 | + } |
| 196 | + } |
| 197 | + |
| 198 | + // Acknowledgments / credits sync |
| 199 | + const csaf20Acknowledgments = csaf20Document.document.acknowledgments.flatMap( |
| 200 | + x => x.names, |
| 201 | + ) as string[]; |
| 202 | + const osvCredits = osvDocument.credits.flatMap(x => x.name) as string[]; |
| 203 | + |
| 204 | + if (osvCredits.length >= csaf20Acknowledgments.length) { |
| 205 | + for (let i = 0; i < osvCredits.length; i++) { |
| 206 | + const osvCredit = osvCredits[i]; |
| 207 | + if (!csaf20Acknowledgments.includes(osvCredit)) { |
| 208 | + errors.push({ |
| 209 | + instancePath: `/credits/${i}`, |
| 210 | + message: `entry \`${osvCredit}\` not found in CSAF 2.0 \`/document/acknowledgments\`.`, |
| 211 | + }); |
| 212 | + } |
| 213 | + } |
| 214 | + } else { |
| 215 | + for (let i = 0; i < csaf20Acknowledgments.length; i++) { |
| 216 | + const csaf20Acknowledgement = csaf20Acknowledgments[i]; |
| 217 | + if (!osvCredits.includes(csaf20Acknowledgement)) { |
| 218 | + errors.push({ |
| 219 | + instancePath: `/credits`, |
| 220 | + message: `missing entry \`${csaf20Acknowledgement}\` from CSAF 2.0 \`/document/acknowledgments\`.`, |
| 221 | + }); |
| 222 | + } |
| 223 | + } |
| 224 | + } |
| 225 | + |
| 226 | + return { |
| 227 | + isValid: errors.length < 1, |
| 228 | + errors, |
| 229 | + }; |
| 230 | +} |
0 commit comments