model-eol 0.4.1 → 0.5.1
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/README.md +147 -20
- package/SPEC.md +21 -6
- package/action.yml +30 -9
- package/bot/bot.mjs +361 -165
- package/bot/lib/git.mjs +49 -0
- package/bot/lib/github.mjs +4 -0
- package/bot.yml.example +20 -11
- package/check.mjs +55 -4
- package/examples/model-eol-eval.mjs +84 -0
- package/examples/workflows/model-eol.yml +52 -0
- package/feeds/amazon.json +5 -1
- package/feeds/anthropic.json +4 -1
- package/lib/apply.mjs +393 -231
- package/lib/config.mjs +16 -1
- package/lib/feeds.mjs +22 -3
- package/lib/json-schema.mjs +291 -0
- package/lib/reports.mjs +95 -22
- package/lib/scanner.mjs +159 -25
- package/lib/validate-document.mjs +320 -0
- package/lib/validate-feed.mjs +7 -1
- package/package.json +4 -3
- package/schema/model-eol.alert.schema.json +46 -10
- package/schema/model-eol.bot-config.schema.json +8 -4
- package/schema/model-eol.check.schema.json +56 -0
- package/schema/model-eol.inventory.schema.json +82 -81
- package/schema/model-eol.plan.schema.json +5 -5
- package/schema/model-eol.schedule.schema.json +10 -10
- package/schema/model-eol.schema.json +24 -8
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(
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
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
|
-
|
|
285
|
+
groups.set(identity.key, group)
|
|
249
286
|
}
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
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
|
|
339
|
+
components,
|
|
267
340
|
}
|
|
268
341
|
}
|
|
269
342
|
|