model-eol 0.4.1 → 0.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/lib/config.mjs CHANGED
@@ -1,8 +1,11 @@
1
1
  import fs from 'node:fs'
2
+ import { TextDecoder } from 'node:util'
2
3
 
3
4
  import { matchesAnyGlob, normalizeRepoPath } from './glob.mjs'
4
5
  import { MAX_REPORT_BYTES, MAX_TIMEOUT_MS, MIN_TIMEOUT_MS } from './validate-feed.mjs'
5
6
 
7
+ const utf8Decoder = new TextDecoder('utf-8', { fatal: true, ignoreBOM: true })
8
+
6
9
  const frozenArray = () => Object.freeze([])
7
10
 
8
11
  export const DEFAULT_CONFIG = Object.freeze({
@@ -248,9 +251,21 @@ export const loadConfig = (file, { defaults = DEFAULT_CONFIG, allowMissing = tru
248
251
  if (allowMissing) return normalizeConfig({}, { defaults })
249
252
  throw new Error(`could not read config ${file}: file not found`)
250
253
  }
254
+ let bytes
255
+ try {
256
+ bytes = fs.readFileSync(file)
257
+ } catch (error) {
258
+ throw new Error(`could not read config ${file}: ${error.message}`)
259
+ }
260
+ let source
261
+ try {
262
+ source = utf8Decoder.decode(bytes)
263
+ } catch {
264
+ throw new Error(`could not read config ${file}: invalid UTF-8`)
265
+ }
251
266
  let value
252
267
  try {
253
- value = JSON.parse(fs.readFileSync(file, 'utf8'))
268
+ value = JSON.parse(source)
254
269
  } catch (error) {
255
270
  throw new Error(`could not read config ${file}: ${error.message}`)
256
271
  }
package/lib/feeds.mjs CHANGED
@@ -1,8 +1,11 @@
1
1
  import fs from 'node:fs'
2
2
  import path from 'node:path'
3
+ import { TextDecoder } from 'node:util'
3
4
 
4
5
  import { assertIsoDate, assertValidFeed } from './validate-feed.mjs'
5
6
 
7
+ const utf8Decoder = new TextDecoder('utf-8', { fatal: true, ignoreBOM: true })
8
+
6
9
  export { assertIsoDate }
7
10
 
8
11
  export const BUILTIN_CHANNELS = Object.freeze([
@@ -25,10 +28,26 @@ export const loadFeeds = feedsDir => {
25
28
 
26
29
  for (const f of fs.readdirSync(feedsDir).filter(f => f.endsWith('.json'))) {
27
30
  const file = path.join(feedsDir, f)
28
- const feed = JSON.parse(fs.readFileSync(file, 'utf8'))
31
+ let bytes
32
+ try {
33
+ bytes = fs.readFileSync(file)
34
+ } catch (error) {
35
+ throw new Error(`${file}: could not read feed: ${error.message}`)
36
+ }
37
+ let source
38
+ try {
39
+ source = utf8Decoder.decode(bytes)
40
+ } catch {
41
+ throw new Error(`${file}: invalid UTF-8`)
42
+ }
43
+ let feed
44
+ try {
45
+ feed = JSON.parse(source)
46
+ } catch (error) {
47
+ throw new Error(`${file}: invalid JSON: ${error.message}`)
48
+ }
29
49
  if (feed.spec !== 'model-eol/0.1') {
30
- console.error(`skipping ${f}: unknown spec ${feed.spec}`)
31
- continue
50
+ throw new Error(`${file}: unsupported feed spec ${JSON.stringify(feed.spec)}; expected "model-eol/0.1"`)
32
51
  }
33
52
  assertValidFeed(feed, file)
34
53
  feeds.push({ ...feed, file })
@@ -0,0 +1,291 @@
1
+ const isObject = value => value !== null && typeof value === 'object' && !Array.isArray(value)
2
+
3
+ const sameJsonValue = (left, right) => {
4
+ if (Object.is(left, right)) return true
5
+ if (Array.isArray(left) && Array.isArray(right)) {
6
+ return left.length === right.length && left.every((value, index) => sameJsonValue(value, right[index]))
7
+ }
8
+ if (isObject(left) && isObject(right)) {
9
+ const leftKeys = Object.keys(left)
10
+ const rightKeys = Object.keys(right)
11
+ return leftKeys.length === rightKeys.length && leftKeys.every(key =>
12
+ Object.hasOwn(right, key) && sameJsonValue(left[key], right[key]))
13
+ }
14
+ return false
15
+ }
16
+
17
+ const jsonType = value => {
18
+ if (value === null) return 'null'
19
+ if (Array.isArray(value)) return 'array'
20
+ if (Number.isInteger(value)) return 'integer'
21
+ return typeof value
22
+ }
23
+
24
+ const matchesType = (value, type) => {
25
+ if (type === 'null') return value === null
26
+ if (type === 'array') return Array.isArray(value)
27
+ if (type === 'object') return isObject(value)
28
+ if (type === 'integer') return Number.isFinite(value) && Number.isInteger(value)
29
+ if (type === 'number') return Number.isFinite(value) && typeof value === 'number'
30
+ return typeof value === type
31
+ }
32
+
33
+ const propertyPath = (base, property) =>
34
+ /^[A-Za-z_$][A-Za-z0-9_$-]*$/.test(property)
35
+ ? `${base}.${property}`
36
+ : `${base}[${JSON.stringify(property)}]`
37
+
38
+ const isCalendarDate = value => {
39
+ if (typeof value !== 'string' || !/^\d{4}-\d{2}-\d{2}$/.test(value)) return false
40
+ const [year, month, day] = value.split('-').map(Number)
41
+ const date = new Date(Date.UTC(year, month - 1, day))
42
+ if (year >= 0 && year <= 99) date.setUTCFullYear(year)
43
+ return date.getUTCFullYear() === year && date.getUTCMonth() + 1 === month && date.getUTCDate() === day
44
+ }
45
+
46
+ const isDateTime = value => {
47
+ if (typeof value !== 'string') return false
48
+ const match = value.match(/^(\d{4}-\d{2}-\d{2})T(\d{2}):(\d{2}):(\d{2})(?:\.\d+)?(?:Z|([+-])(\d{2}):(\d{2}))$/)
49
+ if (!match || !isCalendarDate(match[1])) return false
50
+ const hour = Number(match[2])
51
+ const minute = Number(match[3])
52
+ const second = Number(match[4])
53
+ const offsetHour = match[6] === undefined ? 0 : Number(match[6])
54
+ const offsetMinute = match[7] === undefined ? 0 : Number(match[7])
55
+ return hour <= 23 && minute <= 59 && second <= 59 && offsetHour <= 23 && offsetMinute <= 59 && !Number.isNaN(Date.parse(value))
56
+ }
57
+
58
+ const isUri = value => {
59
+ if (typeof value !== 'string' || !/^[A-Za-z][A-Za-z0-9+.-]*:/.test(value)) return false
60
+ try {
61
+ return Boolean(new URL(value).protocol)
62
+ } catch {
63
+ return false
64
+ }
65
+ }
66
+
67
+ const FORMAT_VALIDATORS = new Map([
68
+ ['date', isCalendarDate],
69
+ ['date-time', isDateTime],
70
+ ['uri', isUri],
71
+ ])
72
+
73
+ const withoutFragment = value => value.split('#', 1)[0]
74
+
75
+ const decodePointer = fragment => {
76
+ if (!fragment) return []
77
+ const decoded = decodeURIComponent(fragment)
78
+ if (!decoded.startsWith('/')) throw new Error(`unsupported JSON Schema fragment #${fragment}`)
79
+ return decoded.slice(1).split('/').map(token => token.replaceAll('~1', '/').replaceAll('~0', '~'))
80
+ }
81
+
82
+ const atPointer = (root, fragment, reference) => {
83
+ let value = root
84
+ for (const token of decodePointer(fragment)) {
85
+ if ((isObject(value) || Array.isArray(value)) && Object.hasOwn(value, token)) value = value[token]
86
+ else throw new Error(`unresolved JSON Schema reference ${reference}`)
87
+ }
88
+ return value
89
+ }
90
+
91
+ export class JsonSchemaRegistry {
92
+ constructor(schemas) {
93
+ this.schemas = new Map()
94
+ this.ids = new WeakMap()
95
+ for (const schema of schemas) this.add(schema)
96
+ }
97
+
98
+ add(schema) {
99
+ if (!isObject(schema) || typeof schema.$id !== 'string' || !schema.$id) {
100
+ throw new Error('each registered JSON Schema must have a non-empty $id')
101
+ }
102
+ let id
103
+ try {
104
+ id = withoutFragment(new URL(schema.$id).href)
105
+ } catch {
106
+ throw new Error(`JSON Schema has an invalid $id: ${schema.$id}`)
107
+ }
108
+ const previous = this.schemas.get(id)
109
+ if (previous && previous !== schema) throw new Error(`duplicate JSON Schema $id: ${id}`)
110
+ this.schemas.set(id, schema)
111
+ this.ids.set(schema, id)
112
+ return this
113
+ }
114
+
115
+ idFor(schema) {
116
+ const id = this.ids.get(schema)
117
+ if (!id) throw new Error('JSON Schema is not registered')
118
+ return id
119
+ }
120
+
121
+ resolve(reference, fromRoot) {
122
+ const base = this.idFor(fromRoot)
123
+ let resolved
124
+ try {
125
+ resolved = new URL(reference, base)
126
+ } catch {
127
+ throw new Error(`invalid JSON Schema reference ${reference}`)
128
+ }
129
+ const rootId = withoutFragment(resolved.href)
130
+ const root = this.schemas.get(rootId)
131
+ if (!root) throw new Error(`unresolved JSON Schema reference ${reference} from ${base}`)
132
+ const fragment = resolved.hash ? resolved.hash.slice(1) : ''
133
+ return { schema: atPointer(root, fragment, reference), root }
134
+ }
135
+
136
+ assertReferences() {
137
+ const seen = new Set()
138
+ const visit = (value, root) => {
139
+ if (!value || typeof value !== 'object' || seen.has(value)) return
140
+ seen.add(value)
141
+ if (typeof value.$ref === 'string') this.resolve(value.$ref, root)
142
+ if (Array.isArray(value)) {
143
+ for (const item of value) visit(item, root)
144
+ } else {
145
+ for (const item of Object.values(value)) visit(item, root)
146
+ }
147
+ }
148
+ for (const root of this.schemas.values()) visit(root, root)
149
+ return true
150
+ }
151
+ }
152
+
153
+ const describeTypes = types => types.length === 1 ? types[0] : `one of: ${types.join(', ')}`
154
+
155
+ export const validateJsonSchema = (value, schema, { registry = new JsonSchemaRegistry([schema]) } = {}) => {
156
+ const errors = []
157
+ const fail = (path, keyword, message) => errors.push({ path, keyword, message })
158
+
159
+ const validate = (instance, rule, path, root) => {
160
+ if (typeof rule === 'boolean') {
161
+ if (!rule) fail(path, 'falseSchema', 'is not allowed')
162
+ return
163
+ }
164
+ if (!isObject(rule)) throw new Error(`invalid JSON Schema rule at ${path}`)
165
+
166
+ if (typeof rule.$ref === 'string') {
167
+ const resolved = registry.resolve(rule.$ref, root)
168
+ validate(instance, resolved.schema, path, resolved.root)
169
+ return
170
+ }
171
+
172
+ if (Array.isArray(rule.allOf)) {
173
+ for (const member of rule.allOf) validate(instance, member, path, root)
174
+ }
175
+ if (Array.isArray(rule.oneOf)) {
176
+ let matches = 0
177
+ for (const member of rule.oneOf) {
178
+ const errorCount = errors.length
179
+ validate(instance, member, path, root)
180
+ if (errors.length === errorCount) matches++
181
+ else errors.splice(errorCount)
182
+ }
183
+ if (matches !== 1) fail(path, 'oneOf', `must match exactly one schema; matched ${matches}`)
184
+ }
185
+
186
+ const declaredTypes = rule.type === undefined
187
+ ? []
188
+ : Array.isArray(rule.type) ? rule.type : [rule.type]
189
+ if (declaredTypes.length && !declaredTypes.some(type => matchesType(instance, type))) {
190
+ fail(path, 'type', `must be ${describeTypes(declaredTypes)}; got ${jsonType(instance)}`)
191
+ return
192
+ }
193
+
194
+ if (Object.hasOwn(rule, 'const') && !sameJsonValue(instance, rule.const)) {
195
+ fail(path, 'const', `must equal ${JSON.stringify(rule.const)}`)
196
+ }
197
+ if (Array.isArray(rule.enum) && !rule.enum.some(candidate => sameJsonValue(instance, candidate))) {
198
+ fail(path, 'enum', `must be one of ${rule.enum.map(candidate => JSON.stringify(candidate)).join(', ')}`)
199
+ }
200
+
201
+ if (typeof instance === 'string') {
202
+ if (Number.isInteger(rule.minLength) && instance.length < rule.minLength) {
203
+ fail(path, 'minLength', `must have at least ${rule.minLength} character(s)`)
204
+ }
205
+ if (Number.isInteger(rule.maxLength) && instance.length > rule.maxLength) {
206
+ fail(path, 'maxLength', `must have at most ${rule.maxLength} character(s)`)
207
+ }
208
+ if (typeof rule.pattern === 'string' && !(new RegExp(rule.pattern)).test(instance)) {
209
+ fail(path, 'pattern', `must match pattern ${rule.pattern}`)
210
+ }
211
+ if (typeof rule.format === 'string') {
212
+ const formatValidator = FORMAT_VALIDATORS.get(rule.format)
213
+ if (!formatValidator) throw new Error(`unsupported JSON Schema format ${rule.format}`)
214
+ if (!formatValidator(instance)) fail(path, 'format', `must match format ${rule.format}`)
215
+ }
216
+ }
217
+
218
+ if (typeof instance === 'number' && Number.isFinite(instance)) {
219
+ if (typeof rule.minimum === 'number' && instance < rule.minimum) {
220
+ fail(path, 'minimum', `must be >= ${rule.minimum}`)
221
+ }
222
+ if (typeof rule.maximum === 'number' && instance > rule.maximum) {
223
+ fail(path, 'maximum', `must be <= ${rule.maximum}`)
224
+ }
225
+ }
226
+
227
+ if (Array.isArray(instance)) {
228
+ if (Number.isInteger(rule.minItems) && instance.length < rule.minItems) {
229
+ fail(path, 'minItems', `must contain at least ${rule.minItems} item(s)`)
230
+ }
231
+ if (Number.isInteger(rule.maxItems) && instance.length > rule.maxItems) {
232
+ fail(path, 'maxItems', `must contain at most ${rule.maxItems} item(s)`)
233
+ }
234
+ if (rule.uniqueItems === true) {
235
+ for (let index = 0; index < instance.length; index++) {
236
+ if (instance.slice(0, index).some(item => sameJsonValue(item, instance[index]))) {
237
+ fail(`${path}[${index}]`, 'uniqueItems', 'must not duplicate another array item')
238
+ }
239
+ }
240
+ }
241
+ if (rule.items !== undefined) {
242
+ instance.forEach((item, index) => validate(item, rule.items, `${path}[${index}]`, root))
243
+ }
244
+ }
245
+
246
+ if (isObject(instance)) {
247
+ const properties = isObject(rule.properties) ? rule.properties : {}
248
+ if (Array.isArray(rule.required)) {
249
+ for (const property of rule.required) {
250
+ if (!Object.hasOwn(instance, property)) fail(propertyPath(path, property), 'required', 'is required')
251
+ }
252
+ }
253
+ for (const [property, propertySchema] of Object.entries(properties)) {
254
+ if (Object.hasOwn(instance, property)) validate(instance[property], propertySchema, propertyPath(path, property), root)
255
+ }
256
+ if (isObject(rule.dependencies)) {
257
+ for (const [property, dependencies] of Object.entries(rule.dependencies)) {
258
+ if (!Object.hasOwn(instance, property)) continue
259
+ if (Array.isArray(dependencies)) {
260
+ for (const dependency of dependencies) {
261
+ if (!Object.hasOwn(instance, dependency)) {
262
+ fail(propertyPath(path, dependency), 'dependencies', `is required when ${property} is present`)
263
+ }
264
+ }
265
+ } else {
266
+ validate(instance, dependencies, path, root)
267
+ }
268
+ }
269
+ }
270
+ for (const property of Object.keys(instance)) {
271
+ if (Object.hasOwn(properties, property)) continue
272
+ if (rule.additionalProperties === false) {
273
+ fail(propertyPath(path, property), 'additionalProperties', 'is not allowed')
274
+ } else if (isObject(rule.additionalProperties) || typeof rule.additionalProperties === 'boolean') {
275
+ validate(instance[property], rule.additionalProperties, propertyPath(path, property), root)
276
+ }
277
+ }
278
+ }
279
+ }
280
+
281
+ const root = schema
282
+ registry.idFor(root)
283
+ validate(value, schema, '$', root)
284
+ return errors
285
+ }
286
+
287
+ export const assertJsonSchema = (value, schema, options = {}) => {
288
+ const errors = validateJsonSchema(value, schema, options)
289
+ if (errors.length) throw new Error(errors.map(error => `${error.path}: ${error.message}`).join('\n'))
290
+ return value
291
+ }
package/lib/reports.mjs CHANGED
@@ -65,6 +65,8 @@ export const buildInventory = ({ scan, findings, days, via, scope, targets }) =>
65
65
  ...(ref.mapped_from ? { mapped_from: ref.mapped_from } : {}),
66
66
  safe_until: lifecycle.safe_until,
67
67
  replacement: lifecycle.replacement,
68
+ ...(lifecycle.replacement_options?.length ? { replacement_options: lifecycle.replacement_options } : {}),
69
+ ...(lifecycle.replacement_note ? { replacement_note: lifecycle.replacement_note } : {}),
68
70
  source: ref.source,
69
71
  policy: ref.policy ?? null,
70
72
  feed_generated: ref.generated ?? null,
@@ -229,30 +231,101 @@ export const formatSchedule = (schedule, days) => {
229
231
  }
230
232
 
231
233
  export const formatInventoryCycloneDX = inventory => {
232
- const components = new Map()
233
- for (const item of inventory.model_references) {
234
- let component = components.get(item.id)
235
- if (!component) {
236
- component = {
237
- type: 'machine-learning-model',
238
- name: item.id,
239
- group: item.publisher,
240
- properties: [
241
- { name: 'model-eol:status', value: String(item.status) },
242
- { name: 'model-eol:shutdown', value: item.shutdown ?? '' },
243
- { name: 'model-eol:replacement', value: item.replacement ?? '' },
244
- { name: 'model-eol:usage', value: item.usage },
245
- ],
246
- evidence: { occurrences: [] },
234
+ const statusSeverity = new Map([
235
+ ['retired', 0],
236
+ ['retiring', 1],
237
+ ['scheduled', 2],
238
+ ['watch', 3],
239
+ ['ok', 4],
240
+ ])
241
+ const lifecycleChannel = item => {
242
+ if (typeof item.requested_via === 'string') {
243
+ return { requested: item.requested_via, label: item.requested_via }
244
+ }
245
+ if (item.requested_via === null) return { requested: null, label: 'publisher-direct' }
246
+ if (item.via && item.via !== 'publisher' && item.via !== 'publisher-fallback') {
247
+ return { requested: item.via, label: item.via }
248
+ }
249
+ return { requested: null, label: 'publisher-direct' }
250
+ }
251
+ const encodeIdentityPart = value => encodeURIComponent(String(value))
252
+ const compareText = (a, b) => a < b ? -1 : a > b ? 1 : 0
253
+ const identityFor = item => {
254
+ const channel = lifecycleChannel(item)
255
+ return {
256
+ channel,
257
+ key: JSON.stringify([item.publisher, item.id, channel.requested]),
258
+ bomRef: `model-eol:model:${encodeIdentityPart(item.publisher)}:${encodeIdentityPart(item.id)}:via:${encodeIdentityPart(channel.label)}`,
259
+ }
260
+ }
261
+ const compareReferences = (a, b) => {
262
+ const aIdentity = identityFor(a)
263
+ const bIdentity = identityFor(b)
264
+ return compareText(a.publisher, b.publisher) ||
265
+ compareText(a.id, b.id) ||
266
+ compareText(aIdentity.channel.label, bIdentity.channel.label) ||
267
+ compareText(a.file, b.file) ||
268
+ a.line - b.line ||
269
+ compareText(a.usage, b.usage) ||
270
+ compareText(a.matched, b.matched)
271
+ }
272
+
273
+ const groups = new Map()
274
+ for (const item of [...inventory.model_references].sort(compareReferences)) {
275
+ const identity = identityFor(item)
276
+ let group = groups.get(identity.key)
277
+ if (!group) {
278
+ group = {
279
+ identity,
280
+ item,
281
+ status: item.status,
282
+ usages: new Set(),
283
+ occurrences: new Set(),
247
284
  }
248
- components.set(item.id, component)
285
+ groups.set(identity.key, group)
249
286
  }
250
- const usage = component.properties.find(property => property.name === 'model-eol:usage')
251
- const usages = new Set(usage.value ? usage.value.split(',') : [])
252
- usages.add(item.usage)
253
- usage.value = [...usages].sort().join(',')
254
- component.evidence.occurrences.push({ location: `${item.file}#${item.line}` })
287
+ if ((statusSeverity.get(item.status) ?? 9) < (statusSeverity.get(group.status) ?? 9)) {
288
+ group.status = item.status
289
+ }
290
+ group.usages.add(item.usage)
291
+ group.occurrences.add(`${item.file}#${item.line}`)
255
292
  }
293
+
294
+ const components = [...groups.values()].map(group => {
295
+ const { item, identity } = group
296
+ return {
297
+ type: 'machine-learning-model',
298
+ 'bom-ref': identity.bomRef,
299
+ name: item.id,
300
+ group: item.publisher,
301
+ properties: [
302
+ { name: 'model-eol:lifecycle_channel', value: identity.channel.label },
303
+ { name: 'model-eol:applied_clock', value: item.via ?? 'unavailable' },
304
+ { name: 'model-eol:status', value: String(group.status) },
305
+ { name: 'model-eol:shutdown', value: item.shutdown ?? '' },
306
+ ...(item.date_precision
307
+ ? [{ name: 'model-eol:date_precision', value: item.date_precision }]
308
+ : []),
309
+ ...(item.distribution_status
310
+ ? [{ name: 'model-eol:distribution_status', value: item.distribution_status }]
311
+ : []),
312
+ ...(item.safe_until
313
+ ? [{ name: 'model-eol:safe_until', value: item.safe_until }]
314
+ : []),
315
+ { name: 'model-eol:replacement', value: item.replacement ?? '' },
316
+ ...(item.replacement_options?.length
317
+ ? [{ name: 'model-eol:replacement_options', value: JSON.stringify(item.replacement_options) }]
318
+ : []),
319
+ ...(item.replacement_note
320
+ ? [{ name: 'model-eol:replacement_note', value: item.replacement_note }]
321
+ : []),
322
+ { name: 'model-eol:usage', value: [...group.usages].sort().join(',') },
323
+ ],
324
+ evidence: {
325
+ occurrences: [...group.occurrences].sort().map(location => ({ location })),
326
+ },
327
+ }
328
+ })
256
329
  return {
257
330
  bomFormat: 'CycloneDX',
258
331
  specVersion: '1.6',
@@ -263,7 +336,7 @@ export const formatInventoryCycloneDX = inventory => {
263
336
  { name: 'model-eol:generator', value: 'model-eol/inventory-cyclonedx@0.1' },
264
337
  ],
265
338
  },
266
- components: [...components.values()],
339
+ components,
267
340
  }
268
341
  }
269
342