@shubhambattoo/look-see 0.0.0-stage → 0.2.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/LICENSE +17 -0
- package/README.md +64 -2
- package/bin/look-see.js +4 -0
- package/package.json +38 -4
- package/src/advisories/nvd.js +69 -0
- package/src/advisories/osv.js +111 -0
- package/src/analyze.js +120 -0
- package/src/cache.js +38 -0
- package/src/cli.js +215 -0
- package/src/discovery/go.js +62 -0
- package/src/discovery/helpers.js +70 -0
- package/src/discovery/index.js +27 -0
- package/src/discovery/npm.js +145 -0
- package/src/progress.js +82 -0
- package/src/purl.js +24 -0
- package/src/report.js +116 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 shubhambattoo
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT.
|
package/README.md
CHANGED
|
@@ -1,3 +1,65 @@
|
|
|
1
|
-
#
|
|
1
|
+
# Look-See
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
Look-See is a Node.js CLI that scans supported dependency lockfiles, identifies packages with PURLs, checks exact package versions against OSV, and enriches matching CVEs with NVD data. It writes JSON and HTML reports and opens the HTML report by default.
|
|
4
|
+
|
|
5
|
+
> Early MVP: npm lockfiles and Go `go.mod` requirements are supported. Automated dependency fixes are not implemented yet.
|
|
6
|
+
|
|
7
|
+
## Usage
|
|
8
|
+
|
|
9
|
+
Requires Node.js 22 or newer. Use the CLI with `npx` without a global install, or install it globally to use the `look-see` command directly.
|
|
10
|
+
|
|
11
|
+
### Run with npx
|
|
12
|
+
|
|
13
|
+
```bash
|
|
14
|
+
npx @shubhambattoo/look-see /path/to/project
|
|
15
|
+
```
|
|
16
|
+
|
|
17
|
+
### Install globally
|
|
18
|
+
|
|
19
|
+
```bash
|
|
20
|
+
npm install -g @shubhambattoo/look-see
|
|
21
|
+
look-see /path/to/project
|
|
22
|
+
```
|
|
23
|
+
|
|
24
|
+
To run from this checkout:
|
|
25
|
+
|
|
26
|
+
```bash
|
|
27
|
+
npm install
|
|
28
|
+
npm start -- /path/to/project --no-open
|
|
29
|
+
```
|
|
30
|
+
|
|
31
|
+
The default output directory is `<project>/.look-see/`:
|
|
32
|
+
|
|
33
|
+
- `look-see-output.html`
|
|
34
|
+
- `look-see-output.json`
|
|
35
|
+
|
|
36
|
+
The CLI scans `package-lock.json`, `pnpm-lock.yaml`, `yarn.lock`, and `go.mod` files below the project directory. It skips common generated/dependency directories such as `node_modules`, `vendor`, and `.git`.
|
|
37
|
+
|
|
38
|
+
## Options
|
|
39
|
+
|
|
40
|
+
```text
|
|
41
|
+
look-see [project-directory]
|
|
42
|
+
--out <directory> Report output directory
|
|
43
|
+
--format html,json Select one or both report formats
|
|
44
|
+
--no-open Do not open the HTML report
|
|
45
|
+
--no-nvd Skip NVD CVE enrichment
|
|
46
|
+
--fail-on <severity> Exit 1 if a finding meets the severity threshold
|
|
47
|
+
--help Show help
|
|
48
|
+
```
|
|
49
|
+
|
|
50
|
+
Set `NVD_API_KEY` for higher NVD API request limits. Advisory responses are cached locally; use `LOOK_SEE_CACHE_DIR` to choose a cache location.
|
|
51
|
+
|
|
52
|
+
## Matching and coverage
|
|
53
|
+
|
|
54
|
+
Each locked dependency is represented by a Package URL. OSV is queried using the package identity and resolved version, which provides ecosystem-aware matching. Matching CVE IDs are then looked up in NVD for CVSS and reference enrichment. NVD is not used as a fuzzy package-name search: its CPE records do not reliably map to every package-manager coordinate.
|
|
55
|
+
|
|
56
|
+
The npm lockfile parsers report resolved versions, including transitive packages. Go scanning currently reads requirements from `go.mod`; it does not run `go list`, resolve the complete selected/transitive module graph, or apply `replace` directives. Unsupported manifests are not scanned. Review the report's coverage and errors before interpreting an empty result as clean.
|
|
57
|
+
|
|
58
|
+
NVD may rate-limit unauthenticated lookups. Enrichment errors are included in both reports; OSV matching results remain available if NVD is unavailable. The CLI does not modify project files.
|
|
59
|
+
|
|
60
|
+
## Development
|
|
61
|
+
|
|
62
|
+
```bash
|
|
63
|
+
npm install
|
|
64
|
+
npm test
|
|
65
|
+
```
|
package/bin/look-see.js
ADDED
package/package.json
CHANGED
|
@@ -1,6 +1,40 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@shubhambattoo/look-see",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"
|
|
5
|
-
"
|
|
6
|
-
|
|
3
|
+
"version": "0.2.0",
|
|
4
|
+
"description": "Look-See scans locked project dependencies for known vulnerabilities",
|
|
5
|
+
"author": "shubhambattoo",
|
|
6
|
+
"type": "module",
|
|
7
|
+
"engines": {
|
|
8
|
+
"node": ">=22"
|
|
9
|
+
},
|
|
10
|
+
"bin": {
|
|
11
|
+
"look-see": "./bin/look-see.js"
|
|
12
|
+
},
|
|
13
|
+
"files": [
|
|
14
|
+
"bin/",
|
|
15
|
+
"src/",
|
|
16
|
+
"README.md",
|
|
17
|
+
"LICENSE"
|
|
18
|
+
],
|
|
19
|
+
"scripts": {
|
|
20
|
+
"test": "node --test",
|
|
21
|
+
"start": "node ./bin/look-see.js",
|
|
22
|
+
"prepublishOnly": "npm test"
|
|
23
|
+
},
|
|
24
|
+
"dependencies": {
|
|
25
|
+
"@yarnpkg/lockfile": "^1.1.0",
|
|
26
|
+
"chalk": "^6.0.1",
|
|
27
|
+
"yaml": "^2.8.1"
|
|
28
|
+
},
|
|
29
|
+
"license": "MIT",
|
|
30
|
+
"keywords": [
|
|
31
|
+
"dependency-audit",
|
|
32
|
+
"vulnerability-scanner",
|
|
33
|
+
"security",
|
|
34
|
+
"osv",
|
|
35
|
+
"nvd"
|
|
36
|
+
],
|
|
37
|
+
"publishConfig": {
|
|
38
|
+
"access": "public"
|
|
39
|
+
}
|
|
40
|
+
}
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
import { getCached, setCached } from '../cache.js';
|
|
2
|
+
|
|
3
|
+
const NVD_URL = 'https://services.nvd.nist.gov/rest/json/cves/2.0';
|
|
4
|
+
const CACHE_TTL = 7 * 24 * 60 * 60 * 1000;
|
|
5
|
+
const NVD_API_KEY = process.env.NVD_API_KEY;
|
|
6
|
+
const MIN_INTERVAL_MS = NVD_API_KEY ? 700 : 6_100;
|
|
7
|
+
|
|
8
|
+
function cveIdsFrom(vulnerabilities) {
|
|
9
|
+
const ids = new Set();
|
|
10
|
+
for (const vulnerability of vulnerabilities) {
|
|
11
|
+
for (const alias of vulnerability.aliases ?? []) {
|
|
12
|
+
if (/^CVE-\d{4}-\d+$/i.test(alias)) ids.add(alias.toUpperCase());
|
|
13
|
+
}
|
|
14
|
+
if (/^CVE-\d{4}-\d+$/i.test(vulnerability.id ?? '')) ids.add(vulnerability.id.toUpperCase());
|
|
15
|
+
}
|
|
16
|
+
return [...ids].sort();
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
async function fetchCve(cveId) {
|
|
20
|
+
const url = new URL(NVD_URL);
|
|
21
|
+
url.searchParams.set('cveId', cveId);
|
|
22
|
+
const headers = { accept: 'application/json' };
|
|
23
|
+
if (NVD_API_KEY) headers.apiKey = NVD_API_KEY;
|
|
24
|
+
const response = await fetch(url, { headers, signal: AbortSignal.timeout(30_000) });
|
|
25
|
+
if (!response.ok) throw new Error(`NVD returned HTTP ${response.status}`);
|
|
26
|
+
const body = await response.json();
|
|
27
|
+
return body.vulnerabilities?.[0]?.cve ?? null;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export async function queryNvd(vulnerabilities, { onProgress } = {}) {
|
|
31
|
+
const ids = cveIdsFrom(vulnerabilities);
|
|
32
|
+
const byCve = new Map();
|
|
33
|
+
const errors = [];
|
|
34
|
+
let lastRequestAt = 0;
|
|
35
|
+
let completed = 0;
|
|
36
|
+
onProgress?.({ stage: 'nvd', completed, total: ids.length });
|
|
37
|
+
for (const cveId of ids) {
|
|
38
|
+
const cacheKey = `nvd:${cveId}`;
|
|
39
|
+
const cached = await getCached(cacheKey, CACHE_TTL);
|
|
40
|
+
if (cached !== null) {
|
|
41
|
+
byCve.set(cveId, cached && Object.hasOwn(cached, 'cve') ? cached.cve : cached);
|
|
42
|
+
completed += 1;
|
|
43
|
+
onProgress?.({ stage: 'nvd', completed, total: ids.length });
|
|
44
|
+
continue;
|
|
45
|
+
}
|
|
46
|
+
const waitMs = Math.max(0, MIN_INTERVAL_MS - (Date.now() - lastRequestAt));
|
|
47
|
+
if (waitMs) await new Promise((resolve) => setTimeout(resolve, waitMs));
|
|
48
|
+
try {
|
|
49
|
+
const cve = await fetchCve(cveId);
|
|
50
|
+
lastRequestAt = Date.now();
|
|
51
|
+
byCve.set(cveId, cve);
|
|
52
|
+
await setCached(cacheKey, { cve });
|
|
53
|
+
completed += 1;
|
|
54
|
+
onProgress?.({ stage: 'nvd', completed, total: ids.length });
|
|
55
|
+
} catch (error) {
|
|
56
|
+
lastRequestAt = Date.now();
|
|
57
|
+
errors.push({ source: 'NVD', cveId, message: error.message });
|
|
58
|
+
completed += 1;
|
|
59
|
+
onProgress?.({ stage: 'nvd', completed, total: ids.length });
|
|
60
|
+
if (/HTTP 403|HTTP 429/.test(error.message)) {
|
|
61
|
+
errors.push({ source: 'NVD', message: 'Stopped further NVD lookups after a rate-limit response.' });
|
|
62
|
+
break;
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
return { byCve, errors };
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
export const nvdHelpers = { cveIdsFrom };
|
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
import { getCached, setCached } from '../cache.js';
|
|
2
|
+
import { packagePurlName } from '../purl.js';
|
|
3
|
+
|
|
4
|
+
const OSV_URL = 'https://api.osv.dev/v1/querybatch';
|
|
5
|
+
const OSV_VULN_URL = 'https://api.osv.dev/v1/vulns/';
|
|
6
|
+
const CACHE_TTL = 24 * 60 * 60 * 1000;
|
|
7
|
+
|
|
8
|
+
async function fetchBatch(queries) {
|
|
9
|
+
const response = await fetch(OSV_URL, {
|
|
10
|
+
method: 'POST',
|
|
11
|
+
headers: { 'content-type': 'application/json', accept: 'application/json' },
|
|
12
|
+
body: JSON.stringify({ queries }),
|
|
13
|
+
signal: AbortSignal.timeout(30_000),
|
|
14
|
+
});
|
|
15
|
+
if (!response.ok) throw new Error(`OSV returned HTTP ${response.status}`);
|
|
16
|
+
return response.json();
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
async function fetchAdvisory(id) {
|
|
20
|
+
const response = await fetch(`${OSV_VULN_URL}${encodeURIComponent(id)}`, {
|
|
21
|
+
headers: { accept: 'application/json' },
|
|
22
|
+
signal: AbortSignal.timeout(30_000),
|
|
23
|
+
});
|
|
24
|
+
if (!response.ok) throw new Error(`OSV advisory lookup returned HTTP ${response.status}`);
|
|
25
|
+
return response.json();
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
async function hydrateAdvisories(byPackage, errors, onProgress) {
|
|
29
|
+
const unique = new Map();
|
|
30
|
+
for (const vulnerabilities of byPackage.values()) {
|
|
31
|
+
for (const vulnerability of vulnerabilities) {
|
|
32
|
+
if (vulnerability?.id) unique.set(vulnerability.id, vulnerability);
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
const details = new Map();
|
|
37
|
+
const missing = [];
|
|
38
|
+
for (const [id, fallback] of unique) {
|
|
39
|
+
const cached = await getCached(`osv-vuln:${id}`, CACHE_TTL);
|
|
40
|
+
if (cached) details.set(id, cached);
|
|
41
|
+
else missing.push([id, fallback]);
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
let completed = unique.size - missing.length;
|
|
45
|
+
onProgress?.({ stage: 'advisories', completed, total: unique.size });
|
|
46
|
+
for (let offset = 0; offset < missing.length; offset += 10) {
|
|
47
|
+
const chunk = missing.slice(offset, offset + 10);
|
|
48
|
+
const results = await Promise.all(chunk.map(async ([id, fallback]) => {
|
|
49
|
+
try {
|
|
50
|
+
const advisory = await fetchAdvisory(id);
|
|
51
|
+
await setCached(`osv-vuln:${id}`, advisory);
|
|
52
|
+
return [id, advisory];
|
|
53
|
+
} catch (error) {
|
|
54
|
+
errors.push({ source: 'OSV', advisoryId: id, message: error.message });
|
|
55
|
+
return [id, fallback];
|
|
56
|
+
}
|
|
57
|
+
}));
|
|
58
|
+
for (const [id, advisory] of results) details.set(id, advisory);
|
|
59
|
+
completed += chunk.length;
|
|
60
|
+
onProgress?.({ stage: 'advisories', completed, total: unique.size });
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
for (const [purl, vulnerabilities] of byPackage) {
|
|
64
|
+
byPackage.set(purl, vulnerabilities.map((vulnerability) => details.get(vulnerability.id) ?? vulnerability));
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
export async function queryOsv(packages, { onProgress } = {}) {
|
|
69
|
+
const byPackage = new Map();
|
|
70
|
+
const errors = [];
|
|
71
|
+
const missing = [];
|
|
72
|
+
for (const item of packages) {
|
|
73
|
+
const key = `osv:${item.purl}`;
|
|
74
|
+
const cached = await getCached(key, CACHE_TTL);
|
|
75
|
+
if (cached) byPackage.set(item.purl, cached);
|
|
76
|
+
else missing.push(item);
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
let packageCompleted = packages.length - missing.length;
|
|
80
|
+
onProgress?.({ stage: 'packages', completed: packageCompleted, total: packages.length });
|
|
81
|
+
for (let offset = 0; offset < missing.length; offset += 100) {
|
|
82
|
+
const batch = missing.slice(offset, offset + 100);
|
|
83
|
+
const queries = batch.map((item) => ({
|
|
84
|
+
version: item.version,
|
|
85
|
+
package: { purl: packagePurlName(item.ecosystem, item.name) },
|
|
86
|
+
}));
|
|
87
|
+
try {
|
|
88
|
+
const result = await fetchBatch(queries);
|
|
89
|
+
if (!Array.isArray(result.results) || result.results.length !== batch.length) {
|
|
90
|
+
throw new Error('OSV returned an unexpected batch response');
|
|
91
|
+
}
|
|
92
|
+
for (let index = 0; index < batch.length; index += 1) {
|
|
93
|
+
const item = batch[index];
|
|
94
|
+
const response = result.results[index];
|
|
95
|
+
if (response?.error) {
|
|
96
|
+
errors.push({ source: 'OSV', package: item.purl, message: response.error });
|
|
97
|
+
continue;
|
|
98
|
+
}
|
|
99
|
+
const vulnerabilities = response?.vulns ?? [];
|
|
100
|
+
byPackage.set(item.purl, vulnerabilities);
|
|
101
|
+
await setCached(`osv:${item.purl}`, vulnerabilities);
|
|
102
|
+
}
|
|
103
|
+
} catch (error) {
|
|
104
|
+
errors.push({ source: 'OSV', message: error.message });
|
|
105
|
+
}
|
|
106
|
+
packageCompleted += batch.length;
|
|
107
|
+
onProgress?.({ stage: 'packages', completed: packageCompleted, total: packages.length });
|
|
108
|
+
}
|
|
109
|
+
await hydrateAdvisories(byPackage, errors, onProgress);
|
|
110
|
+
return { byPackage, errors };
|
|
111
|
+
}
|
package/src/analyze.js
ADDED
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
function highestNvdMetric(cve) {
|
|
2
|
+
const metrics = cve?.metrics ?? {};
|
|
3
|
+
const candidates = Object.values(metrics).flat().filter((metric) => metric?.cvssData?.baseScore != null);
|
|
4
|
+
candidates.sort((a, b) => b.cvssData.baseScore - a.cvssData.baseScore);
|
|
5
|
+
if (!candidates.length) return null;
|
|
6
|
+
const metric = candidates[0];
|
|
7
|
+
return {
|
|
8
|
+
score: metric.cvssData.baseScore,
|
|
9
|
+
severity: metric.cvssData.baseSeverity ?? metric.baseSeverity ?? null,
|
|
10
|
+
version: metric.cvssData.version ?? null,
|
|
11
|
+
vector: metric.cvssData.vectorString ?? null,
|
|
12
|
+
};
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
function englishDescription(entries = []) {
|
|
16
|
+
return entries.find((entry) => entry.lang === 'en')?.value ?? entries[0]?.value ?? null;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
function nvdData(cve) {
|
|
20
|
+
if (!cve) return null;
|
|
21
|
+
return {
|
|
22
|
+
id: cve.id,
|
|
23
|
+
published: cve.published ?? null,
|
|
24
|
+
lastModified: cve.lastModified ?? null,
|
|
25
|
+
description: englishDescription(cve.descriptions),
|
|
26
|
+
severity: highestNvdMetric(cve),
|
|
27
|
+
references: (cve.references ?? []).map((reference) => reference.url).filter(Boolean),
|
|
28
|
+
};
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
function fixedVersions(vulnerability) {
|
|
32
|
+
const versions = new Set();
|
|
33
|
+
for (const affected of vulnerability.affected ?? []) {
|
|
34
|
+
for (const range of affected.ranges ?? []) {
|
|
35
|
+
for (const event of range.events ?? []) {
|
|
36
|
+
if (event.fixed) versions.add(event.fixed);
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
for (const version of affected.ecosystem_specific?.fixed ?? []) versions.add(version);
|
|
40
|
+
}
|
|
41
|
+
return [...versions];
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
function advisoryReferences(vulnerability, nvd) {
|
|
45
|
+
return [...new Set([
|
|
46
|
+
...(vulnerability.references ?? []).map((reference) => reference.url),
|
|
47
|
+
...(nvd?.references ?? []),
|
|
48
|
+
].filter((url) => typeof url === 'string' && /^https?:\/\//i.test(url)))];
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
function severityOf(vulnerability, nvd) {
|
|
52
|
+
const nvdSeverity = nvd?.severity;
|
|
53
|
+
if (nvdSeverity?.severity) return { ...nvdSeverity, source: 'NVD' };
|
|
54
|
+
const databaseSeverity = vulnerability.database_specific?.severity;
|
|
55
|
+
if (databaseSeverity) {
|
|
56
|
+
const raw = String(databaseSeverity).toUpperCase();
|
|
57
|
+
const severity = ({ MODERATE: 'MEDIUM', IMPORTANT: 'HIGH', SEVERE: 'HIGH' })[raw] ?? raw;
|
|
58
|
+
return { severity, score: null, source: 'OSV' };
|
|
59
|
+
}
|
|
60
|
+
return { severity: 'UNKNOWN', score: null, source: null };
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
function guidance(versions) {
|
|
64
|
+
if (!versions.length) {
|
|
65
|
+
return 'Check the linked upstream advisory for a patched release. No fixed version was provided by the advisory data.';
|
|
66
|
+
}
|
|
67
|
+
return `Upgrade to ${versions.join(' or ')} (or a later compatible release), then rerun the scan. Review release notes for breaking changes.`;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
export function buildFindings(packages, osvByPackage, nvdByCve) {
|
|
71
|
+
return packages.map((item) => {
|
|
72
|
+
const vulnerabilities = osvByPackage.get(item.purl) ?? [];
|
|
73
|
+
const findings = vulnerabilities.map((vulnerability) => {
|
|
74
|
+
const cveId = [vulnerability.id, ...(vulnerability.aliases ?? [])]
|
|
75
|
+
.find((id) => /^CVE-\d{4}-\d+$/i.test(id ?? ''))?.toUpperCase() ?? null;
|
|
76
|
+
const nvd = cveId ? nvdData(nvdByCve.get(cveId)) : null;
|
|
77
|
+
const fixes = fixedVersions(vulnerability);
|
|
78
|
+
return {
|
|
79
|
+
id: vulnerability.id,
|
|
80
|
+
aliases: vulnerability.aliases ?? [],
|
|
81
|
+
cveId,
|
|
82
|
+
match: { type: 'package-version', confidence: 'high', source: 'OSV' },
|
|
83
|
+
summary: vulnerability.summary ?? nvd?.description ?? vulnerability.id,
|
|
84
|
+
details: vulnerability.details ?? null,
|
|
85
|
+
severity: severityOf(vulnerability, nvd),
|
|
86
|
+
affected: (vulnerability.affected ?? []).map((affected) => ({
|
|
87
|
+
ranges: (affected.ranges ?? []).map((range) => ({
|
|
88
|
+
type: range.type,
|
|
89
|
+
events: range.events ?? [],
|
|
90
|
+
})),
|
|
91
|
+
versions: affected.versions ?? [],
|
|
92
|
+
})),
|
|
93
|
+
fixedVersions: fixes,
|
|
94
|
+
remediation: guidance(fixes),
|
|
95
|
+
references: advisoryReferences(vulnerability, nvd),
|
|
96
|
+
sources: [...new Set(['OSV', ...(nvd ? ['NVD'] : [])])],
|
|
97
|
+
nvd,
|
|
98
|
+
};
|
|
99
|
+
});
|
|
100
|
+
return { ...item, vulnerabilities: findings };
|
|
101
|
+
});
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
export function summarize(packages) {
|
|
105
|
+
const findings = packages.flatMap((item) => item.vulnerabilities);
|
|
106
|
+
const severities = ['CRITICAL', 'HIGH', 'MEDIUM', 'LOW', 'UNKNOWN'];
|
|
107
|
+
const counts = Object.fromEntries(severities.map((severity) => [severity, 0]));
|
|
108
|
+
for (const finding of findings) {
|
|
109
|
+
const severity = String(finding.severity?.severity ?? 'UNKNOWN').toUpperCase();
|
|
110
|
+
counts[Object.hasOwn(counts, severity) ? severity : 'UNKNOWN'] += 1;
|
|
111
|
+
}
|
|
112
|
+
return {
|
|
113
|
+
dependencies: packages.length,
|
|
114
|
+
vulnerableDependencies: packages.filter((item) => item.vulnerabilities.length).length,
|
|
115
|
+
vulnerabilities: findings.length,
|
|
116
|
+
severityCounts: counts,
|
|
117
|
+
};
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
export const analyzeHelpers = { fixedVersions, highestNvdMetric };
|
package/src/cache.js
ADDED
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
import crypto from 'node:crypto';
|
|
2
|
+
import fs from 'node:fs/promises';
|
|
3
|
+
import os from 'node:os';
|
|
4
|
+
import path from 'node:path';
|
|
5
|
+
|
|
6
|
+
function cacheDirectory() {
|
|
7
|
+
if (process.env.LOOK_SEE_CACHE_DIR) return process.env.LOOK_SEE_CACHE_DIR;
|
|
8
|
+
if (process.env.XDG_CACHE_HOME) return path.join(process.env.XDG_CACHE_HOME, 'look-see');
|
|
9
|
+
if (process.platform === 'win32' && process.env.LOCALAPPDATA) {
|
|
10
|
+
return path.join(process.env.LOCALAPPDATA, 'look-see', 'cache');
|
|
11
|
+
}
|
|
12
|
+
return path.join(os.homedir(), '.cache', 'look-see');
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
function cachePath(key) {
|
|
16
|
+
const digest = crypto.createHash('sha256').update(key).digest('hex');
|
|
17
|
+
return path.join(cacheDirectory(), `${digest}.json`);
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export async function getCached(key, maxAgeMs) {
|
|
21
|
+
try {
|
|
22
|
+
const value = JSON.parse(await fs.readFile(cachePath(key), 'utf8'));
|
|
23
|
+
if (Date.now() - value.cachedAt > maxAgeMs) return null;
|
|
24
|
+
return value.data;
|
|
25
|
+
} catch {
|
|
26
|
+
return null;
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export async function setCached(key, data) {
|
|
31
|
+
try {
|
|
32
|
+
const file = cachePath(key);
|
|
33
|
+
await fs.mkdir(path.dirname(file), { recursive: true });
|
|
34
|
+
await fs.writeFile(file, JSON.stringify({ cachedAt: Date.now(), data }), 'utf8');
|
|
35
|
+
} catch {
|
|
36
|
+
// Cache failures should not interrupt a scan.
|
|
37
|
+
}
|
|
38
|
+
}
|
package/src/cli.js
ADDED
|
@@ -0,0 +1,215 @@
|
|
|
1
|
+
import fs from 'node:fs/promises';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
import { spawn } from 'node:child_process';
|
|
4
|
+
import { pathToFileURL } from 'node:url';
|
|
5
|
+
import { discoverDependencies } from './discovery/index.js';
|
|
6
|
+
import { queryOsv } from './advisories/osv.js';
|
|
7
|
+
import { nvdHelpers, queryNvd } from './advisories/nvd.js';
|
|
8
|
+
import { buildFindings, summarize } from './analyze.js';
|
|
9
|
+
import { writeReports } from './report.js';
|
|
10
|
+
import { createProgress } from './progress.js';
|
|
11
|
+
|
|
12
|
+
const HELP = `Look-See - scan locked dependencies for known vulnerabilities
|
|
13
|
+
|
|
14
|
+
Usage: look-see [project-directory] [options]
|
|
15
|
+
|
|
16
|
+
Options:
|
|
17
|
+
--out <directory> Report output directory (default: <project>/.look-see)
|
|
18
|
+
--format <formats> html,json (default: html,json)
|
|
19
|
+
--no-open Do not open the HTML report automatically
|
|
20
|
+
--no-nvd Skip NVD CVE enrichment
|
|
21
|
+
--fail-on <severity> Exit with code 1 for this severity or higher
|
|
22
|
+
-h, --help Show this help
|
|
23
|
+
-v, --version Show the version
|
|
24
|
+
|
|
25
|
+
Environment:
|
|
26
|
+
NVD_API_KEY Optional NVD API key for higher request limits
|
|
27
|
+
LOOK_SEE_CACHE_DIR Override the local advisory cache directory`;
|
|
28
|
+
|
|
29
|
+
const VERSION = '0.1.0';
|
|
30
|
+
const SEVERITY_RANK = { UNKNOWN: 0, LOW: 1, MEDIUM: 2, HIGH: 3, CRITICAL: 4 };
|
|
31
|
+
|
|
32
|
+
function parseArgs(args) {
|
|
33
|
+
const options = {
|
|
34
|
+
root: process.cwd(), out: null, formats: ['html', 'json'], open: true,
|
|
35
|
+
nvd: true, failOn: null, help: false, version: false,
|
|
36
|
+
};
|
|
37
|
+
let rootWasSet = false;
|
|
38
|
+
for (let index = 0; index < args.length; index += 1) {
|
|
39
|
+
const arg = args[index];
|
|
40
|
+
if (arg === '-h' || arg === '--help') options.help = true;
|
|
41
|
+
else if (arg === '-v' || arg === '--version') options.version = true;
|
|
42
|
+
else if (arg === '--no-open') options.open = false;
|
|
43
|
+
else if (arg === '--no-nvd') options.nvd = false;
|
|
44
|
+
else if (arg === '--out' || arg === '--format' || arg === '--fail-on') {
|
|
45
|
+
const value = args[++index];
|
|
46
|
+
if (!value || value.startsWith('--')) throw new Error(`${arg} requires a value`);
|
|
47
|
+
if (arg === '--out') options.out = value;
|
|
48
|
+
if (arg === '--format') {
|
|
49
|
+
options.formats = value.split(',').map((format) => format.trim().toLowerCase());
|
|
50
|
+
if (!options.formats.length || options.formats.some((format) => !['html', 'json'].includes(format))) {
|
|
51
|
+
throw new Error('--format must contain html, json, or both (comma-separated)');
|
|
52
|
+
}
|
|
53
|
+
options.formats = [...new Set(options.formats)];
|
|
54
|
+
}
|
|
55
|
+
if (arg === '--fail-on') {
|
|
56
|
+
const severity = value.toUpperCase();
|
|
57
|
+
if (!(severity in SEVERITY_RANK) || severity === 'UNKNOWN') {
|
|
58
|
+
throw new Error('--fail-on must be low, medium, high, or critical');
|
|
59
|
+
}
|
|
60
|
+
options.failOn = severity;
|
|
61
|
+
}
|
|
62
|
+
} else if (arg.startsWith('-')) {
|
|
63
|
+
throw new Error(`Unknown option: ${arg}`);
|
|
64
|
+
} else if (!rootWasSet) {
|
|
65
|
+
options.root = arg;
|
|
66
|
+
rootWasSet = true;
|
|
67
|
+
} else {
|
|
68
|
+
throw new Error(`Unexpected argument: ${arg}`);
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
return options;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
function openInBrowser(file) {
|
|
75
|
+
const absolute = path.resolve(file);
|
|
76
|
+
const target = process.platform === 'win32' ? absolute : pathToFileURL(absolute).href;
|
|
77
|
+
const command = process.platform === 'darwin' ? 'open'
|
|
78
|
+
: process.platform === 'win32' ? 'cmd'
|
|
79
|
+
: 'xdg-open';
|
|
80
|
+
const args = process.platform === 'win32' ? ['/c', 'start', '', target] : [target];
|
|
81
|
+
return new Promise((resolve) => {
|
|
82
|
+
const child = spawn(command, args, { detached: true, stdio: 'ignore', windowsHide: true });
|
|
83
|
+
child.once('error', () => resolve(false));
|
|
84
|
+
child.once('spawn', () => {
|
|
85
|
+
child.unref();
|
|
86
|
+
resolve(true);
|
|
87
|
+
});
|
|
88
|
+
});
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
function allOsvVulnerabilities(byPackage) {
|
|
92
|
+
return [...byPackage.values()].flat();
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
export async function run(args = process.argv.slice(2), io = console) {
|
|
96
|
+
let options;
|
|
97
|
+
try {
|
|
98
|
+
options = parseArgs(args);
|
|
99
|
+
} catch (error) {
|
|
100
|
+
io.error(`look-see: ${error.message}\nUse --help for usage.`);
|
|
101
|
+
return 2;
|
|
102
|
+
}
|
|
103
|
+
if (options.help) {
|
|
104
|
+
io.log(HELP);
|
|
105
|
+
return 0;
|
|
106
|
+
}
|
|
107
|
+
if (options.version) {
|
|
108
|
+
io.log(VERSION);
|
|
109
|
+
return 0;
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
const root = path.resolve(options.root);
|
|
113
|
+
let stats;
|
|
114
|
+
try {
|
|
115
|
+
stats = await fs.stat(root);
|
|
116
|
+
if (!stats.isDirectory()) throw new Error('Project path must be a directory');
|
|
117
|
+
} catch (error) {
|
|
118
|
+
io.error(`look-see: cannot read project directory ${root}: ${error.message}`);
|
|
119
|
+
return 2;
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
const outputDirectory = options.out
|
|
123
|
+
? path.resolve(options.out)
|
|
124
|
+
: path.join(root, '.look-see');
|
|
125
|
+
const progress = createProgress(io);
|
|
126
|
+
progress.banner();
|
|
127
|
+
progress.start(`Scanning ${root}`);
|
|
128
|
+
let discovery;
|
|
129
|
+
try {
|
|
130
|
+
discovery = await discoverDependencies(root);
|
|
131
|
+
progress.complete(`Discovered ${discovery.packages.length} locked dependency version(s) across ${discovery.lockfiles.length} file(s).`);
|
|
132
|
+
} catch (error) {
|
|
133
|
+
progress.warn(`Dependency discovery failed: ${error.message}`);
|
|
134
|
+
return 2;
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
progress.start(`Checking ${discovery.packages.length} dependency version(s) against OSV`);
|
|
138
|
+
const osv = await queryOsv(discovery.packages, {
|
|
139
|
+
onProgress: ({ stage, completed, total }) => {
|
|
140
|
+
if (stage === 'packages' && total > 0) progress.update(`OSV package checks: ${completed}/${total}`);
|
|
141
|
+
if (stage === 'advisories' && total > 0) progress.update(`Loading advisory details: ${completed}/${total}`);
|
|
142
|
+
},
|
|
143
|
+
});
|
|
144
|
+
const osvVulnerabilities = allOsvVulnerabilities(osv.byPackage);
|
|
145
|
+
progress.complete(`OSV checks complete: ${osvVulnerabilities.length} advisory finding(s).`);
|
|
146
|
+
|
|
147
|
+
const cveIds = nvdHelpers.cveIdsFrom(osvVulnerabilities);
|
|
148
|
+
let nvd = { byCve: new Map(), errors: [] };
|
|
149
|
+
if (!options.nvd) {
|
|
150
|
+
progress.skip('NVD enrichment disabled (--no-nvd).');
|
|
151
|
+
} else if (cveIds.length === 0) {
|
|
152
|
+
progress.skip('No CVE IDs found for NVD enrichment.');
|
|
153
|
+
} else {
|
|
154
|
+
progress.start(`Enriching ${cveIds.length} CVE(s) with NVD data`);
|
|
155
|
+
nvd = await queryNvd(osvVulnerabilities, {
|
|
156
|
+
onProgress: ({ completed, total }) => progress.update(`NVD CVE enrichment: ${completed}/${total}`),
|
|
157
|
+
});
|
|
158
|
+
const enriched = [...nvd.byCve.values()].filter(Boolean).length;
|
|
159
|
+
progress.complete(`NVD enrichment complete: ${enriched}/${cveIds.length} CVE record(s) retrieved.`);
|
|
160
|
+
}
|
|
161
|
+
const packages = buildFindings(discovery.packages, osv.byPackage, nvd.byCve);
|
|
162
|
+
const summary = summarize(packages);
|
|
163
|
+
const errors = [
|
|
164
|
+
...discovery.errors.map((error) => ({ source: 'Discovery', ...error })),
|
|
165
|
+
...osv.errors,
|
|
166
|
+
...nvd.errors,
|
|
167
|
+
];
|
|
168
|
+
if (!discovery.lockfiles.length) {
|
|
169
|
+
errors.push({ source: 'Discovery', message: 'No supported lockfiles were found; no dependencies were checked.' });
|
|
170
|
+
}
|
|
171
|
+
const report = {
|
|
172
|
+
schemaVersion: '1.0.0',
|
|
173
|
+
tool: { name: 'look-see', version: VERSION },
|
|
174
|
+
generatedAt: new Date().toISOString(),
|
|
175
|
+
project: root,
|
|
176
|
+
summary,
|
|
177
|
+
coverage: {
|
|
178
|
+
lockfiles: discovery.lockfiles,
|
|
179
|
+
packagesScanned: discovery.packages.length,
|
|
180
|
+
advisoryMatching: 'OSV package and version matching using PURLs',
|
|
181
|
+
nvdEnrichment: options.nvd ? 'NVD CVE enrichment for OSV advisories with CVE aliases' : 'disabled by --no-nvd',
|
|
182
|
+
dependencyResolution: 'npm lockfiles provide resolved dependency versions; Go scans go.mod requirements without resolving the full module graph or applying replace directives.',
|
|
183
|
+
errors,
|
|
184
|
+
},
|
|
185
|
+
packages,
|
|
186
|
+
};
|
|
187
|
+
|
|
188
|
+
progress.start('Writing HTML and JSON reports');
|
|
189
|
+
let written;
|
|
190
|
+
try {
|
|
191
|
+
written = await writeReports(report, outputDirectory, options.formats);
|
|
192
|
+
} catch (error) {
|
|
193
|
+
progress.warn(`Could not write reports: ${error.message}`);
|
|
194
|
+
return 2;
|
|
195
|
+
}
|
|
196
|
+
progress.complete('Reports written.');
|
|
197
|
+
for (const [format, file] of Object.entries(written)) progress.note(`${format.toUpperCase()} report: ${file}`);
|
|
198
|
+
progress.complete(`Scan complete: ${summary.vulnerabilities} finding(s) in ${summary.vulnerableDependencies} dependency version(s).`);
|
|
199
|
+
if (errors.length) progress.warn(`${errors.length} scan/data-source issue(s); review coverage.errors in the JSON report.`);
|
|
200
|
+
|
|
201
|
+
if (options.open && written.html) {
|
|
202
|
+
const opened = await openInBrowser(written.html);
|
|
203
|
+
if (!opened) progress.warn('Could not open a browser automatically. Open the HTML report path above.');
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
if (options.failOn) {
|
|
207
|
+
const threshold = SEVERITY_RANK[options.failOn];
|
|
208
|
+
if (packages.some((item) => item.vulnerabilities.some((finding) =>
|
|
209
|
+
SEVERITY_RANK[String(finding.severity?.severity ?? 'UNKNOWN').toUpperCase()] >= threshold))) return 1;
|
|
210
|
+
}
|
|
211
|
+
if (discovery.errors.length || osv.errors.length || !discovery.lockfiles.length) return 2;
|
|
212
|
+
return 0;
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
export const cliHelpers = { parseArgs, openInBrowser };
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
import fs from 'node:fs/promises';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
import { addPackage } from './helpers.js';
|
|
4
|
+
|
|
5
|
+
function cleanGoVersion(version) {
|
|
6
|
+
return version?.trim();
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
export async function scanGoModules(goModPaths) {
|
|
10
|
+
const packages = new Map();
|
|
11
|
+
const errors = [];
|
|
12
|
+
for (const file of goModPaths) {
|
|
13
|
+
try {
|
|
14
|
+
const content = await fs.readFile(file, 'utf8');
|
|
15
|
+
for (const item of parseGoMod(content, file)) {
|
|
16
|
+
addPackage(packages, item);
|
|
17
|
+
}
|
|
18
|
+
} catch (error) {
|
|
19
|
+
errors.push({ file, message: error.message });
|
|
20
|
+
}
|
|
21
|
+
}
|
|
22
|
+
return { packages: [...packages.values()], errors };
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export function parseGoMod(content, file = 'go.mod') {
|
|
26
|
+
const packages = new Map();
|
|
27
|
+
let inRequire = false;
|
|
28
|
+
for (const rawLine of content.split(/\r?\n/)) {
|
|
29
|
+
const line = rawLine.replace(/\/\/.*$/, '').trim();
|
|
30
|
+
if (!line) continue;
|
|
31
|
+
if (/^require\s+\($/.test(line)) {
|
|
32
|
+
inRequire = true;
|
|
33
|
+
continue;
|
|
34
|
+
}
|
|
35
|
+
if (inRequire && line === ')') {
|
|
36
|
+
inRequire = false;
|
|
37
|
+
continue;
|
|
38
|
+
}
|
|
39
|
+
if (line.startsWith('require ')) {
|
|
40
|
+
const fields = line.slice('require '.length).trim().split(/\s+/);
|
|
41
|
+
if (fields.length >= 2) {
|
|
42
|
+
addPackage(packages, {
|
|
43
|
+
ecosystem: 'Go', name: fields[0], version: cleanGoVersion(fields[1]),
|
|
44
|
+
direct: !rawLine.includes('// indirect'), file,
|
|
45
|
+
});
|
|
46
|
+
}
|
|
47
|
+
continue;
|
|
48
|
+
}
|
|
49
|
+
if (inRequire) {
|
|
50
|
+
const fields = line.split(/\s+/);
|
|
51
|
+
if (fields.length >= 2) {
|
|
52
|
+
addPackage(packages, {
|
|
53
|
+
ecosystem: 'Go', name: fields[0], version: cleanGoVersion(fields[1]),
|
|
54
|
+
direct: !rawLine.includes('// indirect'), file,
|
|
55
|
+
});
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
return [...packages.values()];
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
export const goParser = { cleanGoVersion };
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
import fs from 'node:fs/promises';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
|
|
4
|
+
export async function readJson(filePath) {
|
|
5
|
+
return JSON.parse(await fs.readFile(filePath, 'utf8'));
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
export function dependencyNames(manifest) {
|
|
9
|
+
return new Set([
|
|
10
|
+
...Object.keys(manifest.dependencies ?? {}),
|
|
11
|
+
...Object.keys(manifest.devDependencies ?? {}),
|
|
12
|
+
...Object.keys(manifest.optionalDependencies ?? {}),
|
|
13
|
+
...Object.keys(manifest.peerDependencies ?? {}),
|
|
14
|
+
]);
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export function packageNameFromNodeModulesPath(packagePath) {
|
|
18
|
+
const marker = 'node_modules/';
|
|
19
|
+
const index = packagePath.lastIndexOf(marker);
|
|
20
|
+
if (index < 0) return null;
|
|
21
|
+
const remainder = packagePath.slice(index + marker.length).split('/');
|
|
22
|
+
if (!remainder[0]) return null;
|
|
23
|
+
return remainder[0].startsWith('@')
|
|
24
|
+
? (remainder[1] ? `${remainder[0]}/${remainder[1]}` : null)
|
|
25
|
+
: remainder[0];
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export function addPackage(packages, { ecosystem, name, version, direct = false, file }) {
|
|
29
|
+
if (!name || !version || typeof name !== 'string' || typeof version !== 'string') return;
|
|
30
|
+
const key = `${ecosystem}\0${name}\0${version}`;
|
|
31
|
+
const current = packages.get(key) ?? {
|
|
32
|
+
ecosystem,
|
|
33
|
+
name,
|
|
34
|
+
version,
|
|
35
|
+
direct: false,
|
|
36
|
+
files: [],
|
|
37
|
+
};
|
|
38
|
+
current.direct ||= direct;
|
|
39
|
+
if (file && !current.files.includes(file)) current.files.push(file);
|
|
40
|
+
packages.set(key, current);
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
export async function findFiles(root, wantedNames) {
|
|
44
|
+
const found = [];
|
|
45
|
+
const ignored = new Set([
|
|
46
|
+
'.git', '.hg', '.svn', 'node_modules', 'vendor', 'dist', 'build',
|
|
47
|
+
'.next', '.nuxt', 'coverage', '.look-see',
|
|
48
|
+
]);
|
|
49
|
+
|
|
50
|
+
async function visit(directory) {
|
|
51
|
+
let entries;
|
|
52
|
+
try {
|
|
53
|
+
entries = await fs.readdir(directory, { withFileTypes: true });
|
|
54
|
+
} catch {
|
|
55
|
+
return;
|
|
56
|
+
}
|
|
57
|
+
for (const entry of entries) {
|
|
58
|
+
if (entry.isSymbolicLink()) continue;
|
|
59
|
+
const fullPath = path.join(directory, entry.name);
|
|
60
|
+
if (entry.isDirectory()) {
|
|
61
|
+
if (!ignored.has(entry.name)) await visit(fullPath);
|
|
62
|
+
} else if (entry.isFile() && wantedNames.has(entry.name)) {
|
|
63
|
+
found.push(fullPath);
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
await visit(root);
|
|
69
|
+
return found.sort();
|
|
70
|
+
}
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
import path from 'node:path';
|
|
2
|
+
import { findFiles } from './helpers.js';
|
|
3
|
+
import { scanNpmLocks } from './npm.js';
|
|
4
|
+
import { scanGoModules } from './go.js';
|
|
5
|
+
import { createPurl } from '../purl.js';
|
|
6
|
+
|
|
7
|
+
export async function discoverDependencies(root) {
|
|
8
|
+
const [npmFiles, goFiles] = await Promise.all([
|
|
9
|
+
findFiles(root, new Set(['package-lock.json', 'pnpm-lock.yaml', 'yarn.lock'])),
|
|
10
|
+
findFiles(root, new Set(['go.mod'])),
|
|
11
|
+
]);
|
|
12
|
+
const [npmResult, goResult] = await Promise.all([
|
|
13
|
+
scanNpmLocks(npmFiles), scanGoModules(goFiles),
|
|
14
|
+
]);
|
|
15
|
+
const packages = [...npmResult.packages, ...goResult.packages].map((item) => ({
|
|
16
|
+
...item,
|
|
17
|
+
files: item.files.map((file) => path.relative(root, file) || '.'),
|
|
18
|
+
purl: createPurl(item.ecosystem, item.name, item.version),
|
|
19
|
+
}));
|
|
20
|
+
return {
|
|
21
|
+
packages,
|
|
22
|
+
lockfiles: [...npmFiles, ...goFiles].map((file) => path.relative(root, file)),
|
|
23
|
+
errors: [...npmResult.errors, ...goResult.errors].map((error) => ({
|
|
24
|
+
...error, file: path.relative(root, error.file),
|
|
25
|
+
})),
|
|
26
|
+
};
|
|
27
|
+
}
|
|
@@ -0,0 +1,145 @@
|
|
|
1
|
+
import fs from 'node:fs/promises';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
import lockfile from '@yarnpkg/lockfile';
|
|
4
|
+
import YAML from 'yaml';
|
|
5
|
+
import { addPackage, dependencyNames, packageNameFromNodeModulesPath, readJson } from './helpers.js';
|
|
6
|
+
|
|
7
|
+
function walkNpmV1(dependencies, packages, file, directNames, isDirect = false) {
|
|
8
|
+
for (const [name, dependency] of Object.entries(dependencies ?? {})) {
|
|
9
|
+
if (dependency?.version) {
|
|
10
|
+
addPackage(packages, {
|
|
11
|
+
ecosystem: 'npm', name, version: dependency.version,
|
|
12
|
+
direct: isDirect && directNames.has(name), file,
|
|
13
|
+
});
|
|
14
|
+
}
|
|
15
|
+
walkNpmV1(dependency?.dependencies, packages, file, directNames, false);
|
|
16
|
+
}
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
function parseNpmPackageLock(lock, file) {
|
|
20
|
+
const packages = new Map();
|
|
21
|
+
const directNames = dependencyNames(lock.packages?.[''] ?? lock);
|
|
22
|
+
if (lock.packages && typeof lock.packages === 'object') {
|
|
23
|
+
for (const [packagePath, metadata] of Object.entries(lock.packages)) {
|
|
24
|
+
if (!packagePath || !metadata || metadata.link || !metadata.version) continue;
|
|
25
|
+
const name = metadata.name ?? packageNameFromNodeModulesPath(packagePath);
|
|
26
|
+
if (!name) continue;
|
|
27
|
+
addPackage(packages, {
|
|
28
|
+
ecosystem: 'npm', name, version: String(metadata.version),
|
|
29
|
+
direct: directNames.has(name) && !packagePath.slice(1).includes('node_modules/'),
|
|
30
|
+
file,
|
|
31
|
+
});
|
|
32
|
+
}
|
|
33
|
+
} else {
|
|
34
|
+
walkNpmV1(lock.dependencies, packages, file, directNames, true);
|
|
35
|
+
}
|
|
36
|
+
return [...packages.values()];
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
function nameFromYarnDescriptor(descriptor) {
|
|
40
|
+
const value = descriptor.trim().replace(/^['"]|['"]$/g, '');
|
|
41
|
+
const at = value.lastIndexOf('@');
|
|
42
|
+
if (at <= 0) return null;
|
|
43
|
+
return value.slice(0, at);
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
function parseYarnLock(content, file, directNames) {
|
|
47
|
+
const packages = new Map();
|
|
48
|
+
if (content.startsWith('__metadata:')) {
|
|
49
|
+
const parsed = YAML.parse(content);
|
|
50
|
+
for (const [descriptor, metadata] of Object.entries(parsed ?? {})) {
|
|
51
|
+
if (descriptor === '__metadata' || !metadata?.version) continue;
|
|
52
|
+
const name = nameFromYarnDescriptor(metadata.resolution ?? descriptor.split(',')[0]);
|
|
53
|
+
if (!name) continue;
|
|
54
|
+
addPackage(packages, {
|
|
55
|
+
ecosystem: 'npm', name, version: String(metadata.version),
|
|
56
|
+
direct: directNames.has(name), file,
|
|
57
|
+
});
|
|
58
|
+
}
|
|
59
|
+
} else {
|
|
60
|
+
const parsed = lockfile.parse(content);
|
|
61
|
+
if (parsed.type !== 'success') throw new Error(`Could not parse Yarn lockfile: ${parsed.type}`);
|
|
62
|
+
for (const [descriptor, metadata] of Object.entries(parsed.object)) {
|
|
63
|
+
if (!metadata?.version) continue;
|
|
64
|
+
const name = nameFromYarnDescriptor(descriptor.split(',')[0]);
|
|
65
|
+
if (!name) continue;
|
|
66
|
+
addPackage(packages, {
|
|
67
|
+
ecosystem: 'npm', name, version: String(metadata.version),
|
|
68
|
+
direct: directNames.has(name), file,
|
|
69
|
+
});
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
return [...packages.values()];
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
function parsePnpmPackageKey(key, metadata) {
|
|
76
|
+
if (metadata?.name && metadata?.version) return { name: metadata.name, version: String(metadata.version) };
|
|
77
|
+
let value = key.replace(/^\//, '');
|
|
78
|
+
value = value.replace(/\([^)]*\)$/g, '');
|
|
79
|
+
const at = value.lastIndexOf('@');
|
|
80
|
+
if (at > value.indexOf('/')) {
|
|
81
|
+
const name = value.slice(0, at);
|
|
82
|
+
const version = value.slice(at + 1).split('_')[0];
|
|
83
|
+
if (name && version) return { name, version };
|
|
84
|
+
}
|
|
85
|
+
const slash = value.lastIndexOf('/');
|
|
86
|
+
if (slash > 0) {
|
|
87
|
+
const name = value.slice(0, slash);
|
|
88
|
+
const version = value.slice(slash + 1).split('_')[0];
|
|
89
|
+
if (name && version && /^\d|^v\d/.test(version)) return { name, version };
|
|
90
|
+
}
|
|
91
|
+
return null;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
function parsePnpmLock(content, file, directNames) {
|
|
95
|
+
const parsed = YAML.parse(content) ?? {};
|
|
96
|
+
const packages = new Map();
|
|
97
|
+
for (const [key, metadata] of Object.entries(parsed.packages ?? {})) {
|
|
98
|
+
const item = parsePnpmPackageKey(key, metadata);
|
|
99
|
+
if (!item) continue;
|
|
100
|
+
addPackage(packages, {
|
|
101
|
+
ecosystem: 'npm', ...item, direct: directNames.has(item.name), file,
|
|
102
|
+
});
|
|
103
|
+
}
|
|
104
|
+
return [...packages.values()];
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
async function readDirectNames(lockPath) {
|
|
108
|
+
const manifestPath = path.join(path.dirname(lockPath), 'package.json');
|
|
109
|
+
try {
|
|
110
|
+
return dependencyNames(await readJson(manifestPath));
|
|
111
|
+
} catch {
|
|
112
|
+
return new Set();
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
export async function scanNpmLocks(lockPaths) {
|
|
117
|
+
const combined = new Map();
|
|
118
|
+
const errors = [];
|
|
119
|
+
for (const file of lockPaths) {
|
|
120
|
+
try {
|
|
121
|
+
let found;
|
|
122
|
+
if (path.basename(file) === 'package-lock.json') {
|
|
123
|
+
found = parseNpmPackageLock(await readJson(file), file);
|
|
124
|
+
} else {
|
|
125
|
+
const content = await fs.readFile(file, 'utf8');
|
|
126
|
+
const directNames = await readDirectNames(file);
|
|
127
|
+
found = path.basename(file) === 'pnpm-lock.yaml'
|
|
128
|
+
? parsePnpmLock(content, file, directNames)
|
|
129
|
+
: parseYarnLock(content, file, directNames);
|
|
130
|
+
}
|
|
131
|
+
for (const item of found) {
|
|
132
|
+
const key = `${item.ecosystem}\0${item.name}\0${item.version}`;
|
|
133
|
+
const current = combined.get(key) ?? { ...item, files: [] };
|
|
134
|
+
current.direct ||= item.direct;
|
|
135
|
+
if (!current.files.includes(file)) current.files.push(file);
|
|
136
|
+
combined.set(key, current);
|
|
137
|
+
}
|
|
138
|
+
} catch (error) {
|
|
139
|
+
errors.push({ file, message: error.message });
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
return { packages: [...combined.values()], errors };
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
export const npmParsers = { parseNpmPackageLock, parsePnpmLock, parseYarnLock, parsePnpmPackageKey };
|
package/src/progress.js
ADDED
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
import chalk from 'chalk';
|
|
2
|
+
|
|
3
|
+
const FRAMES = ['⠋', '⠙', '⠹', '⠸', '⠼', '⠴', '⠦', '⠧', '⠇', '⠏'];
|
|
4
|
+
const BANNER = String.raw`
|
|
5
|
+
___ ______ ______ __ ___ ________ _______ _______
|
|
6
|
+
|" | / " \ / " \ |/"| / ") /" )/" "| /" "|
|
|
7
|
+
|| | // ____ \ // ____ \ (: |/ / (: \___/(: ______)(: ______)
|
|
8
|
+
|: | / / ) :)/ / ) :)| __/ \___ \ \/ | \/ |
|
|
9
|
+
\ |___(: (____/ //(: (____/ // (// _ \ __/ \\ // ___)_ // ___)_
|
|
10
|
+
( \_|: \\ / \ / |: | \ \ /" \ :)(: "|(: "|
|
|
11
|
+
\_______)"\_____/ "\_____/ (__| \__) (_______/ \_______) \_______)
|
|
12
|
+
`.trim();
|
|
13
|
+
|
|
14
|
+
export function createProgress(io = console, { interactive = Boolean(process.stdout.isTTY) } = {}) {
|
|
15
|
+
let timer = null;
|
|
16
|
+
let frame = 0;
|
|
17
|
+
let label = '';
|
|
18
|
+
|
|
19
|
+
function clearLine() {
|
|
20
|
+
process.stdout.write('\r\u001b[2K');
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
function stopSpinner() {
|
|
24
|
+
if (timer) clearInterval(timer);
|
|
25
|
+
timer = null;
|
|
26
|
+
if (interactive) clearLine();
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
function render() {
|
|
30
|
+
if (!interactive || !label) return;
|
|
31
|
+
const icon = FRAMES[frame++ % FRAMES.length];
|
|
32
|
+
process.stdout.write(`\r\u001b[2K${chalk.cyan(icon)} ${label}`);
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
function start(message) {
|
|
36
|
+
stopSpinner();
|
|
37
|
+
label = message;
|
|
38
|
+
if (interactive) {
|
|
39
|
+
render();
|
|
40
|
+
timer = setInterval(render, 90);
|
|
41
|
+
timer.unref?.();
|
|
42
|
+
} else {
|
|
43
|
+
io.log(chalk.cyan(`› ${message}`));
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
function banner() {
|
|
48
|
+
io.log(chalk.bold.cyan(BANNER));
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
function update(message) {
|
|
52
|
+
label = message;
|
|
53
|
+
if (interactive) render();
|
|
54
|
+
else io.log(chalk.dim(` ${message}`));
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
function complete(message) {
|
|
58
|
+
stopSpinner();
|
|
59
|
+
label = '';
|
|
60
|
+
io.log(chalk.green(`✓ ${message}`));
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
function warn(message) {
|
|
64
|
+
stopSpinner();
|
|
65
|
+
label = '';
|
|
66
|
+
io.warn?.(chalk.yellow(`! ${message}`));
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
function skip(message) {
|
|
70
|
+
stopSpinner();
|
|
71
|
+
label = '';
|
|
72
|
+
io.log(chalk.dim(`– ${message}`));
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
function note(message) {
|
|
76
|
+
io.log(message);
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
return { banner, start, update, complete, warn, skip, note, stop: stopSpinner };
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
export const progressHelpers = { BANNER };
|
package/src/purl.js
ADDED
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
function encodePathSegment(segment) {
|
|
2
|
+
return encodeURIComponent(segment).replaceAll('%3A', ':');
|
|
3
|
+
}
|
|
4
|
+
|
|
5
|
+
function escapeGoPath(path) {
|
|
6
|
+
return path
|
|
7
|
+
.split('/')
|
|
8
|
+
.map((part) => [...part].map((char) => {
|
|
9
|
+
if (char >= 'A' && char <= 'Z') return `!${char.toLowerCase()}`;
|
|
10
|
+
return char;
|
|
11
|
+
}).join(''))
|
|
12
|
+
.join('/');
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export function packagePurlName(ecosystem, name) {
|
|
16
|
+
const type = ecosystem === 'Go' ? 'golang' : ecosystem.toLowerCase();
|
|
17
|
+
const normalizedName = ecosystem === 'Go' ? escapeGoPath(name) : name;
|
|
18
|
+
const encodedName = normalizedName.split('/').map(encodePathSegment).join('/');
|
|
19
|
+
return `pkg:${type}/${encodedName}`;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export function createPurl(ecosystem, name, version) {
|
|
23
|
+
return `${packagePurlName(ecosystem, name)}@${encodeURIComponent(version)}`;
|
|
24
|
+
}
|
package/src/report.js
ADDED
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
import fs from 'node:fs/promises';
|
|
2
|
+
|
|
3
|
+
function escapeHtml(value) {
|
|
4
|
+
return String(value ?? '').replace(/[&<>"']/g, (character) => ({
|
|
5
|
+
'&': '&', '<': '<', '>': '>', '"': '"', "'": ''',
|
|
6
|
+
})[character]);
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
function safeLink(url) {
|
|
10
|
+
return /^https?:\/\//i.test(url ?? '') ? url : null;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
function formatGeneratedAt(value) {
|
|
14
|
+
const date = new Date(value);
|
|
15
|
+
if (Number.isNaN(date.getTime())) return value;
|
|
16
|
+
return date.toLocaleString(undefined, { dateStyle: 'medium', timeStyle: 'short' });
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
function findingMarkup(item, finding) {
|
|
20
|
+
const severity = String(finding.severity?.severity ?? 'UNKNOWN').toUpperCase();
|
|
21
|
+
const score = finding.severity?.score ?? '';
|
|
22
|
+
const refs = finding.references.map((url) => safeLink(url)).filter(Boolean);
|
|
23
|
+
const aliases = [...new Set([finding.id, ...finding.aliases].filter(Boolean))];
|
|
24
|
+
const ranges = finding.affected.flatMap((affected) => affected.ranges.map((range) => {
|
|
25
|
+
const events = range.events.map((event) => {
|
|
26
|
+
const [kind, version] = Object.entries(event)[0] ?? [];
|
|
27
|
+
return kind ? `${kind}: ${version}` : null;
|
|
28
|
+
}).filter(Boolean);
|
|
29
|
+
return `${range.type ?? 'version'} (${events.join(', ')})`;
|
|
30
|
+
}));
|
|
31
|
+
return `<article class="finding" data-severity="${escapeHtml(severity)}" data-package="${escapeHtml(item.name)}" data-score="${escapeHtml(score)}">
|
|
32
|
+
<div class="finding-heading"><span class="badge ${escapeHtml(severity.toLowerCase())}">${escapeHtml(severity)}</span><strong>${escapeHtml(item.name)}@${escapeHtml(item.version)}</strong><span class="ids">${escapeHtml(aliases.join(' · '))}</span></div>
|
|
33
|
+
<p>${escapeHtml(finding.summary)}</p>
|
|
34
|
+
<p class="meta">${escapeHtml(item.purl)} · ${escapeHtml(item.direct ? 'Direct' : 'Transitive / indirect')} · Sources: ${escapeHtml(finding.sources.join(', '))}</p>
|
|
35
|
+
${ranges.length ? `<p><strong>Affected range:</strong> ${escapeHtml(ranges.join('; '))}</p>` : ''}
|
|
36
|
+
<p><strong>Fix guidance:</strong> ${escapeHtml(finding.remediation)}</p>
|
|
37
|
+
${finding.details ? `<details><summary>Advisory details</summary><p>${escapeHtml(finding.details)}</p></details>` : ''}
|
|
38
|
+
${refs.length ? `<p class="refs">${refs.map((url) => `<a href="${escapeHtml(url)}" target="_blank" rel="noopener noreferrer">Reference</a>`).join(' ')}</p>` : ''}
|
|
39
|
+
</article>`;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export function renderHtml(report) {
|
|
43
|
+
const { summary, packages, coverage } = report;
|
|
44
|
+
const rows = packages.flatMap((item) => item.vulnerabilities.map((finding) => findingMarkup(item, finding)));
|
|
45
|
+
const body = rows.length
|
|
46
|
+
? rows.join('\n')
|
|
47
|
+
: '<p class="empty">No vulnerabilities were returned for the dependencies scanned. Check scan coverage below before treating this as a clean bill of health.</p>';
|
|
48
|
+
const severityRows = Object.entries(summary.severityCounts)
|
|
49
|
+
.map(([severity, count]) => `<li><span class="badge ${escapeHtml(severity.toLowerCase())}">${escapeHtml(severity)}</span> ${count}</li>`)
|
|
50
|
+
.join('');
|
|
51
|
+
const coverageErrors = coverage.errors.length
|
|
52
|
+
? `<ul>${coverage.errors.map((error) => `<li><strong>${escapeHtml(error.source ?? 'Discovery')}:</strong> ${escapeHtml(error.file ? `${error.file}: ` : '')}${escapeHtml(error.message)}</li>`).join('')}</ul>`
|
|
53
|
+
: '<p>No scan or data-source errors were reported.</p>';
|
|
54
|
+
const lockfiles = coverage.lockfiles.length
|
|
55
|
+
? `<ul>${coverage.lockfiles.map((file) => `<li><code>${escapeHtml(file)}</code></li>`).join('')}</ul>`
|
|
56
|
+
: '<p>No supported lockfiles or module files were found.</p>';
|
|
57
|
+
|
|
58
|
+
return `<!doctype html>
|
|
59
|
+
<html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1">
|
|
60
|
+
<title>Look-See dependency vulnerability report</title>
|
|
61
|
+
<style>
|
|
62
|
+
:root{color-scheme:light dark;font:15px/1.55 system-ui,sans-serif;--bg:#fff;--fg:#202124;--muted:#5f6368;--card:#f6f8fa;--border:#d0d7de;--link:#0969da}
|
|
63
|
+
@media(prefers-color-scheme:dark){:root{--bg:#0d1117;--fg:#e6edf3;--muted:#9198a1;--card:#161b22;--border:#30363d;--link:#58a6ff}}
|
|
64
|
+
*{box-sizing:border-box}body{margin:0;background:var(--bg);color:var(--fg)}main{max-width:1100px;margin:auto;padding:32px 20px}h1{margin-bottom:4px}.muted,.meta{color:var(--muted)}.summary{display:flex;gap:12px;flex-wrap:wrap;margin:24px 0}.stat,details,.finding{background:var(--card);border:1px solid var(--border);border-radius:8px}.stat{padding:12px 16px;min-width:140px}.stat strong{display:block;font-size:1.35rem}.finding{padding:16px;margin:12px 0}.finding-heading{display:flex;align-items:center;gap:10px;flex-wrap:wrap}.ids{color:var(--muted);font-size:.9rem}.badge{border-radius:4px;padding:2px 7px;font-size:.75rem;font-weight:700;background:#8b949e;color:#fff}.critical,.high{background:#cf222e}.medium{background:#bf8700}.low{background:#0969da}.unknown{background:#6e7781}.meta{font-size:.85rem;overflow-wrap:anywhere}.refs a{margin-right:12px}a{color:var(--link)}details{padding:12px;margin:10px 0}summary{cursor:pointer}.empty{padding:20px;background:var(--card);border-radius:8px}code{overflow-wrap:anywhere}section{margin-top:32px}.controls{display:flex;align-items:end;flex-wrap:wrap;gap:18px;margin:16px 0 20px}.controls label{display:flex;flex-direction:column;gap:6px;font-size:1.05rem;font-weight:500}.controls input,.controls select{min-height:40px;padding:8px 11px;font:inherit;font-size:1.05rem;border:1px solid var(--border);border-radius:6px;background:var(--bg);color:var(--fg)}.controls input[type=search]{min-width:260px}
|
|
65
|
+
</style></head><body><main>
|
|
66
|
+
<h1>Look-See dependency vulnerability report</h1>
|
|
67
|
+
<p class="muted">Project: <code>${escapeHtml(report.project)}</code> · Generated: ${escapeHtml(formatGeneratedAt(report.generatedAt))}</p>
|
|
68
|
+
<div class="summary"><div class="stat"><strong>${summary.dependencies}</strong>dependencies scanned</div><div class="stat"><strong>${summary.vulnerableDependencies}</strong>with findings</div><div class="stat"><strong>${summary.vulnerabilities}</strong>vulnerabilities</div></div>
|
|
69
|
+
<h2>Severity</h2><ul>${severityRows}</ul>
|
|
70
|
+
<section><h2>Findings</h2><div class="controls"><label>Filter <input id="filter" type="search" placeholder="Package, CVE, or text"></label><label>Severity <select id="severity-filter"><option value="">All severities</option><option>CRITICAL</option><option>HIGH</option><option>MEDIUM</option><option>LOW</option><option>UNKNOWN</option></select></label><label>Sort <select id="sort"><option value="severity">Severity</option><option value="package">Package</option><option value="score">CVSS score</option></select></label></div><div id="findings">${body}</div></section>
|
|
71
|
+
<section><h2>Scan coverage</h2><p>Advisory matching uses OSV package/version records. NVD records enrich matching CVEs where available. This report does not imply coverage of ecosystems without a supported lockfile parser.</p><p>Dependency resolution: ${escapeHtml(coverage.dependencyResolution)}</p><h3>Files scanned</h3>${lockfiles}<h3>Errors and limitations</h3>${coverageErrors}</section>
|
|
72
|
+
<footer class="muted"><p>Generated by Look-See. Verify fixes against upstream advisories and rerun the scan.</p></footer>
|
|
73
|
+
</main><script>
|
|
74
|
+
(() => {
|
|
75
|
+
const list = document.getElementById('findings');
|
|
76
|
+
const search = document.getElementById('filter');
|
|
77
|
+
const severity = document.getElementById('severity-filter');
|
|
78
|
+
const sort = document.getElementById('sort');
|
|
79
|
+
const rank = { CRITICAL: 4, HIGH: 3, MEDIUM: 2, LOW: 1, UNKNOWN: 0 };
|
|
80
|
+
const update = () => {
|
|
81
|
+
const query = search.value.toLowerCase();
|
|
82
|
+
const cards = [...list.querySelectorAll('.finding')];
|
|
83
|
+
for (const card of cards) {
|
|
84
|
+
card.hidden = (query && !card.textContent.toLowerCase().includes(query)) || (severity.value && card.dataset.severity !== severity.value);
|
|
85
|
+
}
|
|
86
|
+
cards.sort((a, b) => {
|
|
87
|
+
if (sort.value === 'package') return a.dataset.package.localeCompare(b.dataset.package);
|
|
88
|
+
if (sort.value === 'score') return (Number(b.dataset.score) || -1) - (Number(a.dataset.score) || -1);
|
|
89
|
+
return (rank[b.dataset.severity] ?? 0) - (rank[a.dataset.severity] ?? 0);
|
|
90
|
+
}).forEach((card) => list.append(card));
|
|
91
|
+
};
|
|
92
|
+
search.addEventListener('input', update);
|
|
93
|
+
severity.addEventListener('change', update);
|
|
94
|
+
sort.addEventListener('change', update);
|
|
95
|
+
update();
|
|
96
|
+
})();
|
|
97
|
+
</script></body></html>`;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
export async function writeReports(report, outputDirectory, formats) {
|
|
101
|
+
await fs.mkdir(outputDirectory, { recursive: true });
|
|
102
|
+
const written = {};
|
|
103
|
+
if (formats.includes('json')) {
|
|
104
|
+
const file = `${outputDirectory}/look-see-output.json`;
|
|
105
|
+
await fs.writeFile(file, `${JSON.stringify(report, null, 2)}\n`, 'utf8');
|
|
106
|
+
written.json = file;
|
|
107
|
+
}
|
|
108
|
+
if (formats.includes('html')) {
|
|
109
|
+
const file = `${outputDirectory}/look-see-output.html`;
|
|
110
|
+
await fs.writeFile(file, renderHtml(report), 'utf8');
|
|
111
|
+
written.html = file;
|
|
112
|
+
}
|
|
113
|
+
return written;
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
export const reportHelpers = { escapeHtml, safeLink, formatGeneratedAt };
|