@quantakrypto/core 0.1.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 +337 -0
- package/dist/baseline.d.ts +37 -0
- package/dist/baseline.d.ts.map +1 -0
- package/dist/baseline.js +99 -0
- package/dist/baseline.js.map +1 -0
- package/dist/cbom.d.ts +25 -0
- package/dist/cbom.d.ts.map +1 -0
- package/dist/cbom.js +131 -0
- package/dist/cbom.js.map +1 -0
- package/dist/changed.d.ts +13 -0
- package/dist/changed.d.ts.map +1 -0
- package/dist/changed.js +67 -0
- package/dist/changed.js.map +1 -0
- package/dist/config.d.ts +55 -0
- package/dist/config.d.ts.map +1 -0
- package/dist/config.js +232 -0
- package/dist/config.js.map +1 -0
- package/dist/cwe.d.ts +16 -0
- package/dist/cwe.d.ts.map +1 -0
- package/dist/cwe.js +16 -0
- package/dist/cwe.js.map +1 -0
- package/dist/dependencies.d.ts +25 -0
- package/dist/dependencies.d.ts.map +1 -0
- package/dist/dependencies.js +276 -0
- package/dist/dependencies.js.map +1 -0
- package/dist/detect-utils.d.ts +63 -0
- package/dist/detect-utils.d.ts.map +1 -0
- package/dist/detect-utils.js +113 -0
- package/dist/detect-utils.js.map +1 -0
- package/dist/detectors/pem.d.ts +9 -0
- package/dist/detectors/pem.d.ts.map +1 -0
- package/dist/detectors/pem.js +137 -0
- package/dist/detectors/pem.js.map +1 -0
- package/dist/detectors/source.d.ts +25 -0
- package/dist/detectors/source.d.ts.map +1 -0
- package/dist/detectors/source.js +575 -0
- package/dist/detectors/source.js.map +1 -0
- package/dist/index.d.ts +31 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +39 -0
- package/dist/index.js.map +1 -0
- package/dist/inventory.d.ts +15 -0
- package/dist/inventory.d.ts.map +1 -0
- package/dist/inventory.js +74 -0
- package/dist/inventory.js.map +1 -0
- package/dist/parallel.d.ts +34 -0
- package/dist/parallel.d.ts.map +1 -0
- package/dist/parallel.js +237 -0
- package/dist/parallel.js.map +1 -0
- package/dist/registry.d.ts +42 -0
- package/dist/registry.d.ts.map +1 -0
- package/dist/registry.js +51 -0
- package/dist/registry.js.map +1 -0
- package/dist/remediation.d.ts +42 -0
- package/dist/remediation.d.ts.map +1 -0
- package/dist/remediation.js +131 -0
- package/dist/remediation.js.map +1 -0
- package/dist/report.d.ts +24 -0
- package/dist/report.d.ts.map +1 -0
- package/dist/report.js +286 -0
- package/dist/report.js.map +1 -0
- package/dist/scan-worker.d.ts +2 -0
- package/dist/scan-worker.d.ts.map +1 -0
- package/dist/scan-worker.js +63 -0
- package/dist/scan-worker.js.map +1 -0
- package/dist/scan.d.ts +27 -0
- package/dist/scan.d.ts.map +1 -0
- package/dist/scan.js +168 -0
- package/dist/scan.js.map +1 -0
- package/dist/types.d.ts +190 -0
- package/dist/types.d.ts.map +1 -0
- package/dist/types.js +9 -0
- package/dist/types.js.map +1 -0
- package/dist/version.d.ts +7 -0
- package/dist/version.d.ts.map +1 -0
- package/dist/version.js +7 -0
- package/dist/version.js.map +1 -0
- package/dist/walk.d.ts +37 -0
- package/dist/walk.d.ts.map +1 -0
- package/dist/walk.js +260 -0
- package/dist/walk.js.map +1 -0
- package/package.json +40 -0
- package/src/baseline.ts +111 -0
- package/src/cbom.ts +166 -0
- package/src/changed.ts +76 -0
- package/src/config.ts +295 -0
- package/src/cwe.ts +20 -0
- package/src/dependencies.ts +299 -0
- package/src/detect-utils.ts +157 -0
- package/src/detectors/pem.ts +162 -0
- package/src/detectors/source.ts +733 -0
- package/src/index.ts +79 -0
- package/src/inventory.ts +94 -0
- package/src/parallel.ts +288 -0
- package/src/registry.ts +71 -0
- package/src/remediation.ts +173 -0
- package/src/report.ts +340 -0
- package/src/scan-worker.ts +80 -0
- package/src/scan.ts +187 -0
- package/src/types.ts +224 -0
- package/src/version.ts +6 -0
- package/src/walk.ts +289 -0
package/src/config.ts
ADDED
|
@@ -0,0 +1,295 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `quantakrypto.config.json` loader (ROADMAP P2-9, see docs/CONFIG.md).
|
|
3
|
+
*
|
|
4
|
+
* Reads an optional project configuration file, validates the *types* of known
|
|
5
|
+
* keys, ignores unknown keys (forward compatibility), and maps it onto a
|
|
6
|
+
* `Partial<ScanOptions>` plus the two policy keys qScan consumes
|
|
7
|
+
* (`severityThreshold`, `baseline`). It performs NO scanning and has no
|
|
8
|
+
* side-effects beyond a single `readFile`.
|
|
9
|
+
*
|
|
10
|
+
* Precedence (resolved by the consumer, not here): flags > config > defaults.
|
|
11
|
+
*
|
|
12
|
+
* The parsed object is treated as **untrusted input** in the same spirit as a
|
|
13
|
+
* scanned manifest (THREAT-MODEL Q-09): we only membership-test known keys and
|
|
14
|
+
* never deep-merge the parsed object into anything.
|
|
15
|
+
*/
|
|
16
|
+
import { readFile } from "node:fs/promises";
|
|
17
|
+
import * as path from "node:path";
|
|
18
|
+
|
|
19
|
+
import type { ScanOptions, Severity } from "./types.js";
|
|
20
|
+
|
|
21
|
+
/** Canonical config file name discovered at a scan root. */
|
|
22
|
+
export const CONFIG_FILENAME = "quantakrypto.config.json";
|
|
23
|
+
|
|
24
|
+
/** Severity levels accepted by `severityThreshold`, for validation. */
|
|
25
|
+
const SEVERITIES: readonly Severity[] = ["critical", "high", "medium", "low", "info"];
|
|
26
|
+
|
|
27
|
+
/** Detector-family toggle names recognised in the `detectors` object. */
|
|
28
|
+
const DETECTOR_FAMILIES = [
|
|
29
|
+
"node-crypto",
|
|
30
|
+
"webcrypto",
|
|
31
|
+
"crypto-libs",
|
|
32
|
+
"jwt-jose",
|
|
33
|
+
"tls-config",
|
|
34
|
+
"pem-material",
|
|
35
|
+
"dependencies",
|
|
36
|
+
] as const;
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* The slice of options a `quantakrypto.config.json` can set. A subset of
|
|
40
|
+
* {@link ScanOptions} (the file-selection + scope keys) extended with the two
|
|
41
|
+
* policy keys qScan owns: `severityThreshold` and `baseline`.
|
|
42
|
+
*
|
|
43
|
+
* `root`, `files`, `detectors` (the programmatic detector array), and `onFile`
|
|
44
|
+
* are intentionally NOT configurable from a file — they are call-site concerns.
|
|
45
|
+
*/
|
|
46
|
+
export interface QuantakryptoFileConfig extends Partial<
|
|
47
|
+
Pick<
|
|
48
|
+
ScanOptions,
|
|
49
|
+
| "include"
|
|
50
|
+
| "exclude"
|
|
51
|
+
| "noDefaultIgnores"
|
|
52
|
+
| "maxFileSize"
|
|
53
|
+
| "scanMinified"
|
|
54
|
+
| "source"
|
|
55
|
+
| "dependencies"
|
|
56
|
+
| "config"
|
|
57
|
+
>
|
|
58
|
+
> {
|
|
59
|
+
/** Gate severity (drives the exit code). Maps to qScan's `severityThreshold`. */
|
|
60
|
+
severityThreshold?: Severity;
|
|
61
|
+
/** Path to a baseline file, relative to the config file's directory. */
|
|
62
|
+
baseline?: string;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/** Result of {@link loadConfig}: the resolved config plus where it came from. */
|
|
66
|
+
export interface LoadConfigResult {
|
|
67
|
+
/** Validated, mapped config. Empty object when no file was found. */
|
|
68
|
+
config: QuantakryptoFileConfig;
|
|
69
|
+
/** Absolute path of the file that was loaded, when one was. */
|
|
70
|
+
path?: string;
|
|
71
|
+
/**
|
|
72
|
+
* Non-fatal warnings (unknown keys, unknown future `version`). The caller may
|
|
73
|
+
* surface these; they never abort the load.
|
|
74
|
+
*/
|
|
75
|
+
warnings: string[];
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/** Thrown when a known key has a malformed *value* (a usage error: exit 2). */
|
|
79
|
+
export class ConfigError extends Error {
|
|
80
|
+
override readonly name = "ConfigError";
|
|
81
|
+
/** The config file path the error relates to, when known. */
|
|
82
|
+
readonly path: string | undefined;
|
|
83
|
+
constructor(message: string, configPath?: string) {
|
|
84
|
+
super(message);
|
|
85
|
+
this.path = configPath;
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
function isObject(v: unknown): v is Record<string, unknown> {
|
|
90
|
+
return typeof v === "object" && v !== null && !Array.isArray(v);
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/** Validate an optional boolean field; returns it or undefined, or throws. */
|
|
94
|
+
function asBool(obj: Record<string, unknown>, key: string, file: string): boolean | undefined {
|
|
95
|
+
if (!(key in obj)) return undefined;
|
|
96
|
+
const v = obj[key];
|
|
97
|
+
if (typeof v !== "boolean") {
|
|
98
|
+
throw new ConfigError(`"${key}" must be a boolean (got ${typeof v})`, file);
|
|
99
|
+
}
|
|
100
|
+
return v;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/** Validate an optional non-negative-integer field. */
|
|
104
|
+
function asInt(obj: Record<string, unknown>, key: string, file: string): number | undefined {
|
|
105
|
+
if (!(key in obj)) return undefined;
|
|
106
|
+
const v = obj[key];
|
|
107
|
+
if (typeof v !== "number" || !Number.isInteger(v) || v < 0) {
|
|
108
|
+
throw new ConfigError(`"${key}" must be a non-negative integer`, file);
|
|
109
|
+
}
|
|
110
|
+
return v;
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
/** Validate an optional string[] field (every element must be a string). */
|
|
114
|
+
function asStringArray(
|
|
115
|
+
obj: Record<string, unknown>,
|
|
116
|
+
key: string,
|
|
117
|
+
file: string,
|
|
118
|
+
): string[] | undefined {
|
|
119
|
+
if (!(key in obj)) return undefined;
|
|
120
|
+
const v = obj[key];
|
|
121
|
+
if (!Array.isArray(v) || !v.every((x): x is string => typeof x === "string")) {
|
|
122
|
+
throw new ConfigError(`"${key}" must be an array of strings`, file);
|
|
123
|
+
}
|
|
124
|
+
return [...v];
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
/**
|
|
128
|
+
* Map the validated raw JSON object onto a {@link QuantakryptoFileConfig}. Throws
|
|
129
|
+
* {@link ConfigError} on a malformed *value* for any known key; collects
|
|
130
|
+
* warnings for unknown keys and unrecognised `version`.
|
|
131
|
+
*/
|
|
132
|
+
function mapConfig(
|
|
133
|
+
raw: Record<string, unknown>,
|
|
134
|
+
file: string,
|
|
135
|
+
warnings: string[],
|
|
136
|
+
): QuantakryptoFileConfig {
|
|
137
|
+
// Known top-level keys. Anything else is a warning, not an error.
|
|
138
|
+
const KNOWN = new Set([
|
|
139
|
+
"$schema",
|
|
140
|
+
"version",
|
|
141
|
+
"include",
|
|
142
|
+
"exclude",
|
|
143
|
+
"noDefaultIgnores",
|
|
144
|
+
"maxFileSize",
|
|
145
|
+
"scanMinified",
|
|
146
|
+
"detectors",
|
|
147
|
+
"languages",
|
|
148
|
+
"severityThreshold",
|
|
149
|
+
"baseline",
|
|
150
|
+
]);
|
|
151
|
+
for (const key of Object.keys(raw)) {
|
|
152
|
+
if (!KNOWN.has(key)) warnings.push(`unknown config key "${key}" ignored`);
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
// version: known forward-compat warning only.
|
|
156
|
+
if ("version" in raw) {
|
|
157
|
+
const version = raw["version"];
|
|
158
|
+
if (typeof version !== "number" || !Number.isInteger(version)) {
|
|
159
|
+
throw new ConfigError(`"version" must be an integer`, file);
|
|
160
|
+
}
|
|
161
|
+
if (version !== 1) {
|
|
162
|
+
warnings.push(`config "version" ${version} is newer than supported (1); best-effort`);
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
const out: QuantakryptoFileConfig = {};
|
|
167
|
+
|
|
168
|
+
const include = asStringArray(raw, "include", file);
|
|
169
|
+
if (include) out.include = include;
|
|
170
|
+
const exclude = asStringArray(raw, "exclude", file);
|
|
171
|
+
if (exclude) out.exclude = exclude;
|
|
172
|
+
|
|
173
|
+
const noDefaultIgnores = asBool(raw, "noDefaultIgnores", file);
|
|
174
|
+
if (noDefaultIgnores !== undefined) out.noDefaultIgnores = noDefaultIgnores;
|
|
175
|
+
const scanMinified = asBool(raw, "scanMinified", file);
|
|
176
|
+
if (scanMinified !== undefined) out.scanMinified = scanMinified;
|
|
177
|
+
|
|
178
|
+
const maxFileSize = asInt(raw, "maxFileSize", file);
|
|
179
|
+
if (maxFileSize !== undefined) out.maxFileSize = maxFileSize;
|
|
180
|
+
|
|
181
|
+
// detectors.<family> bool map → source / dependencies / config scope toggles.
|
|
182
|
+
// A family turned off is equivalent to its `--no-*` flag (per docs/CONFIG.md
|
|
183
|
+
// §4.1). We collapse the per-family map onto the three scan-scope toggles:
|
|
184
|
+
// dependencies → ScanOptions.dependencies
|
|
185
|
+
// pem-material + tls-config → ScanOptions.config (config-scope detectors)
|
|
186
|
+
// the JS source families → ScanOptions.source (source-scope detectors)
|
|
187
|
+
// Only an explicit `false` flips a toggle off; absent/true leaves the default.
|
|
188
|
+
if ("detectors" in raw) {
|
|
189
|
+
const det = raw["detectors"];
|
|
190
|
+
if (!isObject(det)) {
|
|
191
|
+
throw new ConfigError(`"detectors" must be an object of family→boolean`, file);
|
|
192
|
+
}
|
|
193
|
+
for (const key of Object.keys(det)) {
|
|
194
|
+
if (!(DETECTOR_FAMILIES as readonly string[]).includes(key)) {
|
|
195
|
+
warnings.push(`unknown detector family "${key}" ignored`);
|
|
196
|
+
continue;
|
|
197
|
+
}
|
|
198
|
+
const v = det[key];
|
|
199
|
+
if (typeof v !== "boolean") {
|
|
200
|
+
throw new ConfigError(`"detectors.${key}" must be a boolean`, file);
|
|
201
|
+
}
|
|
202
|
+
if (v) continue; // true = default; nothing to disable.
|
|
203
|
+
switch (key) {
|
|
204
|
+
case "dependencies":
|
|
205
|
+
out.dependencies = false;
|
|
206
|
+
break;
|
|
207
|
+
case "tls-config":
|
|
208
|
+
case "pem-material":
|
|
209
|
+
out.config = false;
|
|
210
|
+
break;
|
|
211
|
+
default:
|
|
212
|
+
// node-crypto / webcrypto / crypto-libs / jwt-jose are source-scope.
|
|
213
|
+
out.source = false;
|
|
214
|
+
break;
|
|
215
|
+
}
|
|
216
|
+
}
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
// languages: forward-looking (inert until the plugin registry lands). Validate
|
|
220
|
+
// the type so the file format is stable, but do not map it anywhere.
|
|
221
|
+
const languages = asStringArray(raw, "languages", file);
|
|
222
|
+
if (languages) warnings.push(`"languages" is accepted but inert until detector plugins land`);
|
|
223
|
+
|
|
224
|
+
// severityThreshold: enum.
|
|
225
|
+
if ("severityThreshold" in raw) {
|
|
226
|
+
const sev = raw["severityThreshold"];
|
|
227
|
+
if (typeof sev !== "string" || !(SEVERITIES as readonly string[]).includes(sev)) {
|
|
228
|
+
throw new ConfigError(`"severityThreshold" must be one of: ${SEVERITIES.join(", ")}`, file);
|
|
229
|
+
}
|
|
230
|
+
out.severityThreshold = sev as Severity;
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
// baseline: path string, resolved relative to the config file's directory.
|
|
234
|
+
if ("baseline" in raw) {
|
|
235
|
+
const baseline = raw["baseline"];
|
|
236
|
+
if (typeof baseline !== "string" || baseline.length === 0) {
|
|
237
|
+
throw new ConfigError(`"baseline" must be a non-empty path string`, file);
|
|
238
|
+
}
|
|
239
|
+
out.baseline = path.isAbsolute(baseline) ? baseline : path.join(path.dirname(file), baseline);
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
return out;
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
/**
|
|
246
|
+
* Load a `quantakrypto.config.json` for a scan.
|
|
247
|
+
*
|
|
248
|
+
* By default reads `<root>/quantakrypto.config.json`. Pass an explicit file path as
|
|
249
|
+
* `root` (or via the caller's `--config` flag) to read a named file instead —
|
|
250
|
+
* if the path's basename is `quantakrypto.config.json` it is read directly; otherwise
|
|
251
|
+
* the path is treated as a directory and the file is looked up inside it. When
|
|
252
|
+
* an explicit path is given but missing, that is an error; an *absent*
|
|
253
|
+
* auto-discovered file is tolerated (returns an empty config).
|
|
254
|
+
*
|
|
255
|
+
* @param root Directory to look in, OR an explicit config file path.
|
|
256
|
+
* @param opts.explicit When true, a missing file is an error (the user named it).
|
|
257
|
+
* @throws {ConfigError} On malformed JSON, a bad value for a known key, or a
|
|
258
|
+
* missing explicitly-named file.
|
|
259
|
+
*/
|
|
260
|
+
export async function loadConfig(
|
|
261
|
+
root: string,
|
|
262
|
+
opts: { explicit?: boolean } = {},
|
|
263
|
+
): Promise<LoadConfigResult> {
|
|
264
|
+
// Resolve the file path: an explicit *.json file is used verbatim; otherwise
|
|
265
|
+
// treat `root` as a directory and append the canonical name.
|
|
266
|
+
const base = path.basename(root);
|
|
267
|
+
const file =
|
|
268
|
+
base === CONFIG_FILENAME || base.endsWith(".json")
|
|
269
|
+
? path.resolve(root)
|
|
270
|
+
: path.resolve(root, CONFIG_FILENAME);
|
|
271
|
+
|
|
272
|
+
let text: string;
|
|
273
|
+
try {
|
|
274
|
+
text = await readFile(file, "utf8");
|
|
275
|
+
} catch {
|
|
276
|
+
if (opts.explicit) {
|
|
277
|
+
throw new ConfigError(`config file not found: ${file}`, file);
|
|
278
|
+
}
|
|
279
|
+
return { config: {}, warnings: [] }; // tolerant when auto-discovering.
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
let parsed: unknown;
|
|
283
|
+
try {
|
|
284
|
+
parsed = JSON.parse(text);
|
|
285
|
+
} catch (err) {
|
|
286
|
+
throw new ConfigError(`invalid JSON in ${file}: ${(err as Error).message}`, file);
|
|
287
|
+
}
|
|
288
|
+
if (!isObject(parsed)) {
|
|
289
|
+
throw new ConfigError(`config must be a JSON object, got ${typeof parsed}`, file);
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
const warnings: string[] = [];
|
|
293
|
+
const config = mapConfig(parsed, file, warnings);
|
|
294
|
+
return { config, path: file, warnings };
|
|
295
|
+
}
|
package/src/cwe.ts
ADDED
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* CWE (Common Weakness Enumeration) identifiers used to tag findings. These
|
|
3
|
+
* feed SARIF rule taxa / properties and the CBOM export, and let downstream
|
|
4
|
+
* compliance tooling map quantaKrypto findings to a standard weakness taxonomy.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
/** CWE-327: Use of a Broken or Risky Cryptographic Algorithm. */
|
|
8
|
+
export const CWE_BROKEN_CRYPTO = "CWE-327";
|
|
9
|
+
|
|
10
|
+
/** CWE-326: Inadequate Encryption Strength. */
|
|
11
|
+
export const CWE_WEAK_STRENGTH = "CWE-326";
|
|
12
|
+
|
|
13
|
+
/** CWE-295: Improper Certificate Validation. */
|
|
14
|
+
export const CWE_CERT_VALIDATION = "CWE-295";
|
|
15
|
+
|
|
16
|
+
/** CWE-798: Use of Hard-coded Credentials (embedded private keys). */
|
|
17
|
+
export const CWE_HARDCODED_KEY = "CWE-798";
|
|
18
|
+
|
|
19
|
+
/** CWE-1240: Use of a Cryptographic Primitive with a Risky Implementation. */
|
|
20
|
+
export const CWE_RISKY_PRIMITIVE = "CWE-1240";
|
|
@@ -0,0 +1,299 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Curated database of npm packages that primarily expose classical asymmetric
|
|
3
|
+
* cryptography (and are therefore quantum-vulnerable), plus a manifest scanner
|
|
4
|
+
* that flags any of them found in package.json / package-lock.json.
|
|
5
|
+
*
|
|
6
|
+
* The list is intentionally focused on libraries whose *purpose* is classical
|
|
7
|
+
* public-key crypto. General-purpose packages that merely call out to Node's
|
|
8
|
+
* crypto are out of scope here (those are caught by the source detectors).
|
|
9
|
+
*/
|
|
10
|
+
import type { AlgorithmFamily, Finding, VulnerableDependency } from "./types.js";
|
|
11
|
+
import { makeFinding } from "./detect-utils.js";
|
|
12
|
+
import { remediationText } from "./remediation.js";
|
|
13
|
+
import { CWE_BROKEN_CRYPTO } from "./cwe.js";
|
|
14
|
+
|
|
15
|
+
/** Known quantum-vulnerable npm dependencies. */
|
|
16
|
+
export const vulnerableDependencies: VulnerableDependency[] = [
|
|
17
|
+
{
|
|
18
|
+
name: "node-forge",
|
|
19
|
+
ecosystem: "npm",
|
|
20
|
+
reason: "Pure-JS implementation of RSA, RSA-OAEP, and X.509 PKI.",
|
|
21
|
+
algorithms: ["RSA"],
|
|
22
|
+
severity: "high",
|
|
23
|
+
},
|
|
24
|
+
{
|
|
25
|
+
name: "elliptic",
|
|
26
|
+
ecosystem: "npm",
|
|
27
|
+
reason: "Elliptic-curve ECDSA/ECDH (secp256k1, p256, ed25519).",
|
|
28
|
+
algorithms: ["ECDSA", "ECDH", "EdDSA"],
|
|
29
|
+
severity: "high",
|
|
30
|
+
},
|
|
31
|
+
{
|
|
32
|
+
name: "jsrsasign",
|
|
33
|
+
ecosystem: "npm",
|
|
34
|
+
reason: "RSA/ECDSA/DSA signing, JWT, and X.509 in pure JS.",
|
|
35
|
+
algorithms: ["RSA", "ECDSA", "DSA"],
|
|
36
|
+
severity: "high",
|
|
37
|
+
},
|
|
38
|
+
{
|
|
39
|
+
name: "node-rsa",
|
|
40
|
+
ecosystem: "npm",
|
|
41
|
+
reason: "Classical RSA encryption and signing.",
|
|
42
|
+
algorithms: ["RSA"],
|
|
43
|
+
severity: "high",
|
|
44
|
+
},
|
|
45
|
+
{
|
|
46
|
+
name: "ursa",
|
|
47
|
+
ecosystem: "npm",
|
|
48
|
+
reason: "OpenSSL-backed RSA encryption and signing bindings.",
|
|
49
|
+
algorithms: ["RSA"],
|
|
50
|
+
severity: "high",
|
|
51
|
+
},
|
|
52
|
+
{
|
|
53
|
+
name: "sshpk",
|
|
54
|
+
ecosystem: "npm",
|
|
55
|
+
reason: "Parses/handles SSH and PEM keys (RSA, ECDSA, Ed25519, DSA).",
|
|
56
|
+
algorithms: ["RSA", "ECDSA", "EdDSA", "DSA"],
|
|
57
|
+
severity: "medium",
|
|
58
|
+
},
|
|
59
|
+
{
|
|
60
|
+
name: "jsonwebtoken",
|
|
61
|
+
ecosystem: "npm",
|
|
62
|
+
reason: "JWTs commonly signed with RS256/ES256 (classical RSA/ECDSA).",
|
|
63
|
+
algorithms: ["RSA", "ECDSA"],
|
|
64
|
+
severity: "high",
|
|
65
|
+
},
|
|
66
|
+
{
|
|
67
|
+
name: "jose",
|
|
68
|
+
ecosystem: "npm",
|
|
69
|
+
reason: "JWS/JWE with classical RSA-OAEP, RSA-PSS, ECDH-ES and ECDSA.",
|
|
70
|
+
algorithms: ["RSA", "ECDH", "ECDSA", "EdDSA"],
|
|
71
|
+
severity: "high",
|
|
72
|
+
},
|
|
73
|
+
{
|
|
74
|
+
name: "jws",
|
|
75
|
+
ecosystem: "npm",
|
|
76
|
+
reason: "JSON Web Signatures using classical RS/ES algorithms.",
|
|
77
|
+
algorithms: ["RSA", "ECDSA"],
|
|
78
|
+
severity: "high",
|
|
79
|
+
},
|
|
80
|
+
{
|
|
81
|
+
name: "eccrypto",
|
|
82
|
+
ecosystem: "npm",
|
|
83
|
+
reason: "ECIES (ECDH-based) encryption and ECDSA signatures.",
|
|
84
|
+
algorithms: ["ECIES", "ECDH", "ECDSA"],
|
|
85
|
+
severity: "high",
|
|
86
|
+
},
|
|
87
|
+
{
|
|
88
|
+
name: "secp256k1",
|
|
89
|
+
ecosystem: "npm",
|
|
90
|
+
reason: "secp256k1 ECDSA/ECDH bindings (blockchain keys).",
|
|
91
|
+
algorithms: ["ECDSA", "ECDH"],
|
|
92
|
+
severity: "high",
|
|
93
|
+
},
|
|
94
|
+
{
|
|
95
|
+
name: "tweetnacl",
|
|
96
|
+
ecosystem: "npm",
|
|
97
|
+
reason: "X25519 key exchange and Ed25519 signatures (modern but classical).",
|
|
98
|
+
algorithms: ["X25519", "EdDSA"],
|
|
99
|
+
severity: "low",
|
|
100
|
+
},
|
|
101
|
+
{
|
|
102
|
+
name: "ed25519",
|
|
103
|
+
ecosystem: "npm",
|
|
104
|
+
reason: "Ed25519 signatures (classical).",
|
|
105
|
+
algorithms: ["EdDSA"],
|
|
106
|
+
severity: "low",
|
|
107
|
+
},
|
|
108
|
+
{
|
|
109
|
+
name: "@noble/curves",
|
|
110
|
+
ecosystem: "npm",
|
|
111
|
+
reason: "Audited classical curves: ECDSA, ECDH, Ed25519, X25519, secp256k1.",
|
|
112
|
+
algorithms: ["ECDSA", "ECDH", "EdDSA", "X25519"],
|
|
113
|
+
severity: "medium",
|
|
114
|
+
},
|
|
115
|
+
{
|
|
116
|
+
name: "@noble/secp256k1",
|
|
117
|
+
ecosystem: "npm",
|
|
118
|
+
reason: "secp256k1 ECDSA/ECDH (classical).",
|
|
119
|
+
algorithms: ["ECDSA", "ECDH"],
|
|
120
|
+
severity: "medium",
|
|
121
|
+
},
|
|
122
|
+
{
|
|
123
|
+
name: "@noble/ed25519",
|
|
124
|
+
ecosystem: "npm",
|
|
125
|
+
reason: "Ed25519 signatures and X25519 key exchange (classical).",
|
|
126
|
+
algorithms: ["EdDSA", "X25519"],
|
|
127
|
+
severity: "low",
|
|
128
|
+
},
|
|
129
|
+
{
|
|
130
|
+
name: "paseto",
|
|
131
|
+
ecosystem: "npm",
|
|
132
|
+
reason: "PASETO public tokens signed with classical Ed25519 (v2/v4) or RSA.",
|
|
133
|
+
algorithms: ["EdDSA", "RSA"],
|
|
134
|
+
severity: "medium",
|
|
135
|
+
},
|
|
136
|
+
{
|
|
137
|
+
name: "bcrypto",
|
|
138
|
+
ecosystem: "npm",
|
|
139
|
+
reason: "Broad classical crypto suite: RSA, ECDSA, ECDH, Ed25519, DSA.",
|
|
140
|
+
algorithms: ["RSA", "ECDSA", "ECDH", "EdDSA", "DSA"],
|
|
141
|
+
severity: "high",
|
|
142
|
+
},
|
|
143
|
+
{
|
|
144
|
+
name: "ecpair",
|
|
145
|
+
ecosystem: "npm",
|
|
146
|
+
reason: "secp256k1 ECDSA key pairs for Bitcoin.",
|
|
147
|
+
algorithms: ["ECDSA"],
|
|
148
|
+
severity: "medium",
|
|
149
|
+
},
|
|
150
|
+
{
|
|
151
|
+
name: "keypair",
|
|
152
|
+
ecosystem: "npm",
|
|
153
|
+
reason: "Pure-JS RSA key pair generation.",
|
|
154
|
+
algorithms: ["RSA"],
|
|
155
|
+
severity: "high",
|
|
156
|
+
},
|
|
157
|
+
];
|
|
158
|
+
|
|
159
|
+
/** Fast lookup map by package name. */
|
|
160
|
+
const BY_NAME = new Map<string, VulnerableDependency>(
|
|
161
|
+
vulnerableDependencies.map((d) => [d.name, d]),
|
|
162
|
+
);
|
|
163
|
+
|
|
164
|
+
/**
|
|
165
|
+
* Precompiled `"name":` lookup regex per known package (P1-9): the package set
|
|
166
|
+
* is static, so we build the (escaped-name → RegExp) map once at module load
|
|
167
|
+
* rather than recompiling a fresh RegExp per found package per manifest.
|
|
168
|
+
*/
|
|
169
|
+
const KEY_REGEX_BY_NAME = new Map<string, RegExp>(
|
|
170
|
+
vulnerableDependencies.map((d): [string, RegExp] => {
|
|
171
|
+
const escaped = d.name.replace(/[.*+?^${}()|[\]\\/]/g, "\\$&");
|
|
172
|
+
return [d.name, new RegExp(`"${escaped}"\\s*:`)];
|
|
173
|
+
}),
|
|
174
|
+
);
|
|
175
|
+
|
|
176
|
+
/** Algorithm families that expose confidentiality (HNDL) rather than only signing. */
|
|
177
|
+
const CONFIDENTIALITY_FAMILIES: ReadonlySet<AlgorithmFamily> = new Set<AlgorithmFamily>([
|
|
178
|
+
"RSA",
|
|
179
|
+
"ECDH",
|
|
180
|
+
"DH",
|
|
181
|
+
"ECIES",
|
|
182
|
+
"X25519",
|
|
183
|
+
"X448",
|
|
184
|
+
]);
|
|
185
|
+
|
|
186
|
+
/**
|
|
187
|
+
* Build a remediation string covering ALL families a package exposes (C5),
|
|
188
|
+
* rather than only `algorithms[0]`. De-duplicates the per-family recommendation
|
|
189
|
+
* strings and joins them, so a signature+KEM library points at both replacements.
|
|
190
|
+
*/
|
|
191
|
+
function multiFamilyRemediation(algorithms: readonly AlgorithmFamily[]): string {
|
|
192
|
+
const parts = new Set<string>();
|
|
193
|
+
for (const a of algorithms) parts.add(remediationText(a));
|
|
194
|
+
return [...parts].join("; ");
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
/** True if a file path looks like a manifest we can parse for dependencies. */
|
|
198
|
+
export function isManifestFile(file: string): boolean {
|
|
199
|
+
const base = file.split("/").pop() ?? file;
|
|
200
|
+
return base === "package.json" || base === "package-lock.json";
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
/**
|
|
204
|
+
* Build a finding for a vulnerable dependency located in a manifest. The
|
|
205
|
+
* location points at the line where the package name appears in the file (best
|
|
206
|
+
* effort), falling back to line 1.
|
|
207
|
+
*/
|
|
208
|
+
function dependencyFinding(
|
|
209
|
+
dep: VulnerableDependency,
|
|
210
|
+
file: string,
|
|
211
|
+
content: string,
|
|
212
|
+
index: number,
|
|
213
|
+
): Finding {
|
|
214
|
+
// Use the first listed algorithm for the headline family, but derive the
|
|
215
|
+
// remediation from ALL families the package exposes (C5).
|
|
216
|
+
const algorithm = dep.algorithms[0] ?? "unknown";
|
|
217
|
+
return makeFinding({
|
|
218
|
+
ruleId: "dep-vulnerable",
|
|
219
|
+
title: `Quantum-vulnerable dependency: ${dep.name}`,
|
|
220
|
+
category: "dependency",
|
|
221
|
+
severity: dep.severity,
|
|
222
|
+
confidence: "high",
|
|
223
|
+
algorithm,
|
|
224
|
+
// Confidentiality libs are HNDL-exposed; signature-only ones are not.
|
|
225
|
+
hndl: dep.algorithms.some((a) => CONFIDENTIALITY_FAMILIES.has(a)),
|
|
226
|
+
cwe: CWE_BROKEN_CRYPTO,
|
|
227
|
+
message: `${dep.name} — ${dep.reason}`,
|
|
228
|
+
remediation: multiFamilyRemediation(dep.algorithms),
|
|
229
|
+
file,
|
|
230
|
+
content,
|
|
231
|
+
index,
|
|
232
|
+
});
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
/** Locate the offset of a JSON key `"name"` in the manifest text (or 0). */
|
|
236
|
+
function offsetOfKey(content: string, name: string): number {
|
|
237
|
+
// Use the precompiled per-name regex (non-global; .exec from position 0).
|
|
238
|
+
const re = KEY_REGEX_BY_NAME.get(name);
|
|
239
|
+
if (!re) return 0;
|
|
240
|
+
const m = re.exec(content);
|
|
241
|
+
return m ? m.index : 0;
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
/**
|
|
245
|
+
* Scan a single manifest file's contents for vulnerable dependencies.
|
|
246
|
+
*
|
|
247
|
+
* - `package.json`: dependencies / devDependencies / peerDependencies / optionalDependencies.
|
|
248
|
+
* - `package-lock.json` (v2/v3): the `packages` map keys (node_modules/<name>),
|
|
249
|
+
* plus legacy `dependencies` map for v1.
|
|
250
|
+
*
|
|
251
|
+
* Returns one finding per distinct vulnerable package name found in the file.
|
|
252
|
+
*/
|
|
253
|
+
export function scanManifest(file: string, content: string): Finding[] {
|
|
254
|
+
let json: unknown;
|
|
255
|
+
try {
|
|
256
|
+
json = JSON.parse(content);
|
|
257
|
+
} catch {
|
|
258
|
+
return []; // not valid JSON — skip quietly.
|
|
259
|
+
}
|
|
260
|
+
if (json === null || typeof json !== "object") return [];
|
|
261
|
+
|
|
262
|
+
const found = new Set<string>();
|
|
263
|
+
const obj = json as Record<string, unknown>;
|
|
264
|
+
|
|
265
|
+
const collectFromRecord = (rec: unknown): void => {
|
|
266
|
+
if (rec === null || typeof rec !== "object") return;
|
|
267
|
+
for (const key of Object.keys(rec as Record<string, unknown>)) {
|
|
268
|
+
if (BY_NAME.has(key)) found.add(key);
|
|
269
|
+
}
|
|
270
|
+
};
|
|
271
|
+
|
|
272
|
+
// package.json dependency sections.
|
|
273
|
+
collectFromRecord(obj.dependencies);
|
|
274
|
+
collectFromRecord(obj.devDependencies);
|
|
275
|
+
collectFromRecord(obj.peerDependencies);
|
|
276
|
+
collectFromRecord(obj.optionalDependencies);
|
|
277
|
+
|
|
278
|
+
// package-lock.json v2/v3 "packages" map: keys are "node_modules/<name>".
|
|
279
|
+
const packages = obj.packages;
|
|
280
|
+
if (packages !== null && typeof packages === "object") {
|
|
281
|
+
for (const key of Object.keys(packages as Record<string, unknown>)) {
|
|
282
|
+
if (!key) continue; // root package entry
|
|
283
|
+
const marker = "node_modules/";
|
|
284
|
+
const idx = key.lastIndexOf(marker);
|
|
285
|
+
const name = idx >= 0 ? key.slice(idx + marker.length) : key;
|
|
286
|
+
if (BY_NAME.has(name)) found.add(name);
|
|
287
|
+
}
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
const findings: Finding[] = [];
|
|
291
|
+
for (const name of found) {
|
|
292
|
+
const dep = BY_NAME.get(name);
|
|
293
|
+
if (!dep) continue;
|
|
294
|
+
findings.push(dependencyFinding(dep, file, content, offsetOfKey(content, name)));
|
|
295
|
+
}
|
|
296
|
+
// Deterministic ordering by package name.
|
|
297
|
+
findings.sort((a, b) => (a.title < b.title ? -1 : a.title > b.title ? 1 : 0));
|
|
298
|
+
return findings;
|
|
299
|
+
}
|