cra-audit 0.1.0 → 1.0.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/README.md CHANGED
@@ -3,7 +3,7 @@
3
3
  > Compliance audit for the **Cyber Resilience Act** (Regulation EU 2024/2847) and the **BSI TR-03183** technical guideline, for npm projects.
4
4
 
5
5
  `cra-audit` audits the installed dependencies of an npm project and checks the key requirements that the CRA imposes on "products with digital elements". It runs directly with `npx`, **without installation**, and has **no production dependencies** to minimize its own supply-chain surface.
6
-
6
+ It reads the project lockfile natively, so it works with **npm** (`package-lock.json` / `npm-shrinkwrap.json`), **Yarn** (classic v1 and Berry v2+ `yarn.lock`) and **pnpm** (`pnpm-lock.yaml`) — auditing the exact versions each package manager pinned.
7
7
  ```bash
8
8
  # Run ALL the law's checks (Vulnerabilities + Licenses + SBOM)
9
9
  npx cra-audit
@@ -24,13 +24,13 @@ Under the hood, the package runs the checks derived from the CRA legal obligatio
24
24
 
25
25
  ### 1. Automatic SBOM validation
26
26
 
27
- Generates and validates a **Software Bill of Materials (SBOM)** in a machine-readable format —**CycloneDX 1.5** or **SPDX 2.3**— from the `package-lock.json` / `npm-shrinkwrap.json`. It verifies the **minimum elements** required by TR-03183 §6 for every component: name, version, unique identifier (`purl`), cryptographic integrity hash and license.
27
+ Generates and validates a **Software Bill of Materials (SBOM)** in a machine-readable format —**CycloneDX 1.5** or **SPDX 2.3**— from the `package-lock.json` / `npm-shrinkwrap.json`, `yarn.lock` or `pnpm-lock.yaml`. It verifies the **minimum elements** required by TR-03183 §6 for every component: name, version, unique identifier (`purl`), cryptographic integrity hash and license.
28
28
 
29
29
  > CRA Annex I · TR-03183 Part 2 — *"Transparency through SBOM"*.
30
30
 
31
31
  ### 2. "Zero known vulnerabilities" audit
32
32
 
33
- Scans **direct and transitive** dependencies (from the lockfile) with `npm audit` and blocks the audit if there are exploitable vulnerabilities above the configured threshold.
33
+ Scans **direct and transitive** dependencies (from the lockfile) with `npm audit` and blocks the audit if there are exploitable vulnerabilities above the configured threshold. For Yarn/pnpm projects (which have no npm lockfile), `cra-audit` synthesizes a temporary `package-lock.json` from the exact versions pinned in `yarn.lock` / `pnpm-lock.yaml` and runs `npm audit --package-lock-only` against it, so the scanned versions match what the package manager actually resolved.
34
34
 
35
35
  > CRA Art. 13 — *products must be placed on the market without known exploitable vulnerabilities*.
36
36
 
@@ -237,7 +237,7 @@ const signals = await enrichComponents(components, { network: true, github: true
237
237
 
238
238
  - Node.js >= 18 (uses native `fetch` and `node --test`).
239
239
  - `npm` available on the `PATH` (for `npm audit`).
240
- - A `package-lock.json` or `npm-shrinkwrap.json` present (run `npm install` if missing).
240
+ - A lockfile present: `package-lock.json` / `npm-shrinkwrap.json` (npm), `yarn.lock` (Yarn classic or Berry) or `pnpm-lock.yaml` (pnpm). Run `npm install` / `yarn` / `pnpm install` if missing.
241
241
 
242
242
  ## Legal notice
243
243
 
package/package.json CHANGED
@@ -1,8 +1,7 @@
1
1
  {
2
2
  "name": "cra-audit",
3
- "version": "0.1.0",
4
- "description": "Audits npm projects for compliance with the European Cyber Resilience Act (CRA, EU Regulation 2024/2847) and the BSI TR-03183 technical guideline: SBOM (CycloneDX/SPDX), known vulnerabilities, third-party components, and licenses.",
5
- "author": "Miguel Gonzalez Herrera",
3
+ "version": "1.0.0",
4
+ "description": "Audits npm, yarn, and pnpm projects for compliance with the European Cyber Resilience Act (CRA, EU Regulation 2024/2847) and BSI TR-03183: SBOM (CycloneDX/SPDX), known vulnerabilities, third-party components, and licenses.",
6
5
  "keywords": [
7
6
  "cra",
8
7
  "cyber-resilience-act",
package/src/cli.js CHANGED
@@ -79,8 +79,8 @@ async function main(argv) {
79
79
  return 0;
80
80
 
81
81
  default:
82
- logger.error(`Comando desconocido: "${command}".`);
83
- logger.log(`Ejecuta ${color.cyan('cra-audit --help')} para ver los comandos disponibles.`);
82
+ logger.error(`Unknown command: "${command}".`);
83
+ logger.log(`Run ${color.cyan('cra-audit --help')} to see the available commands.`);
84
84
  return 1;
85
85
  }
86
86
  }
@@ -160,56 +160,56 @@ function setFlag(flags, name, value) {
160
160
  function printHelp() {
161
161
  const c = color;
162
162
  logger.log(`
163
- ${c.bold('cra-audit')} — Auditoría de cumplimiento del Cyber Resilience Act (UE 2024/2847)
164
- y la directriz técnica BSI TR-03183 para proyectos npm.
165
-
166
- ${c.bold('USO')}
167
- npx cra-audit [comando] [opciones]
168
-
169
- ${c.bold('COMANDOS')}
170
- ${c.cyan('audit')} Ejecuta TODAS las comprobaciones (vulnerabilidades + SBOM + licencias). Por defecto.
171
- ${c.cyan('visualize')} Genera un informe HTML interactivo (SBOM, licencias, versiones y mantenimiento).
172
- ${c.cyan('sbom generate')} Genera un SBOM (CycloneDX o SPDX) y lo imprime o guarda.
173
- ${c.cyan('sbom check')} Valida que el SBOM cumple los elementos mínimos de TR-03183.
174
- ${c.cyan('vulnerabilities')} Analiza solo vulnerabilidades conocidas (alias: vuln).
175
- ${c.cyan('licenses')} Analiza solo las licencias de las dependencias.
176
- ${c.cyan('help')} Muestra esta ayuda.
177
-
178
- ${c.bold('OPCIONES')}
179
- ${c.cyan('--visualize, -V')} Atajo para generar y abrir el informe HTML interactivo.
180
- ${c.cyan('--github')} Enriquece el informe visual con datos de GitHub (último commit, contribuidores, stars).
181
- ${c.cyan('--offline')} No consulta la red al visualizar (omite metadatos de mantenimiento).
182
- ${c.cyan('--no-open')} No abre el informe HTML en el navegador automáticamente.
183
- ${c.cyan('--sbom')} Atajo equivalente a "sbom check".
184
- ${c.cyan('--format <fmt>')} Formato del SBOM: cyclonedx (def.) | spdx.
185
- ${c.cyan('--fail-on <sev>')} Severidad mínima que hace fallar la auditoría: info|low|moderate|high|critical.
186
- ${c.cyan('--production, --prod')} Audita solo dependencias de producción (omite devDependencies).
187
- ${c.cyan('--no-sbom')} No exige SBOM en la auditoría completa.
188
- ${c.cyan('--json')} Salida en JSON legible por máquina.
189
- ${c.cyan('--output, -o <ruta>')} Escribe el resultado/SBOM/HTML en un archivo.
190
- ${c.cyan('--input, -i <ruta>')} SBOM existente a validar (para "sbom check").
191
- ${c.cyan('--config, -c <ruta>')} Ruta a la política de seguridad (.cra-audit.json).
192
- ${c.cyan('--cwd <ruta>')} Directorio del proyecto a auditar.
193
- ${c.cyan('--no-color')} Desactiva los colores.
194
- ${c.cyan('--version, -v')} Muestra la versión.
195
- ${c.cyan('--help, -h')} Muestra esta ayuda.
196
-
197
- ${c.bold('EJEMPLOS')}
198
- ${c.gray('# Auditoría completa de cumplimiento CRA')}
163
+ ${c.bold('cra-audit')} — Cyber Resilience Act (EU 2024/2847) compliance audit
164
+ and the BSI TR-03183 technical guideline, for npm projects.
165
+
166
+ ${c.bold('USAGE')}
167
+ npx cra-audit [command] [options]
168
+
169
+ ${c.bold('COMMANDS')}
170
+ ${c.cyan('audit')} Run ALL the checks (vulnerabilities + SBOM + licenses). Default.
171
+ ${c.cyan('visualize')} Generate an interactive HTML report (SBOM, licenses, versions and maintenance).
172
+ ${c.cyan('sbom generate')} Generate an SBOM (CycloneDX or SPDX) and print or save it.
173
+ ${c.cyan('sbom check')} Validate that the SBOM meets the TR-03183 minimum elements.
174
+ ${c.cyan('vulnerabilities')} Analyze known vulnerabilities only (alias: vuln).
175
+ ${c.cyan('licenses')} Analyze dependency licenses only.
176
+ ${c.cyan('help')} Show this help.
177
+
178
+ ${c.bold('OPTIONS')}
179
+ ${c.cyan('--visualize, -V')} Shortcut to generate and open the interactive HTML report.
180
+ ${c.cyan('--github')} Enrich the visual report with GitHub data (last commit, contributors, stars).
181
+ ${c.cyan('--offline')} Do not query the network when visualizing (skips maintenance metadata).
182
+ ${c.cyan('--no-open')} Do not open the HTML report in the browser automatically.
183
+ ${c.cyan('--sbom')} Shortcut equivalent to "sbom check".
184
+ ${c.cyan('--format <fmt>')} SBOM format: cyclonedx (default) | spdx.
185
+ ${c.cyan('--fail-on <sev>')} Minimum severity that fails the audit: info|low|moderate|high|critical.
186
+ ${c.cyan('--production, --prod')} Audit production dependencies only (skips devDependencies).
187
+ ${c.cyan('--no-sbom')} Do not require an SBOM in the full audit.
188
+ ${c.cyan('--json')} Machine-readable JSON output.
189
+ ${c.cyan('--output, -o <path>')} Write the result/SBOM/HTML to a file.
190
+ ${c.cyan('--input, -i <path>')} Existing SBOM to validate (for "sbom check").
191
+ ${c.cyan('--config, -c <path>')} Path to the security policy (.cra-audit.json).
192
+ ${c.cyan('--cwd <path>')} Project directory to audit.
193
+ ${c.cyan('--no-color')} Disable colors.
194
+ ${c.cyan('--version, -v')} Show the version.
195
+ ${c.cyan('--help, -h')} Show this help.
196
+
197
+ ${c.bold('EXAMPLES')}
198
+ ${c.gray('# Full CRA compliance audit')}
199
199
  npx cra-audit
200
200
 
201
- ${c.gray('# Informe visual interactivo (HTML) con datos de mantenimiento')}
201
+ ${c.gray('# Interactive visual report (HTML) with maintenance data')}
202
202
  npx cra-audit --visualize
203
203
  npx cra-audit visualize --github
204
204
 
205
- ${c.gray('# Solo la parte del SBOM')}
205
+ ${c.gray('# Just the SBOM part')}
206
206
  npx cra-audit --sbom
207
207
  npx cra-audit sbom check
208
208
 
209
- ${c.gray('# Generar un SBOM CycloneDX en disco')}
209
+ ${c.gray('# Generate a CycloneDX SBOM on disk')}
210
210
  npx cra-audit sbom generate -o sbom.cdx.json
211
211
 
212
- ${c.gray('# Fallar solo ante vulnerabilidades críticas, en CI')}
212
+ ${c.gray('# Fail only on critical vulnerabilities, in CI')}
213
213
  npx cra-audit --fail-on critical --production --json -o cra-report.json
214
214
  `);
215
215
  }
@@ -43,7 +43,7 @@ function resolveRoot(flags) {
43
43
  const start = flags.cwd || process.cwd();
44
44
  const root = findProjectRoot(start);
45
45
  if (!root) {
46
- logger.error('No se encontró ningún package.json. Ejecuta el comando dentro de un proyecto npm.');
46
+ logger.error('No package.json found. Run the command inside an npm project.');
47
47
  return null;
48
48
  }
49
49
  return root;
@@ -16,7 +16,7 @@ const { validateSbom } = require('../core/sbom-validator');
16
16
  function sbomCommand(subcommand, flags) {
17
17
  const projectRoot = findProjectRoot(flags.cwd || process.cwd());
18
18
  if (!projectRoot) {
19
- logger.error('No se encontró ningún package.json. Ejecuta el comando dentro de un proyecto npm.');
19
+ logger.error('No package.json found. Run the command inside an npm project.');
20
20
  return 1;
21
21
  }
22
22
 
@@ -30,7 +30,7 @@ function sbomCommand(subcommand, flags) {
30
30
  return sbomGenerate(projectRoot, format, flags);
31
31
  }
32
32
 
33
- logger.error(`Subcomando de SBOM desconocido: "${action}". Usa "generate" o "check".`);
33
+ logger.error(`Unknown SBOM subcommand: "${action}". Use "generate" or "check".`);
34
34
  return 1;
35
35
  }
36
36
 
@@ -44,7 +44,7 @@ function sbomGenerate(projectRoot, format, flags) {
44
44
  if (flags.output) {
45
45
  const outPath = path.isAbsolute(flags.output) ? flags.output : path.join(projectRoot, flags.output);
46
46
  writeJson(outPath, result.document);
47
- logger.success(`SBOM (${result.format}) con ${result.componentCount} componentes escrito en: ${outPath}`);
47
+ logger.success(`SBOM (${result.format}) with ${result.componentCount} components written to: ${outPath}`);
48
48
  } else {
49
49
  process.stdout.write(JSON.stringify(result.document, null, 2) + '\n');
50
50
  }
@@ -58,7 +58,7 @@ function sbomCheck(projectRoot, format, flags) {
58
58
  if (flags.input) {
59
59
  const inPath = path.isAbsolute(flags.input) ? flags.input : path.join(projectRoot, flags.input);
60
60
  if (!exists(inPath)) {
61
- logger.error(`No se encontró el SBOM indicado: ${flags.input}`);
61
+ logger.error(`The specified SBOM was not found: ${flags.input}`);
62
62
  return 1;
63
63
  }
64
64
  document = readJson(inPath);
@@ -82,18 +82,18 @@ function sbomCheck(projectRoot, format, flags) {
82
82
  return validation.valid ? 0 : 1;
83
83
  }
84
84
 
85
- logger.heading('Validación de SBOM · BSI TR-03183 §6 (elementos mínimos)');
86
- logger.detail(`Formato detectado: ${validation.format} · Componentes: ${validation.stats.total}`);
85
+ logger.heading('SBOM validation · BSI TR-03183 §6 (minimum elements)');
86
+ logger.detail(`Detected format: ${validation.format} · Components: ${validation.stats.total}`);
87
87
  for (const check of validation.checks) {
88
88
  const mark = check.passed ? color.green('✔') : color.red('✖');
89
89
  logger.log(` ${mark} ${check.label}`);
90
90
  }
91
91
  logger.log('');
92
92
  if (validation.valid) {
93
- logger.success(color.bold('SBOM VÁLIDO — cumple los elementos mínimos exigidos por el CRA.'));
93
+ logger.success(color.bold('SBOM VALID — meets the minimum elements required by the CRA.'));
94
94
  return 0;
95
95
  }
96
- logger.error(color.bold(`SBOM INVÁLIDO — ${validation.failedChecks.length} requisito(s) sin cumplir.`));
96
+ logger.error(color.bold(`SBOM INVALID — ${validation.failedChecks.length} requirement(s) not met.`));
97
97
  return 1;
98
98
  }
99
99
 
@@ -25,7 +25,7 @@ const { buildHtml } = require('../reporters/html');
25
25
  async function visualizeCommand(flags) {
26
26
  const projectRoot = findProjectRoot(flags.cwd || process.cwd());
27
27
  if (!projectRoot) {
28
- logger.error('No se encontró ningún package.json. Ejecuta el comando dentro de un proyecto npm.');
28
+ logger.error('No package.json found. Run the command inside an npm project.');
29
29
  return 1;
30
30
  }
31
31
 
@@ -42,7 +42,7 @@ async function visualizeCommand(flags) {
42
42
  policy = {};
43
43
  }
44
44
 
45
- logger.info(`Analizando ${parsed.components.length} componentes de ${parsed.root.name}@${parsed.root.version}…`);
45
+ logger.info(`Analyzing ${parsed.components.length} components from ${parsed.root.name}@${parsed.root.version}…`);
46
46
 
47
47
  // Vulnerabilities + licenses run locally (no network).
48
48
  const vulns = scanVulnerabilities(projectRoot, { production: policy.productionOnly });
@@ -53,23 +53,18 @@ async function visualizeCommand(flags) {
53
53
  const github = Boolean(flags.github);
54
54
  if (network) {
55
55
  logger.info(
56
- `Consultando metadatos de mantenimiento (npm${github ? ' + GitHub' : ''})…` +
57
- (github && !process.env.GITHUB_TOKEN ? color.gray(' (sin GITHUB_TOKEN: límite de 60 req/h)') : '')
56
+ `Fetching maintenance metadata (npm${github ? ' + GitHub' : ''})…` +
57
+ (github && !process.env.GITHUB_TOKEN ? color.gray(' (no GITHUB_TOKEN: limited to 60 req/h)') : '')
58
58
  );
59
59
  } else {
60
- logger.info('Modo offline: se omiten metadatos de mantenimiento.');
60
+ logger.info('Offline mode: maintenance metadata skipped.');
61
61
  }
62
62
 
63
- let lastPct = -1;
64
63
  const enrichment = await enrichComponents(parsed.components, {
65
64
  network,
66
65
  github,
67
66
  onProgress: (done, total) => {
68
- const pct = Math.floor((done / total) * 100);
69
- if (pct !== lastPct && pct % 20 === 0) {
70
- logger.detail(`metadatos npm: ${pct}%`);
71
- lastPct = pct;
72
- }
67
+ logger.progress('npm metadata', done, total);
73
68
  },
74
69
  });
75
70
 
@@ -78,13 +73,13 @@ async function visualizeCommand(flags) {
78
73
 
79
74
  const outPath = resolveOutputPath(projectRoot, flags.output);
80
75
  fs.writeFileSync(outPath, html, 'utf8');
81
- logger.success(`Informe visual escrito en: ${outPath}`);
76
+ logger.success(`Visual report written to: ${outPath}`);
82
77
 
83
78
  if (!flags.noOpen) {
84
79
  openInBrowser(outPath);
85
- logger.detail('Abriendo en el navegador…');
80
+ logger.detail('Opening in the browser…');
86
81
  } else {
87
- logger.detail(`Ábrelo en tu navegador: file://${outPath.replace(/\\/g, '/')}`);
82
+ logger.detail(`Open it in your browser: file://${outPath.replace(/\\/g, '/')}`);
88
83
  }
89
84
 
90
85
  return 0;
@@ -107,11 +102,16 @@ function buildModel(parsed, vulns, licenses, enrichment) {
107
102
  const lic = licByKey.get(key) || null;
108
103
  const enr = enrichment.get(key) || {};
109
104
 
105
+ const license = (lic && lic.license) || c.license || enr.license || null;
106
+ const licenseStatus = (lic && lic.license)
107
+ ? lic.status
108
+ : (license ? 'ok' : 'missing');
109
+
110
110
  return {
111
111
  name: c.name,
112
112
  version: c.version,
113
- license: (lic && lic.license) || c.license || null,
114
- licenseStatus: lic ? lic.status : (c.license ? 'ok' : 'missing'),
113
+ license,
114
+ licenseStatus,
115
115
  severity: vuln ? vuln.severity : 'none',
116
116
  fixAvailable: vuln ? Boolean(vuln.fixAvailable) : false,
117
117
  latest: enr.latest || null,
@@ -34,13 +34,13 @@ function runAudit(projectRoot, policy, policySource, options = {}) {
34
34
  sections.vulnerabilities = vulns;
35
35
 
36
36
  if (!vulns.ok) {
37
- reasons.push({ label: `No se pudieron analizar vulnerabilidades: ${vulns.error}`, passed: false });
37
+ reasons.push({ label: `Vulnerabilities could not be analyzed: ${vulns.error}`, passed: false });
38
38
  } else {
39
39
  const blocking = countBlocking(vulns, policy);
40
40
  reasons.push({
41
41
  label: blocking === 0
42
- ? `Sin vulnerabilidades de severidad >= ${policy.failOn}`
43
- : `${blocking} vulnerabilidad(es) de severidad >= ${policy.failOn}`,
42
+ ? `No vulnerabilities of severity >= ${policy.failOn}`
43
+ : `${blocking} vulnerability(ies) of severity >= ${policy.failOn}`,
44
44
  passed: blocking === 0,
45
45
  });
46
46
  }
@@ -56,12 +56,12 @@ function runAudit(projectRoot, policy, policySource, options = {}) {
56
56
 
57
57
  if (policy.requireSbom) {
58
58
  if (!sbom.ok) {
59
- reasons.push({ label: `No se pudo generar el SBOM: ${sbom.error}`, passed: false });
59
+ reasons.push({ label: `The SBOM could not be generated: ${sbom.error}`, passed: false });
60
60
  } else {
61
61
  reasons.push({
62
62
  label: sbom.validation.valid
63
- ? 'SBOM válido según los elementos mínimos de TR-03183'
64
- : 'SBOM incompleto respecto a TR-03183',
63
+ ? 'SBOM valid against the TR-03183 minimum elements'
64
+ : 'SBOM incomplete with respect to TR-03183',
65
65
  passed: sbom.validation.valid,
66
66
  });
67
67
  }
@@ -74,15 +74,15 @@ function runAudit(projectRoot, policy, policySource, options = {}) {
74
74
  sections.licenses = licenses;
75
75
 
76
76
  if (!licenses.ok) {
77
- reasons.push({ label: `No se pudieron analizar licencias: ${licenses.error}`, passed: false });
77
+ reasons.push({ label: `Licenses could not be analyzed: ${licenses.error}`, passed: false });
78
78
  } else {
79
79
  const s = licenses.summary;
80
80
  const missingFails = policy.licenses.failOnMissing && s.missing.length > 0;
81
81
  const licensesPassed = s.denied.length === 0 && s.notAllowed.length === 0 && !missingFails;
82
82
  reasons.push({
83
83
  label: licensesPassed
84
- ? 'Licencias documentadas y conformes con la política'
85
- : 'Problemas de licencias detectados (prohibidas, no permitidas o sin documentar)',
84
+ ? 'Licenses documented and compliant with the policy'
85
+ : 'License issues detected (denied, not allowed or undocumented)',
86
86
  passed: licensesPassed,
87
87
  });
88
88
  }
@@ -103,9 +103,35 @@ async function fetchNpmMeta(name, timeout) {
103
103
  repo,
104
104
  deprecated,
105
105
  homepage: doc.homepage || null,
106
+ licenseByVersion: licensesByVersion(doc),
106
107
  };
107
108
  }
108
109
 
110
+ /**
111
+ * Builds a compact `{ version: licenseId }` map from a registry document so the
112
+ * report can document a license even when it is absent from the lockfile or
113
+ * not installed on disk (common with pnpm/Yarn).
114
+ */
115
+ function licensesByVersion(doc) {
116
+ const map = {};
117
+ const versions = doc.versions && typeof doc.versions === 'object' ? doc.versions : {};
118
+ for (const [version, vDoc] of Object.entries(versions)) {
119
+ const license = normalizeRegistryLicense(vDoc);
120
+ if (license) map[version] = license;
121
+ }
122
+ return map;
123
+ }
124
+
125
+ function normalizeRegistryLicense(vDoc) {
126
+ if (!vDoc) return null;
127
+ if (typeof vDoc.license === 'string') return vDoc.license;
128
+ if (vDoc.license && typeof vDoc.license.type === 'string') return vDoc.license.type;
129
+ if (Array.isArray(vDoc.licenses) && vDoc.licenses.length) {
130
+ return vDoc.licenses.map((l) => (typeof l === 'string' ? l : l && l.type)).filter(Boolean).join(' OR ') || null;
131
+ }
132
+ return null;
133
+ }
134
+
109
135
  async function fetchGithubRepo(owner, name, timeout, token) {
110
136
  const headers = { 'User-Agent': 'cra-audit', Accept: 'application/vnd.github+json' };
111
137
  if (token) headers.Authorization = `Bearer ${token}`;
@@ -163,7 +189,7 @@ function buildSignals(comp, npm, gh) {
163
189
  const signals = {
164
190
  name: comp.name,
165
191
  version: comp.version,
166
- license: comp.license || null,
192
+ license: comp.license || (npm && npm.licenseByVersion && npm.licenseByVersion[comp.version]) || null,
167
193
  latest: npm ? npm.latest : null,
168
194
  outdated: Boolean(npm && npm.latest && comp.version && npm.latest !== comp.version),
169
195
  lastPublish: npm ? npm.lastPublish : null,