@quantakrypto/core 0.3.0 → 0.4.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/dist/agent-types.d.ts +48 -0
- package/dist/agent-types.d.ts.map +1 -0
- package/dist/agent-types.js +2 -0
- package/dist/agent-types.js.map +1 -0
- package/dist/cache.d.ts +27 -0
- package/dist/cache.d.ts.map +1 -0
- package/dist/cache.js +93 -0
- package/dist/cache.js.map +1 -0
- package/dist/cbom.d.ts.map +1 -1
- package/dist/cbom.js +26 -1
- package/dist/cbom.js.map +1 -1
- package/dist/changed.d.ts.map +1 -1
- package/dist/changed.js +14 -2
- package/dist/changed.js.map +1 -1
- package/dist/codemods/config-toggle.d.ts +3 -0
- package/dist/codemods/config-toggle.d.ts.map +1 -0
- package/dist/codemods/config-toggle.js +26 -0
- package/dist/codemods/config-toggle.js.map +1 -0
- package/dist/codemods/registry.d.ts +26 -0
- package/dist/codemods/registry.d.ts.map +1 -0
- package/dist/codemods/registry.js +8 -0
- package/dist/codemods/registry.js.map +1 -0
- package/dist/comments.d.ts +45 -0
- package/dist/comments.d.ts.map +1 -0
- package/dist/comments.js +164 -0
- package/dist/comments.js.map +1 -0
- package/dist/config.d.ts +1 -1
- package/dist/config.d.ts.map +1 -1
- package/dist/config.js +7 -0
- package/dist/config.js.map +1 -1
- package/dist/dependencies.d.ts +31 -13
- package/dist/dependencies.d.ts.map +1 -1
- package/dist/dependencies.js +460 -40
- package/dist/dependencies.js.map +1 -1
- package/dist/detect-utils.d.ts +44 -1
- package/dist/detect-utils.d.ts.map +1 -1
- package/dist/detect-utils.js +113 -17
- package/dist/detect-utils.js.map +1 -1
- package/dist/detectors/c.d.ts +20 -0
- package/dist/detectors/c.d.ts.map +1 -0
- package/dist/detectors/c.js +104 -0
- package/dist/detectors/c.js.map +1 -0
- package/dist/detectors/csharp.d.ts +15 -0
- package/dist/detectors/csharp.d.ts.map +1 -0
- package/dist/detectors/csharp.js +76 -0
- package/dist/detectors/csharp.js.map +1 -0
- package/dist/detectors/go.d.ts +25 -0
- package/dist/detectors/go.d.ts.map +1 -0
- package/dist/detectors/go.js +132 -0
- package/dist/detectors/go.js.map +1 -0
- package/dist/detectors/java.d.ts +25 -0
- package/dist/detectors/java.d.ts.map +1 -0
- package/dist/detectors/java.js +200 -0
- package/dist/detectors/java.js.map +1 -0
- package/dist/detectors/python.d.ts +26 -0
- package/dist/detectors/python.d.ts.map +1 -0
- package/dist/detectors/python.js +171 -0
- package/dist/detectors/python.js.map +1 -0
- package/dist/detectors/ruby.d.ts +15 -0
- package/dist/detectors/ruby.d.ts.map +1 -0
- package/dist/detectors/ruby.js +75 -0
- package/dist/detectors/ruby.js.map +1 -0
- package/dist/detectors/rust.d.ts +13 -0
- package/dist/detectors/rust.d.ts.map +1 -0
- package/dist/detectors/rust.js +94 -0
- package/dist/detectors/rust.js.map +1 -0
- package/dist/detectors/source.d.ts.map +1 -1
- package/dist/detectors/source.js +70 -22
- package/dist/detectors/source.js.map +1 -1
- package/dist/index.d.ts +18 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +15 -1
- package/dist/index.js.map +1 -1
- package/dist/parallel.d.ts +4 -0
- package/dist/parallel.d.ts.map +1 -1
- package/dist/parallel.js +59 -8
- package/dist/parallel.js.map +1 -1
- package/dist/patch-policy.d.ts +22 -0
- package/dist/patch-policy.d.ts.map +1 -0
- package/dist/patch-policy.js +21 -0
- package/dist/patch-policy.js.map +1 -0
- package/dist/redact.d.ts +19 -0
- package/dist/redact.d.ts.map +1 -0
- package/dist/redact.js +77 -0
- package/dist/redact.js.map +1 -0
- package/dist/registry.d.ts +13 -3
- package/dist/registry.d.ts.map +1 -1
- package/dist/registry.js +31 -4
- package/dist/registry.js.map +1 -1
- package/dist/remediate-pipeline.d.ts +41 -0
- package/dist/remediate-pipeline.d.ts.map +1 -0
- package/dist/remediate-pipeline.js +38 -0
- package/dist/remediate-pipeline.js.map +1 -0
- package/dist/remediate-request.d.ts +21 -0
- package/dist/remediate-request.d.ts.map +1 -0
- package/dist/remediate-request.js +28 -0
- package/dist/remediate-request.js.map +1 -0
- package/dist/remediation.d.ts +10 -0
- package/dist/remediation.d.ts.map +1 -1
- package/dist/remediation.js +21 -8
- package/dist/remediation.js.map +1 -1
- package/dist/report.d.ts.map +1 -1
- package/dist/report.js +16 -1
- package/dist/report.js.map +1 -1
- package/dist/scan-worker.js +6 -2
- package/dist/scan-worker.js.map +1 -1
- package/dist/scan.d.ts +5 -4
- package/dist/scan.d.ts.map +1 -1
- package/dist/scan.js +52 -29
- package/dist/scan.js.map +1 -1
- package/dist/triage.d.ts +27 -0
- package/dist/triage.d.ts.map +1 -0
- package/dist/triage.js +33 -0
- package/dist/triage.js.map +1 -0
- package/dist/types.d.ts +53 -3
- package/dist/types.d.ts.map +1 -1
- package/dist/verify.d.ts +30 -0
- package/dist/verify.d.ts.map +1 -0
- package/dist/verify.js +65 -0
- package/dist/verify.js.map +1 -0
- package/dist/version.d.ts +1 -1
- package/dist/version.js +1 -1
- package/dist/walk.d.ts +7 -0
- package/dist/walk.d.ts.map +1 -1
- package/dist/walk.js +85 -11
- package/dist/walk.js.map +1 -1
- package/dist/worktree.d.ts +6 -0
- package/dist/worktree.d.ts.map +1 -0
- package/dist/worktree.js +43 -0
- package/dist/worktree.js.map +1 -0
- package/package.json +1 -1
- package/src/agent-types.ts +52 -0
- package/src/cache.ts +120 -0
- package/src/cbom.ts +27 -1
- package/src/changed.ts +13 -4
- package/src/codemods/config-toggle.ts +35 -0
- package/src/codemods/registry.ts +31 -0
- package/src/comments.ts +180 -0
- package/src/config.ts +8 -0
- package/src/dependencies.ts +483 -51
- package/src/detect-utils.ts +123 -15
- package/src/detectors/c.ts +132 -0
- package/src/detectors/csharp.ts +98 -0
- package/src/detectors/go.ts +167 -0
- package/src/detectors/java.ts +235 -0
- package/src/detectors/python.ts +212 -0
- package/src/detectors/ruby.ts +97 -0
- package/src/detectors/rust.ts +112 -0
- package/src/detectors/source.ts +69 -21
- package/src/index.ts +45 -1
- package/src/parallel.ts +63 -6
- package/src/patch-policy.ts +42 -0
- package/src/redact.ts +92 -0
- package/src/registry.ts +32 -4
- package/src/remediate-pipeline.ts +81 -0
- package/src/remediate-request.ts +52 -0
- package/src/remediation.ts +27 -11
- package/src/report.ts +27 -1
- package/src/scan-worker.ts +6 -2
- package/src/scan.ts +62 -31
- package/src/triage.ts +58 -0
- package/src/types.ts +66 -3
- package/src/verify.ts +82 -0
- package/src/version.ts +1 -1
- package/src/walk.ts +81 -12
- package/src/worktree.ts +45 -0
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Remediation rubric, fix schema, and request-bundle builder — offline and
|
|
3
|
+
* deterministic, shared by `@quantakrypto/agent` (which prompts a model with
|
|
4
|
+
* them) and the MCP `remediate_findings` tool (which hands the bundle to the
|
|
5
|
+
* host agent). Keeping them here means the MCP server stays offline/key-free.
|
|
6
|
+
*/
|
|
7
|
+
import type { Finding } from "./types.js";
|
|
8
|
+
import type { ContextLevel, RedactedContext } from "./agent-types.js";
|
|
9
|
+
import { buildContext } from "./redact.js";
|
|
10
|
+
|
|
11
|
+
/** The system rubric for a fix proposal. */
|
|
12
|
+
export const REMEDIATE_RUBRIC =
|
|
13
|
+
"You are a post-quantum cryptography migration engineer. Given the FULL content " +
|
|
14
|
+
"of one source file plus a finding describing classical (quantum-vulnerable) " +
|
|
15
|
+
"cryptography in it, return the FULL corrected file content that removes the " +
|
|
16
|
+
"flagged usage, migrating to a post-quantum or hybrid construction " +
|
|
17
|
+
"(ML-KEM-768 / ML-DSA-65, hybrid X25519MLKEM768) where a safe replacement " +
|
|
18
|
+
"exists. Change as little as possible; preserve all other code and formatting " +
|
|
19
|
+
"exactly. If you cannot safely fix it, return newContent identical to the " +
|
|
20
|
+
"input. NEVER invent or alter secrets/keys. After proposing, VERIFY with the " +
|
|
21
|
+
"verify_fix tool and keep only fixes that clear the finding.";
|
|
22
|
+
|
|
23
|
+
/** JSON Schema every fix proposal must satisfy. */
|
|
24
|
+
export const FIX_REQUEST_SCHEMA: Record<string, unknown> = {
|
|
25
|
+
type: "object",
|
|
26
|
+
required: ["path", "newContent", "explanation"],
|
|
27
|
+
properties: {
|
|
28
|
+
path: { type: "string" },
|
|
29
|
+
newContent: { type: "string" },
|
|
30
|
+
explanation: { type: "string" },
|
|
31
|
+
},
|
|
32
|
+
};
|
|
33
|
+
|
|
34
|
+
/** A remediation request bundle for the host agent. */
|
|
35
|
+
export interface RemediateRequest {
|
|
36
|
+
instructions: string;
|
|
37
|
+
schema: Record<string, unknown>;
|
|
38
|
+
contexts: RedactedContext[];
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/** Build a remediation request bundle (offline; metadata level unless `readContent`). */
|
|
42
|
+
export function buildRemediateRequest(
|
|
43
|
+
findings: readonly Finding[],
|
|
44
|
+
level: ContextLevel = "metadata",
|
|
45
|
+
readContent?: (finding: Finding) => string,
|
|
46
|
+
): RemediateRequest {
|
|
47
|
+
const effectiveLevel: ContextLevel = readContent ? level : "metadata";
|
|
48
|
+
const contexts = findings.map((f) =>
|
|
49
|
+
buildContext(f, effectiveLevel, readContent ? readContent(f) : ""),
|
|
50
|
+
);
|
|
51
|
+
return { instructions: REMEDIATE_RUBRIC, schema: FIX_REQUEST_SCHEMA, contexts };
|
|
52
|
+
}
|
package/src/remediation.ts
CHANGED
|
@@ -96,6 +96,31 @@ export function remediationText(algorithm: AlgorithmFamily): string {
|
|
|
96
96
|
return REMEDIATIONS[algorithm].recommendation;
|
|
97
97
|
}
|
|
98
98
|
|
|
99
|
+
/**
|
|
100
|
+
* True when a family's PQC replacement is a KEM — i.e. it's used for
|
|
101
|
+
* confidentiality / key-agreement (and is therefore harvest-now-decrypt-later
|
|
102
|
+
* exposed). RSA is in both this set and {@link isSignatureFamily}. Single
|
|
103
|
+
* source of truth for the family→target taxonomy used by tier guidance and the
|
|
104
|
+
* multi-family remediation composer.
|
|
105
|
+
*/
|
|
106
|
+
export function isConfidentialityFamily(algorithm: AlgorithmFamily): boolean {
|
|
107
|
+
return (
|
|
108
|
+
algorithm === "RSA" ||
|
|
109
|
+
algorithm === "ECDH" ||
|
|
110
|
+
algorithm === "DH" ||
|
|
111
|
+
algorithm === "X25519" ||
|
|
112
|
+
algorithm === "X448" ||
|
|
113
|
+
algorithm === "ECIES"
|
|
114
|
+
);
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
/** True when a family's PQC replacement is a signature scheme (ML-DSA / SLH-DSA). */
|
|
118
|
+
export function isSignatureFamily(algorithm: AlgorithmFamily): boolean {
|
|
119
|
+
return (
|
|
120
|
+
algorithm === "RSA" || algorithm === "ECDSA" || algorithm === "EdDSA" || algorithm === "DSA"
|
|
121
|
+
);
|
|
122
|
+
}
|
|
123
|
+
|
|
99
124
|
/* -------------------------------------------------------------------------- */
|
|
100
125
|
/* Security-tier guidance (CNSA 2.0 Category 5) + stateful HBS (SP 800-208) */
|
|
101
126
|
/* -------------------------------------------------------------------------- */
|
|
@@ -135,14 +160,7 @@ export function remediationForTier(
|
|
|
135
160
|
const base = REMEDIATIONS[algorithm];
|
|
136
161
|
const params = TIER_PARAMS[tier];
|
|
137
162
|
// Confidentiality families lean on the KEM; signature families on the signer.
|
|
138
|
-
const
|
|
139
|
-
algorithm === "RSA" ||
|
|
140
|
-
algorithm === "ECDH" ||
|
|
141
|
-
algorithm === "DH" ||
|
|
142
|
-
algorithm === "X25519" ||
|
|
143
|
-
algorithm === "X448" ||
|
|
144
|
-
algorithm === "ECIES";
|
|
145
|
-
const primary = isConfidentiality ? params.kem : params.signature;
|
|
163
|
+
const primary = isConfidentialityFamily(algorithm) ? params.kem : params.signature;
|
|
146
164
|
return {
|
|
147
165
|
algorithm,
|
|
148
166
|
recommendation: `${base.recommendation} — ${tier}: ${primary}`,
|
|
@@ -167,7 +185,5 @@ export const STATEFUL_HBS_NOTE =
|
|
|
167
185
|
/** True when stateful HBS (SP 800-208) is a reasonable alternative for a family. */
|
|
168
186
|
export function statefulHbsApplies(algorithm: AlgorithmFamily): boolean {
|
|
169
187
|
// Signature families only — LMS/XMSS are signatures, not KEMs.
|
|
170
|
-
return (
|
|
171
|
-
algorithm === "RSA" || algorithm === "ECDSA" || algorithm === "EdDSA" || algorithm === "DSA"
|
|
172
|
-
);
|
|
188
|
+
return isSignatureFamily(algorithm);
|
|
173
189
|
}
|
package/src/report.ts
CHANGED
|
@@ -6,6 +6,7 @@
|
|
|
6
6
|
import type { Finding, RuleMeta, ScanResult, Severity } from "./types.js";
|
|
7
7
|
import { VERSION } from "./version.js";
|
|
8
8
|
import { SEVERITY_ORDER, sarifLevel } from "./severity.js";
|
|
9
|
+
import { ANALYZABLE_LANGUAGES_LABEL } from "./detect-utils.js";
|
|
9
10
|
|
|
10
11
|
/** Minimal SARIF 2.1.0 log shape (kept permissive on purpose). */
|
|
11
12
|
export interface SarifLog {
|
|
@@ -253,6 +254,8 @@ export function toJson(result: ScanResult, opts?: ReportOptions): Record<string,
|
|
|
253
254
|
startedAt: result.startedAt,
|
|
254
255
|
finishedAt: result.finishedAt,
|
|
255
256
|
filesScanned: result.filesScanned,
|
|
257
|
+
...(result.analyzedFiles !== undefined ? { analyzedFiles: result.analyzedFiles } : {}),
|
|
258
|
+
...(result.diagnostics ? { diagnostics: result.diagnostics } : {}),
|
|
256
259
|
inventory: {
|
|
257
260
|
readinessScore: result.inventory.readinessScore,
|
|
258
261
|
hndlCount: result.inventory.hndlCount,
|
|
@@ -339,12 +342,35 @@ export function formatSummary(result: ScanResult, options?: { color?: boolean })
|
|
|
339
342
|
lines.push(
|
|
340
343
|
`Readiness score: ${c(`${ANSI.bold}${scoreColor(inv.readinessScore)}`, `${inv.readinessScore}/100`)}`,
|
|
341
344
|
);
|
|
345
|
+
const analyzed =
|
|
346
|
+
result.analyzedFiles !== undefined
|
|
347
|
+
? ` Analyzed (${ANALYZABLE_LANGUAGES_LABEL}): ${result.analyzedFiles}`
|
|
348
|
+
: "";
|
|
342
349
|
lines.push(
|
|
343
|
-
`Files scanned: ${result.filesScanned} Findings: ${result.findings.length} HNDL-exposed: ${c(
|
|
350
|
+
`Files scanned: ${result.filesScanned}${analyzed} Findings: ${result.findings.length} HNDL-exposed: ${c(
|
|
344
351
|
inv.hndlCount > 0 ? ANSI.red : ANSI.green,
|
|
345
352
|
String(inv.hndlCount),
|
|
346
353
|
)}`,
|
|
347
354
|
);
|
|
355
|
+
// Coverage honesty: a score over zero analyzable files is not a clean bill of health.
|
|
356
|
+
if (result.analyzedFiles === 0 && result.filesScanned > 0) {
|
|
357
|
+
lines.push(
|
|
358
|
+
c(
|
|
359
|
+
ANSI.yellow,
|
|
360
|
+
`Note: 0 files were in a supported source language (${ANALYZABLE_LANGUAGES_LABEL}) — the readiness score does not reflect this codebase.`,
|
|
361
|
+
),
|
|
362
|
+
);
|
|
363
|
+
}
|
|
364
|
+
// Coverage diagnostics: skipped files mean the finding count may be incomplete.
|
|
365
|
+
const diag = result.diagnostics;
|
|
366
|
+
if (diag && (diag.unreadable > 0 || diag.skippedMinified > 0)) {
|
|
367
|
+
lines.push(
|
|
368
|
+
c(
|
|
369
|
+
ANSI.yellow,
|
|
370
|
+
`Coverage: ${diag.unreadable} unreadable, ${diag.skippedMinified} skipped as minified — results may be incomplete.`,
|
|
371
|
+
),
|
|
372
|
+
);
|
|
373
|
+
}
|
|
348
374
|
lines.push("");
|
|
349
375
|
|
|
350
376
|
// Severity breakdown.
|
package/src/scan-worker.ts
CHANGED
|
@@ -42,6 +42,8 @@ if (parentPort) {
|
|
|
42
42
|
try {
|
|
43
43
|
const findings: Finding[] = [];
|
|
44
44
|
let filesScanned = 0;
|
|
45
|
+
let unreadable = 0;
|
|
46
|
+
let skippedMinified = 0;
|
|
45
47
|
const scannedNames: string[] = [];
|
|
46
48
|
|
|
47
49
|
for (const rel of req.files) {
|
|
@@ -50,9 +52,11 @@ if (parentPort) {
|
|
|
50
52
|
try {
|
|
51
53
|
content = readFileSync(abs, "utf8");
|
|
52
54
|
} catch {
|
|
53
|
-
|
|
55
|
+
unreadable += 1;
|
|
56
|
+
continue;
|
|
54
57
|
}
|
|
55
58
|
if (!toggles.scanMinified && !isManifestFile(rel) && looksMinified(content)) {
|
|
59
|
+
skippedMinified += 1;
|
|
56
60
|
continue;
|
|
57
61
|
}
|
|
58
62
|
filesScanned += 1;
|
|
@@ -75,7 +79,7 @@ if (parentPort) {
|
|
|
75
79
|
port.postMessage({
|
|
76
80
|
index: req.index,
|
|
77
81
|
files: scannedNames,
|
|
78
|
-
result: { findings, filesScanned },
|
|
82
|
+
result: { findings, filesScanned, unreadable, skippedMinified },
|
|
79
83
|
});
|
|
80
84
|
} catch (err) {
|
|
81
85
|
port.postMessage({
|
package/src/scan.ts
CHANGED
|
@@ -13,22 +13,25 @@ import { readFile, stat } from "node:fs/promises";
|
|
|
13
13
|
import * as path from "node:path";
|
|
14
14
|
|
|
15
15
|
import type { Detector, Finding, ScanOptions, ScanResult } from "./types.js";
|
|
16
|
-
import { walkFiles, toPosix, isBinaryPath, looksMinified } from "./walk.js";
|
|
17
|
-
import {
|
|
18
|
-
import {
|
|
19
|
-
import {
|
|
16
|
+
import { walkFiles, toPosix, isBinaryPath, looksMinified, matchesAny } from "./walk.js";
|
|
17
|
+
import { isAnalyzableSource } from "./detect-utils.js";
|
|
18
|
+
import { stripCommentFindings, stripIgnoredFindings } from "./comments.js";
|
|
19
|
+
import { hashContent, loadCache, rulesetFingerprint, saveCache } from "./cache.js";
|
|
20
|
+
import type { CacheEntry } from "./cache.js";
|
|
21
|
+
import { builtinDetectors, defaultRegistry, detectorScope } from "./registry.js";
|
|
20
22
|
import { isManifestFile, scanManifest } from "./dependencies.js";
|
|
21
23
|
import { buildInventory } from "./inventory.js";
|
|
22
24
|
import { AbortError, BudgetExceededError } from "./errors.js";
|
|
23
25
|
import { VERSION } from "./version.js";
|
|
24
26
|
|
|
25
27
|
/**
|
|
26
|
-
* The full set of built-in detectors exposed on the public API.
|
|
27
|
-
*
|
|
28
|
-
*
|
|
29
|
-
*
|
|
28
|
+
* The full set of built-in detectors exposed on the public API. Re-exported
|
|
29
|
+
* from {@link builtinDetectors} in `registry.ts` — the single source of truth
|
|
30
|
+
* the {@link defaultRegistry} is also built from — so the public array and the
|
|
31
|
+
* registry can never drift apart. The manifest scanner is handled separately
|
|
32
|
+
* (it parses JSON rather than running a Detector).
|
|
30
33
|
*/
|
|
31
|
-
export const detectors: Detector[] =
|
|
34
|
+
export const detectors: Detector[] = builtinDetectors;
|
|
32
35
|
|
|
33
36
|
/** Stable comparator: by file, then line, then ruleId. Exported for reuse. */
|
|
34
37
|
export function compareFindings(a: Finding, b: Finding): number {
|
|
@@ -54,7 +57,7 @@ export function detectFile(
|
|
|
54
57
|
toggles: { source: boolean; config: boolean; deps: boolean },
|
|
55
58
|
disabledRules?: readonly string[],
|
|
56
59
|
): Finding[] {
|
|
57
|
-
|
|
60
|
+
let out: Finding[] = [];
|
|
58
61
|
|
|
59
62
|
for (const det of dets) {
|
|
60
63
|
if (!det.appliesTo(file)) continue;
|
|
@@ -63,6 +66,13 @@ export function detectFile(
|
|
|
63
66
|
out.push(...det.detect({ file, content }));
|
|
64
67
|
}
|
|
65
68
|
|
|
69
|
+
// Drop lexical false positives that land inside comments (`// migrated off
|
|
70
|
+
// createECDH()`), then honour inline `// qscan-ignore-line` /
|
|
71
|
+
// `qscan-ignore-next-line` directives. Both run before the dependency scan
|
|
72
|
+
// appends its findings (manifests carry neither).
|
|
73
|
+
out = stripCommentFindings(out, content, file);
|
|
74
|
+
out = stripIgnoredFindings(out, content);
|
|
75
|
+
|
|
66
76
|
if (toggles.deps && isManifestFile(file)) {
|
|
67
77
|
out.push(...scanManifest(file, content));
|
|
68
78
|
}
|
|
@@ -99,7 +109,16 @@ export async function scan(options: ScanOptions): Promise<ScanResult> {
|
|
|
99
109
|
|
|
100
110
|
const findings: Finding[] = [];
|
|
101
111
|
let filesScanned = 0;
|
|
112
|
+
let analyzedFiles = 0;
|
|
102
113
|
let bytesScanned = 0;
|
|
114
|
+
let unreadable = 0;
|
|
115
|
+
let skippedMinified = 0;
|
|
116
|
+
|
|
117
|
+
// Optional content-hash scan cache: unchanged files reuse prior findings.
|
|
118
|
+
const cacheFile = options.cacheFile;
|
|
119
|
+
const ruleset = cacheFile ? rulesetFingerprint(dets, options.disabledRules) : "";
|
|
120
|
+
const cache = cacheFile ? await loadCache(cacheFile, ruleset) : null;
|
|
121
|
+
const nextEntries: Map<string, CacheEntry> | null = cacheFile ? new Map() : null;
|
|
103
122
|
|
|
104
123
|
// Work-budget / cancellation controls (all optional, unlimited when omitted).
|
|
105
124
|
const signal = options.signal;
|
|
@@ -135,12 +154,14 @@ export async function scan(options: ScanOptions): Promise<ScanResult> {
|
|
|
135
154
|
try {
|
|
136
155
|
content = await readFile(absPath, "utf8");
|
|
137
156
|
} catch {
|
|
138
|
-
|
|
157
|
+
unreadable += 1; // permissions / vanished / decode failure — tracked, not silent.
|
|
158
|
+
continue;
|
|
139
159
|
}
|
|
140
160
|
|
|
141
161
|
// Skip machine-minified / generated content (unless explicitly enabled).
|
|
142
162
|
// Manifests are always scanned (their findings are dependency findings).
|
|
143
163
|
if (!scanMinified && !isManifestFile(reportedPath) && looksMinified(content)) {
|
|
164
|
+
skippedMinified += 1;
|
|
144
165
|
continue;
|
|
145
166
|
}
|
|
146
167
|
|
|
@@ -151,21 +172,40 @@ export async function scan(options: ScanOptions): Promise<ScanResult> {
|
|
|
151
172
|
}
|
|
152
173
|
|
|
153
174
|
filesScanned += 1;
|
|
154
|
-
|
|
155
|
-
|
|
175
|
+
if (isAnalyzableSource(reportedPath)) analyzedFiles += 1;
|
|
176
|
+
|
|
177
|
+
// Cache: reuse prior findings when the content hash matches; otherwise scan
|
|
178
|
+
// and record. `nextEntries` becomes the new cache (dropping vanished files).
|
|
179
|
+
let fileFindings: Finding[];
|
|
180
|
+
if (cache && nextEntries) {
|
|
181
|
+
const hash = hashContent(content);
|
|
182
|
+
const hit = cache.get(reportedPath);
|
|
183
|
+
fileFindings =
|
|
184
|
+
hit && hit.hash === hash
|
|
185
|
+
? hit.findings
|
|
186
|
+
: detectFile(
|
|
187
|
+
reportedPath,
|
|
188
|
+
content,
|
|
189
|
+
dets,
|
|
190
|
+
{ source: doSource, config: doConfig, deps: doDeps },
|
|
191
|
+
options.disabledRules,
|
|
192
|
+
);
|
|
193
|
+
nextEntries.set(reportedPath, { hash, findings: fileFindings });
|
|
194
|
+
} else {
|
|
195
|
+
fileFindings = detectFile(
|
|
156
196
|
reportedPath,
|
|
157
197
|
content,
|
|
158
198
|
dets,
|
|
159
|
-
{
|
|
160
|
-
source: doSource,
|
|
161
|
-
config: doConfig,
|
|
162
|
-
deps: doDeps,
|
|
163
|
-
},
|
|
199
|
+
{ source: doSource, config: doConfig, deps: doDeps },
|
|
164
200
|
options.disabledRules,
|
|
165
|
-
)
|
|
166
|
-
|
|
201
|
+
);
|
|
202
|
+
}
|
|
203
|
+
findings.push(...fileFindings);
|
|
167
204
|
}
|
|
168
205
|
|
|
206
|
+
// Persist the cache (best effort) before ordering/returning.
|
|
207
|
+
if (cacheFile && nextEntries) await saveCache(cacheFile, ruleset, nextEntries);
|
|
208
|
+
|
|
169
209
|
// Stable ordering: by file, then line, then ruleId.
|
|
170
210
|
findings.sort(compareFindings);
|
|
171
211
|
|
|
@@ -176,6 +216,8 @@ export async function scan(options: ScanOptions): Promise<ScanResult> {
|
|
|
176
216
|
root: options.root,
|
|
177
217
|
findings,
|
|
178
218
|
filesScanned,
|
|
219
|
+
analyzedFiles,
|
|
220
|
+
diagnostics: { unreadable, skippedMinified },
|
|
179
221
|
inventory,
|
|
180
222
|
startedAt: startedAt.toISOString(),
|
|
181
223
|
finishedAt: finishedAt.toISOString(),
|
|
@@ -221,14 +263,3 @@ export function filterExplicitFileList(
|
|
|
221
263
|
async function* filterExplicitFiles(files: string[], options: ScanOptions): AsyncGenerator<string> {
|
|
222
264
|
for (const rel of filterExplicitFileList(files, options)) yield rel;
|
|
223
265
|
}
|
|
224
|
-
|
|
225
|
-
/** Local substring/prefix matcher (mirrors the walker's pattern semantics). */
|
|
226
|
-
function matchesAny(rel: string, patterns: readonly string[]): boolean {
|
|
227
|
-
for (const pattern of patterns) {
|
|
228
|
-
if (!pattern) continue;
|
|
229
|
-
const p = toPosix(pattern).replace(/\/+$/, "");
|
|
230
|
-
if (rel.includes(p)) return true;
|
|
231
|
-
if (rel === p || rel.startsWith(`${p}/`)) return true;
|
|
232
|
-
}
|
|
233
|
-
return false;
|
|
234
|
-
}
|
package/src/triage.ts
ADDED
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Triage rubric, verdict schema, and request-bundle builder — all offline and
|
|
3
|
+
* deterministic. Both planes share them: `@quantakrypto/agent` (networked)
|
|
4
|
+
* prompts a model with the rubric, while the MCP `triage_findings` tool emits a
|
|
5
|
+
* request bundle for the HOST agent to reason over. Keeping them here means the
|
|
6
|
+
* MCP server never imports the networked package and both sides use one rubric.
|
|
7
|
+
*/
|
|
8
|
+
import type { Finding } from "./types.js";
|
|
9
|
+
import type { ContextLevel, RedactedContext } from "./agent-types.js";
|
|
10
|
+
import { buildContext } from "./redact.js";
|
|
11
|
+
|
|
12
|
+
/** The system rubric that defines what an exposure verdict means. */
|
|
13
|
+
export const TRIAGE_RUBRIC =
|
|
14
|
+
"You are a post-quantum cryptography triage assistant. You are given ONE finding " +
|
|
15
|
+
"of classical (quantum-vulnerable) cryptography detected in a codebase, with " +
|
|
16
|
+
"limited, possibly-redacted context. Assess its REAL-WORLD exposure and urgency. " +
|
|
17
|
+
"exposureScore (0-100): how exploitable/exposed this usage is — a long-lived " +
|
|
18
|
+
"confidentiality key over the network (harvest-now-decrypt-later) scores high; a " +
|
|
19
|
+
"short-lived local signature scores lower. priority: 'now' for HNDL/high-exposure, " +
|
|
20
|
+
"'soon' for important-but-not-urgent, 'later' for low-exposure. You NEVER decide " +
|
|
21
|
+
"whether the finding is valid and you NEVER suppress it — you only rank exposure. " +
|
|
22
|
+
"Base your rationale only on the given context; do not invent facts.";
|
|
23
|
+
|
|
24
|
+
/** JSON Schema every triage verdict must satisfy. */
|
|
25
|
+
export const TRIAGE_VERDICT_SCHEMA: Record<string, unknown> = {
|
|
26
|
+
type: "object",
|
|
27
|
+
required: ["exposureScore", "priority", "rationale"],
|
|
28
|
+
properties: {
|
|
29
|
+
exposureScore: { type: "number", minimum: 0, maximum: 100 },
|
|
30
|
+
priority: { enum: ["now", "soon", "later"] },
|
|
31
|
+
rationale: { type: "string" },
|
|
32
|
+
},
|
|
33
|
+
};
|
|
34
|
+
|
|
35
|
+
/** A triage request bundle: the rubric, the verdict schema, and redacted contexts. */
|
|
36
|
+
export interface TriageRequest {
|
|
37
|
+
rubric: string;
|
|
38
|
+
schema: Record<string, unknown>;
|
|
39
|
+
contexts: RedactedContext[];
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* Build a triage request bundle for a set of findings. Offline: for non-metadata
|
|
44
|
+
* levels, pass `readContent` to supply each finding's file text; without it,
|
|
45
|
+
* contexts are built at `metadata` level (no source), which is the safe default
|
|
46
|
+
* for the MCP plane where the host agent already has the code open.
|
|
47
|
+
*/
|
|
48
|
+
export function buildTriageRequest(
|
|
49
|
+
findings: readonly Finding[],
|
|
50
|
+
level: ContextLevel = "metadata",
|
|
51
|
+
readContent?: (finding: Finding) => string,
|
|
52
|
+
): TriageRequest {
|
|
53
|
+
const effectiveLevel: ContextLevel = readContent ? level : "metadata";
|
|
54
|
+
const contexts = findings.map((f) =>
|
|
55
|
+
buildContext(f, effectiveLevel, readContent ? readContent(f) : ""),
|
|
56
|
+
);
|
|
57
|
+
return { rubric: TRIAGE_RUBRIC, schema: TRIAGE_VERDICT_SCHEMA, contexts };
|
|
58
|
+
}
|
package/src/types.ts
CHANGED
|
@@ -50,6 +50,21 @@ export interface SourceLocation {
|
|
|
50
50
|
snippet?: string;
|
|
51
51
|
}
|
|
52
52
|
|
|
53
|
+
/** Relative urgency an LLM triage pass assigns to a finding. */
|
|
54
|
+
export type TriagePriority = "now" | "soon" | "later";
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* Optional LLM triage annotation attached to a finding by `qscan --triage`.
|
|
58
|
+
* Purely additive: it re-ranks and explains, it never suppresses a finding and
|
|
59
|
+
* never influences the exit code (which is computed from `severity` alone).
|
|
60
|
+
*/
|
|
61
|
+
export interface TriageAnnotation {
|
|
62
|
+
/** 0–100 real-world exposure/exploitability estimate. */
|
|
63
|
+
exposureScore: number;
|
|
64
|
+
priority: TriagePriority;
|
|
65
|
+
rationale: string;
|
|
66
|
+
}
|
|
67
|
+
|
|
53
68
|
/** A single detected concern. */
|
|
54
69
|
export interface Finding {
|
|
55
70
|
/** Stable rule identifier, e.g. "rsa-keygen", "ecdh-usage", "tls-legacy-version", "dep-vulnerable". */
|
|
@@ -74,14 +89,19 @@ export interface Finding {
|
|
|
74
89
|
* snippet for such findings, regardless of any redaction flag.
|
|
75
90
|
*/
|
|
76
91
|
sensitive?: boolean;
|
|
92
|
+
/** Optional LLM triage annotation (`qscan --triage`); never affects exit code. */
|
|
93
|
+
triage?: TriageAnnotation;
|
|
77
94
|
location: SourceLocation;
|
|
78
95
|
}
|
|
79
96
|
|
|
97
|
+
/** Package ecosystems the dependency scanner understands. */
|
|
98
|
+
export type DependencyEcosystem = "npm" | "pypi" | "cargo" | "go" | "maven" | "rubygems";
|
|
99
|
+
|
|
80
100
|
/** A known quantum-vulnerable dependency entry. */
|
|
81
101
|
export interface VulnerableDependency {
|
|
82
|
-
/** Package name. */
|
|
102
|
+
/** Package name (as written in the ecosystem's manifest). */
|
|
83
103
|
name: string;
|
|
84
|
-
ecosystem:
|
|
104
|
+
ecosystem: DependencyEcosystem;
|
|
85
105
|
/** Why it's flagged (what classical crypto it provides). */
|
|
86
106
|
reason: string;
|
|
87
107
|
/** Algorithm families the package primarily exposes. */
|
|
@@ -99,7 +119,16 @@ export type DetectorScope = "source" | "config";
|
|
|
99
119
|
* The programming language / surface a detector targets. `"any"` means the
|
|
100
120
|
* detector is language-agnostic (e.g. PEM material, config files).
|
|
101
121
|
*/
|
|
102
|
-
export type DetectorLanguage =
|
|
122
|
+
export type DetectorLanguage =
|
|
123
|
+
| "js"
|
|
124
|
+
| "python"
|
|
125
|
+
| "go"
|
|
126
|
+
| "java"
|
|
127
|
+
| "csharp"
|
|
128
|
+
| "rust"
|
|
129
|
+
| "ruby"
|
|
130
|
+
| "c"
|
|
131
|
+
| "any";
|
|
103
132
|
|
|
104
133
|
/**
|
|
105
134
|
* Declarative metadata for a single rule a detector can emit. This is the
|
|
@@ -224,6 +253,14 @@ export interface ScanOptions {
|
|
|
224
253
|
* catalog ({@link DetectorRegistry.ruleCatalog}) for the valid ids.
|
|
225
254
|
*/
|
|
226
255
|
disabledRules?: string[];
|
|
256
|
+
/**
|
|
257
|
+
* Path to an on-disk scan cache. When set, unchanged files (same content hash)
|
|
258
|
+
* reuse their previous findings instead of re-running detectors, and the cache
|
|
259
|
+
* is rewritten after the scan. Invalidated wholesale when the tool version,
|
|
260
|
+
* detector set, or `disabledRules` change. Optional; omitted = no caching.
|
|
261
|
+
* The cache forces the in-process (serial) path.
|
|
262
|
+
*/
|
|
263
|
+
cacheFile?: string;
|
|
227
264
|
/** Optional progress callback. */
|
|
228
265
|
onFile?: (file: string) => void;
|
|
229
266
|
/**
|
|
@@ -274,12 +311,38 @@ export interface CryptoInventory {
|
|
|
274
311
|
readinessScore: number;
|
|
275
312
|
}
|
|
276
313
|
|
|
314
|
+
/**
|
|
315
|
+
* Non-fatal things that happened during a scan that reduce coverage. Surfaced so
|
|
316
|
+
* a silent under-scan (e.g. half the tree was unreadable) can't masquerade as a
|
|
317
|
+
* clean "0 findings" result — reporters warn when any count is non-zero.
|
|
318
|
+
*/
|
|
319
|
+
export interface ScanDiagnostics {
|
|
320
|
+
/** Files that could not be read (permissions, vanished, decode failure) and were skipped. */
|
|
321
|
+
unreadable: number;
|
|
322
|
+
/** Files skipped because they look machine-minified / generated (scan with `scanMinified` to include). */
|
|
323
|
+
skippedMinified: number;
|
|
324
|
+
}
|
|
325
|
+
|
|
277
326
|
/** The full result of a scan. */
|
|
278
327
|
export interface ScanResult {
|
|
279
328
|
/** The scan root (as provided). */
|
|
280
329
|
root: string;
|
|
281
330
|
findings: Finding[];
|
|
282
331
|
filesScanned: number;
|
|
332
|
+
/**
|
|
333
|
+
* Coverage diagnostics: counts of files skipped as unreadable or minified.
|
|
334
|
+
* Optional for backward compatibility with hand-built results.
|
|
335
|
+
*/
|
|
336
|
+
diagnostics?: ScanDiagnostics;
|
|
337
|
+
/**
|
|
338
|
+
* Of `filesScanned`, how many were in a source language the scanner can
|
|
339
|
+
* actually analyze for inline crypto (JS/TS, Python, Go, Java). When this is 0 but
|
|
340
|
+
* `filesScanned` > 0, the readiness score reflects NO analyzable code — the
|
|
341
|
+
* crypto likely lives in an unsupported language (Go, Java, Rust, …) — and
|
|
342
|
+
* reporters surface that so a bare 100/100 can't read as "safe". Optional for
|
|
343
|
+
* backward compatibility with hand-built results.
|
|
344
|
+
*/
|
|
345
|
+
analyzedFiles?: number;
|
|
283
346
|
inventory: CryptoInventory;
|
|
284
347
|
/** ISO timestamps. */
|
|
285
348
|
startedAt: string;
|
package/src/verify.ts
ADDED
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Snippet-level fix verification: run the detectors over a piece of code (NOT
|
|
3
|
+
* the filesystem) and report any classical crypto that remains. This is the
|
|
4
|
+
* deterministic gate a fix must pass — the same logic the MCP `verify_fix` tool
|
|
5
|
+
* and the remediation pipeline both use, so they can never disagree on what
|
|
6
|
+
* "the finding is gone" means.
|
|
7
|
+
*/
|
|
8
|
+
import type { Finding } from "./types.js";
|
|
9
|
+
import { detectFile, detectors } from "./scan.js";
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* Map a language name (or a bare extension) to a source extension whose
|
|
13
|
+
* detectors we run. Returns null for languages the scanner does not analyze.
|
|
14
|
+
*/
|
|
15
|
+
export function languageToExtension(language: string): string | null {
|
|
16
|
+
const l = language.trim().toLowerCase().replace(/^\./, "");
|
|
17
|
+
const map: Record<string, string> = {
|
|
18
|
+
js: ".js",
|
|
19
|
+
javascript: ".js",
|
|
20
|
+
jsx: ".jsx",
|
|
21
|
+
ts: ".ts",
|
|
22
|
+
typescript: ".ts",
|
|
23
|
+
tsx: ".tsx",
|
|
24
|
+
mjs: ".mjs",
|
|
25
|
+
cjs: ".cjs",
|
|
26
|
+
py: ".py",
|
|
27
|
+
python: ".py",
|
|
28
|
+
go: ".go",
|
|
29
|
+
golang: ".go",
|
|
30
|
+
java: ".java",
|
|
31
|
+
kotlin: ".kt",
|
|
32
|
+
kt: ".kt",
|
|
33
|
+
cs: ".cs",
|
|
34
|
+
csharp: ".cs",
|
|
35
|
+
"c#": ".cs",
|
|
36
|
+
dotnet: ".cs",
|
|
37
|
+
rs: ".rs",
|
|
38
|
+
rust: ".rs",
|
|
39
|
+
rb: ".rb",
|
|
40
|
+
ruby: ".rb",
|
|
41
|
+
c: ".c",
|
|
42
|
+
"c++": ".cpp",
|
|
43
|
+
cpp: ".cpp",
|
|
44
|
+
cc: ".cc",
|
|
45
|
+
h: ".h",
|
|
46
|
+
hpp: ".hpp",
|
|
47
|
+
};
|
|
48
|
+
return map[l] ?? null;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/** Result of {@link verifyFix}: the findings that remain, and whether the
|
|
52
|
+
* language is one the scanner actually analyzes (so `findings: []` on an
|
|
53
|
+
* unsupported language is NOT a clean verification). */
|
|
54
|
+
export interface VerifyResult {
|
|
55
|
+
supported: boolean;
|
|
56
|
+
findings: Finding[];
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* Run all detectors over `code`, selecting them by `filename` (extension) or
|
|
61
|
+
* `language`. Pure: no I/O. When neither identifies an analyzable language,
|
|
62
|
+
* `supported` is false and the empty findings list must not be read as "fixed".
|
|
63
|
+
*/
|
|
64
|
+
export function verifyFix(
|
|
65
|
+
code: string,
|
|
66
|
+
opts: { filename?: string; language?: string },
|
|
67
|
+
): VerifyResult {
|
|
68
|
+
let name: string;
|
|
69
|
+
if (opts.filename && opts.filename.trim()) {
|
|
70
|
+
name = opts.filename.trim();
|
|
71
|
+
} else if (opts.language && opts.language.trim()) {
|
|
72
|
+
const ext = languageToExtension(opts.language);
|
|
73
|
+
if (!ext) return { supported: false, findings: [] };
|
|
74
|
+
name = `snippet${ext}`;
|
|
75
|
+
} else {
|
|
76
|
+
return { supported: false, findings: [] };
|
|
77
|
+
}
|
|
78
|
+
const dotExt = name.replace(/^.*(\.[^.]+)$/, "$1");
|
|
79
|
+
const supported = languageToExtension(dotExt) !== null;
|
|
80
|
+
const findings = detectFile(name, code, detectors, { source: true, config: true, deps: true });
|
|
81
|
+
return { supported, findings };
|
|
82
|
+
}
|
package/src/version.ts
CHANGED