@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
package/src/detectors/source.ts
CHANGED
|
@@ -29,6 +29,7 @@
|
|
|
29
29
|
import type { Detector, Finding, RuleMeta } from "../types.js";
|
|
30
30
|
import {
|
|
31
31
|
JS_TS_EXTENSIONS,
|
|
32
|
+
JWT_HOST_EXTENSIONS,
|
|
32
33
|
eachMatch,
|
|
33
34
|
findingFromRule,
|
|
34
35
|
hasExtension,
|
|
@@ -40,31 +41,41 @@ import { CWE_BROKEN_CRYPTO, CWE_CERT_VALIDATION, CWE_WEAK_STRENGTH } from "../cw
|
|
|
40
41
|
/* Precompiled regexes (module scope — never recreated per file) */
|
|
41
42
|
/* -------------------------------------------------------------------------- */
|
|
42
43
|
|
|
44
|
+
// `rsa-pss` is listed before `rsa` so the alternation consumes the full token
|
|
45
|
+
// (ordered alternation would otherwise match `rsa` and reject the `-pss` tail).
|
|
43
46
|
const RE_GENERATE_KEYPAIR =
|
|
44
|
-
/generateKeyPair(?:Sync)?\s*\(\s*['"`](rsa|ec|dsa|dh|x25519|x448|ed25519|ed448)['"`]/g;
|
|
47
|
+
/generateKeyPair(?:Sync)?\s*\(\s*['"`](rsa-pss|rsa|ec|dsa|dh|x25519|x448|ed25519|ed448)['"`]/g;
|
|
45
48
|
const RE_CREATE_SIGN_VERIFY = /create(?:Sign|Verify)\s*\(/g;
|
|
46
|
-
// One-shot crypto.sign/verify(algorithm, data, key).
|
|
47
|
-
// fire inside identifiers like `assign(`
|
|
48
|
-
// createSign
|
|
49
|
-
//
|
|
50
|
-
//
|
|
49
|
+
// One-shot crypto.sign/verify(algorithm, data, key). A LOOKBEHIND (not a
|
|
50
|
+
// consumed char) anchors it so it doesn't fire inside identifiers like `assign(`
|
|
51
|
+
// or `createSign(` (handled by the dedicated createSign/createVerify rule) —
|
|
52
|
+
// consuming the preceding char used to push the match onto the previous line,
|
|
53
|
+
// corrupting the reported line/snippet and SARIF/baseline fingerprints. The
|
|
54
|
+
// first argument is either a quoted digest-algorithm string (RSA/ECDSA) or
|
|
55
|
+
// `null` — Node's EdDSA one-shot form is `crypto.sign(null, data, edKey)`.
|
|
51
56
|
const RE_ONESHOT_SIGN_VERIFY =
|
|
52
|
-
/(
|
|
57
|
+
/(?<![.\w])(?:crypto\.)?(sign|verify)\s*\(\s*(?:['"`][\w.-]+['"`]|null)\s*,/g;
|
|
53
58
|
const RE_CREATE_DH = /createDiffieHellman(?:Group)?\s*\(/g;
|
|
54
59
|
const RE_GET_DH = /getDiffieHellman\s*\(\s*['"`](modp\d+)['"`]\s*\)/g;
|
|
55
60
|
const RE_CREATE_ECDH = /createECDH\s*\(/g;
|
|
56
61
|
const RE_RSA_ENCRYPT = /(?:crypto\.)?(?:publicEncrypt|privateDecrypt)\s*\(/g;
|
|
57
62
|
const RE_DH_KEYOBJECT = /(?:crypto\.)?diffieHellman\s*\(\s*\{/g;
|
|
58
63
|
|
|
59
|
-
// WebCrypto.
|
|
60
|
-
|
|
64
|
+
// WebCrypto. Includes the newer curve algorithms (Ed25519/Ed448 signatures,
|
|
65
|
+
// X25519/X448 key agreement) shipping in modern SubtleCrypto implementations.
|
|
66
|
+
const RE_WEBCRYPTO_ALGO =
|
|
67
|
+
/\b(RSA-OAEP|RSA-PSS|RSASSA-PKCS1-v1_5|ECDH|ECDSA|Ed25519|Ed448|X25519|X448)\b/gi;
|
|
61
68
|
const RE_SUBTLE_CALL =
|
|
62
69
|
/subtle\s*\.\s*(generateKey|importKey|exportKey|deriveKey|deriveBits|sign|verify|encrypt|decrypt|wrapKey|unwrapKey)\s*\(/g;
|
|
63
70
|
|
|
64
71
|
// Libraries.
|
|
65
72
|
const RE_FORGE_RSA = /pki\.rsa\.generateKeyPair\s*\(/g;
|
|
66
73
|
const RE_FORGE_ED25519 = /forge\.ed25519\b/g;
|
|
67
|
-
|
|
74
|
+
// Require a curve-like first argument so `new EC("request-scope")` (a non-crypto
|
|
75
|
+
// `EC` class) is not flagged; the elliptic library is always constructed with a
|
|
76
|
+
// named curve (`new EC('secp256k1')`, `new EC('p256')`, …).
|
|
77
|
+
const RE_ELLIPTIC_EC =
|
|
78
|
+
/new\s+(?:elliptic\.)?ec\s*\(\s*['"`](?:sec[pt]|prime|nistp|curve|ed25519|ed448|brainpool|p-?(?:192|224|256|384|521)|x25519|x448)/gi;
|
|
68
79
|
const RE_JSRSASIGN_KEYGEN = /KEYUTIL\.generateKeypair\s*\(/g;
|
|
69
80
|
const RE_JSRSASIGN_SIGN = /KJUR\.crypto\.(?:Signature|ECDSA)\b/g;
|
|
70
81
|
const RE_NODE_RSA = /new\s+NodeRSA\s*\(/g;
|
|
@@ -237,6 +248,18 @@ const nodeCryptoDetector: Detector = {
|
|
|
237
248
|
}
|
|
238
249
|
> = {
|
|
239
250
|
rsa: { algo: "RSA", cat: "kem", sev: "high", hndl: true, label: "RSA" },
|
|
251
|
+
// RSA-PSS is signature-only, so classify it as a (forgeable) signature
|
|
252
|
+
// rather than a KEM — no HNDL confidentiality exposure.
|
|
253
|
+
"rsa-pss": {
|
|
254
|
+
algo: "RSA",
|
|
255
|
+
cat: "signature",
|
|
256
|
+
sev: "high",
|
|
257
|
+
hndl: false,
|
|
258
|
+
label: "RSA-PSS",
|
|
259
|
+
message:
|
|
260
|
+
"Generates a classical RSA-PSS signing key, which is forgeable by a quantum attacker.",
|
|
261
|
+
remediation: "ML-DSA-65 (FIPS 204) or SLH-DSA (FIPS 205)",
|
|
262
|
+
},
|
|
240
263
|
// EC keys feed BOTH ECDSA (sign) and ECDH (key agreement). ECDH is
|
|
241
264
|
// HNDL-exposed, so classify conservatively as key-exchange-capable and
|
|
242
265
|
// surface both concerns rather than asserting signature-only (P0-4).
|
|
@@ -406,15 +429,36 @@ const webCryptoDetector: Detector = {
|
|
|
406
429
|
eachMatch(RE_WEBCRYPTO_ALGO, content, (m) => {
|
|
407
430
|
if (!nearSortedCall(callIndexes, m.index, 400)) return;
|
|
408
431
|
const name = m[1].toUpperCase();
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
|
|
432
|
+
// Classify by algorithm: RSA-OAEP is KEM (HNDL); ECDH/X25519/X448 are key
|
|
433
|
+
// agreement (HNDL); RSA-PSS/RSASSA/ECDSA/Ed25519/Ed448 are signatures.
|
|
434
|
+
let algorithm: Finding["algorithm"];
|
|
435
|
+
let category: Finding["category"];
|
|
436
|
+
let hndl: boolean;
|
|
437
|
+
let severity: Finding["severity"] | undefined;
|
|
438
|
+
if (name.startsWith("RSA")) {
|
|
439
|
+
algorithm = "RSA";
|
|
440
|
+
const isKem = name === "RSA-OAEP";
|
|
441
|
+
category = isKem ? "kem" : "signature";
|
|
442
|
+
hndl = isKem;
|
|
443
|
+
} else if (name === "ECDH") {
|
|
444
|
+
algorithm = "ECDH";
|
|
445
|
+
category = "key-exchange";
|
|
446
|
+
hndl = true;
|
|
447
|
+
} else if (name === "X25519" || name === "X448") {
|
|
448
|
+
algorithm = name === "X448" ? "X448" : "X25519";
|
|
449
|
+
category = "key-exchange";
|
|
450
|
+
hndl = true;
|
|
451
|
+
severity = "low"; // modern but classical
|
|
452
|
+
} else if (name === "ED25519" || name === "ED448") {
|
|
453
|
+
algorithm = "EdDSA";
|
|
454
|
+
category = "signature";
|
|
455
|
+
hndl = false;
|
|
456
|
+
severity = "low";
|
|
457
|
+
} else {
|
|
458
|
+
algorithm = "ECDSA";
|
|
459
|
+
category = "signature";
|
|
460
|
+
hndl = false;
|
|
461
|
+
}
|
|
418
462
|
findings.push(
|
|
419
463
|
findingFromRule(
|
|
420
464
|
RULE_WEBCRYPTO,
|
|
@@ -424,6 +468,7 @@ const webCryptoDetector: Detector = {
|
|
|
424
468
|
category,
|
|
425
469
|
algorithm,
|
|
426
470
|
hndl,
|
|
471
|
+
...(severity ? { severity } : {}),
|
|
427
472
|
message: `WebCrypto algorithm "${m[1]}" is classical asymmetric crypto and not quantum-safe.`,
|
|
428
473
|
},
|
|
429
474
|
),
|
|
@@ -607,9 +652,12 @@ const jwtDetector: Detector = {
|
|
|
607
652
|
id: "jwt-jose",
|
|
608
653
|
description: "Classical JWT/JOSE algorithms (RS/PS/ES/EdDSA) and ECDH-ES key agreement",
|
|
609
654
|
scope: "source",
|
|
610
|
-
|
|
655
|
+
// Language-agnostic evidence: a quoted "RS256"/"ES256" alg token is the same
|
|
656
|
+
// signal in JS/TS or Python (e.g. PyJWT `algorithm="RS256"`), so this detector
|
|
657
|
+
// is un-gated from JS-only to the JWT host surfaces.
|
|
658
|
+
language: "any",
|
|
611
659
|
rules: [RULE_JWT_ALG, RULE_JOSE_ECDH],
|
|
612
|
-
appliesTo: (f) => hasExtension(f,
|
|
660
|
+
appliesTo: (f) => hasExtension(f, JWT_HOST_EXTENSIONS),
|
|
613
661
|
detect({ file, content }): Finding[] {
|
|
614
662
|
const findings: Finding[] = [];
|
|
615
663
|
|
package/src/index.ts
CHANGED
|
@@ -18,6 +18,41 @@ export type { SarifLog } from "./report.js";
|
|
|
18
18
|
// Core orchestration + built-in detector set.
|
|
19
19
|
export { scan, detectors, detectFile, compareFindings } from "./scan.js";
|
|
20
20
|
|
|
21
|
+
// Snippet-level fix verification (shared by MCP verify_fix + remediation).
|
|
22
|
+
export { verifyFix, languageToExtension } from "./verify.js";
|
|
23
|
+
export type { VerifyResult } from "./verify.js";
|
|
24
|
+
|
|
25
|
+
// Agent-plane shared types + the context redactor (offline; reused by MCP).
|
|
26
|
+
export type {
|
|
27
|
+
ContextLevel,
|
|
28
|
+
RedactedContext,
|
|
29
|
+
TriageVerdict,
|
|
30
|
+
Patch,
|
|
31
|
+
FixProposal,
|
|
32
|
+
} from "./agent-types.js";
|
|
33
|
+
export { buildContext, renderPreflight } from "./redact.js";
|
|
34
|
+
export { TRIAGE_RUBRIC, TRIAGE_VERDICT_SCHEMA, buildTriageRequest } from "./triage.js";
|
|
35
|
+
export type { TriageRequest } from "./triage.js";
|
|
36
|
+
export {
|
|
37
|
+
REMEDIATE_RUBRIC,
|
|
38
|
+
FIX_REQUEST_SCHEMA,
|
|
39
|
+
buildRemediateRequest,
|
|
40
|
+
} from "./remediate-request.js";
|
|
41
|
+
export type { RemediateRequest } from "./remediate-request.js";
|
|
42
|
+
export { checkPatchPolicy } from "./patch-policy.js";
|
|
43
|
+
export type { PolicyContext, PolicyDecision } from "./patch-policy.js";
|
|
44
|
+
export { withWorktree } from "./worktree.js";
|
|
45
|
+
export { codemodRegistry, codemodFor } from "./codemods/registry.js";
|
|
46
|
+
export type { Codemod } from "./codemods/registry.js";
|
|
47
|
+
export { configToggleCodemod } from "./codemods/config-toggle.js";
|
|
48
|
+
export { remediateFindings } from "./remediate-pipeline.js";
|
|
49
|
+
export type {
|
|
50
|
+
RemediateOptions,
|
|
51
|
+
RemediationResult,
|
|
52
|
+
VerifiedPatch,
|
|
53
|
+
RejectedPatch,
|
|
54
|
+
} from "./remediate-pipeline.js";
|
|
55
|
+
|
|
21
56
|
// Scan cancellation / work-budget errors.
|
|
22
57
|
export { AbortError, BudgetExceededError } from "./errors.js";
|
|
23
58
|
|
|
@@ -50,11 +85,20 @@ export type { QuantakryptoFileConfig, LoadConfigResult } from "./config.js";
|
|
|
50
85
|
// Filesystem walker (relative POSIX paths, default ignores, size/binary filters).
|
|
51
86
|
export { walkFiles, isBinaryPath, isGeneratedPath, looksMinified } from "./walk.js";
|
|
52
87
|
|
|
88
|
+
// Analyzable-language coverage (which source languages the scanner inspects).
|
|
89
|
+
export {
|
|
90
|
+
isAnalyzableSource,
|
|
91
|
+
ANALYZABLE_SOURCE_EXTENSIONS,
|
|
92
|
+
ANALYZABLE_LANGUAGES_LABEL,
|
|
93
|
+
} from "./detect-utils.js";
|
|
94
|
+
|
|
53
95
|
// Inventory + readiness score.
|
|
54
96
|
export { buildInventory } from "./inventory.js";
|
|
55
97
|
|
|
56
98
|
// Vulnerable-dependency database (the manifest scanner is used internally by scan()).
|
|
57
|
-
|
|
99
|
+
// `DEP_VULNERABLE_RULE` is the generic catalog entry for dependency findings
|
|
100
|
+
// (which don't come from a Detector, so aren't in the registry's rule catalog).
|
|
101
|
+
export { vulnerableDependencies, DEP_VULNERABLE_RULE, isManifestFile } from "./dependencies.js";
|
|
58
102
|
|
|
59
103
|
// Severity utilities (ordering, threshold, SARIF level) — shared across tools.
|
|
60
104
|
export { SEVERITY_ORDER, severityRank, meetsThreshold, sarifLevel } from "./severity.js";
|
package/src/parallel.ts
CHANGED
|
@@ -18,8 +18,10 @@ import type { Worker as NodeWorker } from "node:worker_threads";
|
|
|
18
18
|
|
|
19
19
|
import type { Finding, ParallelScanOptions, ScanResult } from "./types.js";
|
|
20
20
|
import { walkFiles } from "./walk.js";
|
|
21
|
+
import { isAnalyzableSource } from "./detect-utils.js";
|
|
21
22
|
import { buildInventory } from "./inventory.js";
|
|
22
23
|
import { compareFindings, filterExplicitFileList, scan } from "./scan.js";
|
|
24
|
+
import { AbortError, BudgetExceededError } from "./errors.js";
|
|
23
25
|
import { VERSION } from "./version.js";
|
|
24
26
|
|
|
25
27
|
/** One unit of work dispatched to a worker. */
|
|
@@ -31,6 +33,10 @@ export interface ScanChunk {
|
|
|
31
33
|
export interface ChunkResult {
|
|
32
34
|
findings: Finding[];
|
|
33
35
|
filesScanned: number;
|
|
36
|
+
/** Files that couldn't be read in this chunk (optional; older workers omit it). */
|
|
37
|
+
unreadable?: number;
|
|
38
|
+
/** Files skipped as minified in this chunk (optional; older workers omit it). */
|
|
39
|
+
skippedMinified?: number;
|
|
34
40
|
}
|
|
35
41
|
|
|
36
42
|
const DEFAULT_PARALLEL_THRESHOLD_BYTES = 2 * 1024 * 1024;
|
|
@@ -74,12 +80,16 @@ export function chunkByBytes(files: readonly SizedFile[], chunkBytes: number): S
|
|
|
74
80
|
export function mergeChunkResults(results: readonly ChunkResult[]): ChunkResult {
|
|
75
81
|
const findings: Finding[] = [];
|
|
76
82
|
let filesScanned = 0;
|
|
83
|
+
let unreadable = 0;
|
|
84
|
+
let skippedMinified = 0;
|
|
77
85
|
for (const r of results) {
|
|
78
86
|
for (const f of r.findings) findings.push(f);
|
|
79
87
|
filesScanned += r.filesScanned;
|
|
88
|
+
unreadable += r.unreadable ?? 0;
|
|
89
|
+
skippedMinified += r.skippedMinified ?? 0;
|
|
80
90
|
}
|
|
81
91
|
findings.sort(compareFindings);
|
|
82
|
-
return { findings, filesScanned };
|
|
92
|
+
return { findings, filesScanned, unreadable, skippedMinified };
|
|
83
93
|
}
|
|
84
94
|
|
|
85
95
|
/** Resolve the worker count (>= 1). */
|
|
@@ -162,14 +172,29 @@ export async function scanParallel(options: ParallelScanOptions): Promise<ScanRe
|
|
|
162
172
|
const rootStat = await stat(options.root);
|
|
163
173
|
const baseDir = rootStat.isFile() ? path.dirname(options.root) : options.root;
|
|
164
174
|
|
|
165
|
-
// Single-file roots
|
|
166
|
-
// detectors may not be structured-cloneable across the worker
|
|
167
|
-
|
|
175
|
+
// Single-file roots, the override-detectors path, and the scan cache always
|
|
176
|
+
// run serially: detectors may not be structured-cloneable across the worker
|
|
177
|
+
// boundary, and the cache read/write is owned by the in-process `scan()`.
|
|
178
|
+
if (rootStat.isFile() || options.detectors || options.cacheFile) {
|
|
168
179
|
return scan(options);
|
|
169
180
|
}
|
|
170
181
|
|
|
171
182
|
const files = await enumerateFiles(options, baseDir);
|
|
172
183
|
|
|
184
|
+
// Budget + cancellation parity with the serial path. The parallel path
|
|
185
|
+
// enumerates upfront, so budgets are enforced against the whole file set
|
|
186
|
+
// before any worker is dispatched.
|
|
187
|
+
if (options.signal?.aborted) throw new AbortError();
|
|
188
|
+
if (typeof options.maxFiles === "number" && files.length > options.maxFiles) {
|
|
189
|
+
throw new BudgetExceededError(`maxFiles budget exceeded (limit: ${options.maxFiles}).`);
|
|
190
|
+
}
|
|
191
|
+
if (typeof options.maxBytes === "number") {
|
|
192
|
+
const totalBytes = files.reduce((n, f) => n + f.size, 0);
|
|
193
|
+
if (totalBytes > options.maxBytes) {
|
|
194
|
+
throw new BudgetExceededError(`maxBytes budget exceeded (limit: ${options.maxBytes}).`);
|
|
195
|
+
}
|
|
196
|
+
}
|
|
197
|
+
|
|
173
198
|
if (!shouldParallelize(options, files)) {
|
|
174
199
|
// In-process: reuse the exact serial path over the same file list.
|
|
175
200
|
return scan({ ...options, files: files.map((f) => f.rel) });
|
|
@@ -206,9 +231,12 @@ export async function scanParallel(options: ParallelScanOptions): Promise<ScanRe
|
|
|
206
231
|
chunks,
|
|
207
232
|
concurrency,
|
|
208
233
|
options.onFile,
|
|
234
|
+
options.signal,
|
|
209
235
|
);
|
|
210
|
-
} catch {
|
|
211
|
-
//
|
|
236
|
+
} catch (err) {
|
|
237
|
+
// Cancellation / budget overflow must propagate, not silently degrade.
|
|
238
|
+
if (err instanceof AbortError || err instanceof BudgetExceededError) throw err;
|
|
239
|
+
// Any other worker failure → safe fallback to the serial path.
|
|
212
240
|
return scan({ ...options, files: files.map((f) => f.rel) });
|
|
213
241
|
}
|
|
214
242
|
|
|
@@ -216,10 +244,21 @@ export async function scanParallel(options: ParallelScanOptions): Promise<ScanRe
|
|
|
216
244
|
const inventory = buildInventory(merged.findings);
|
|
217
245
|
const finishedAt = new Date();
|
|
218
246
|
|
|
247
|
+
// Coverage: count analyzable-source files in the enumerated set. Computed from
|
|
248
|
+
// the file list (not per-worker) to avoid worker-boundary plumbing; on the
|
|
249
|
+
// parallel path this can include a minified analyzable file the workers
|
|
250
|
+
// skipped, but that is vanishingly rare and never under-reports coverage.
|
|
251
|
+
const analyzedFiles = files.reduce((n, f) => (isAnalyzableSource(f.rel) ? n + 1 : n), 0);
|
|
252
|
+
|
|
219
253
|
return {
|
|
220
254
|
root: options.root,
|
|
221
255
|
findings: merged.findings,
|
|
222
256
|
filesScanned: merged.filesScanned,
|
|
257
|
+
analyzedFiles,
|
|
258
|
+
diagnostics: {
|
|
259
|
+
unreadable: merged.unreadable ?? 0,
|
|
260
|
+
skippedMinified: merged.skippedMinified ?? 0,
|
|
261
|
+
},
|
|
223
262
|
inventory,
|
|
224
263
|
startedAt: startedAt.toISOString(),
|
|
225
264
|
finishedAt: finishedAt.toISOString(),
|
|
@@ -243,6 +282,7 @@ function runPool(
|
|
|
243
282
|
chunks: ScanChunk[],
|
|
244
283
|
concurrency: number,
|
|
245
284
|
onFile?: (file: string) => void,
|
|
285
|
+
signal?: AbortSignal,
|
|
246
286
|
): Promise<ChunkResult[]> {
|
|
247
287
|
return new Promise((resolve, reject) => {
|
|
248
288
|
const results: ChunkResult[] = new Array(chunks.length);
|
|
@@ -251,10 +291,27 @@ function runPool(
|
|
|
251
291
|
let failed = false;
|
|
252
292
|
const workers: Array<NodeWorker> = [];
|
|
253
293
|
|
|
294
|
+
const onAbort = (): void => {
|
|
295
|
+
if (failed) return;
|
|
296
|
+
failed = true;
|
|
297
|
+
cleanup();
|
|
298
|
+
reject(new AbortError());
|
|
299
|
+
};
|
|
300
|
+
|
|
254
301
|
const cleanup = () => {
|
|
302
|
+
if (signal) signal.removeEventListener("abort", onAbort);
|
|
255
303
|
for (const w of workers) void w.terminate();
|
|
256
304
|
};
|
|
257
305
|
|
|
306
|
+
// Cooperative cancellation: stop dispatching and tear down on abort.
|
|
307
|
+
if (signal) {
|
|
308
|
+
if (signal.aborted) {
|
|
309
|
+
reject(new AbortError());
|
|
310
|
+
return;
|
|
311
|
+
}
|
|
312
|
+
signal.addEventListener("abort", onAbort, { once: true });
|
|
313
|
+
}
|
|
314
|
+
|
|
258
315
|
const dispatch = (w: NodeWorker): void => {
|
|
259
316
|
if (failed) return;
|
|
260
317
|
if (next >= chunks.length) {
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Patch-policy allowlist. A remediation patch may only edit a file that already
|
|
3
|
+
* has a finding, or add dependencies to a manifest. Everything else — CI config,
|
|
4
|
+
* lockfiles, env/secret files, unrelated source — is denied. This is the second
|
|
5
|
+
* safety gate (the first is `verify_fix`): even a "verified" patch is dropped if
|
|
6
|
+
* it strays outside the sanctioned surface.
|
|
7
|
+
*/
|
|
8
|
+
import type { Patch } from "./agent-types.js";
|
|
9
|
+
|
|
10
|
+
/** Files a remediation may write to. */
|
|
11
|
+
export interface PolicyContext {
|
|
12
|
+
/** Relative paths (posix) that contain at least one finding. */
|
|
13
|
+
findingFiles: Set<string>;
|
|
14
|
+
/** Relative manifest paths (package.json, requirements.txt, …). */
|
|
15
|
+
manifestFiles: Set<string>;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* Paths a patch may NEVER touch, regardless of anything else: version control,
|
|
20
|
+
* CI, dependency lockfiles, and anything that looks like a secret/env file.
|
|
21
|
+
*/
|
|
22
|
+
const DENY_RE =
|
|
23
|
+
/(^|\/)(\.github|\.git|node_modules)\/|(^|\/)(package-lock\.json|yarn\.lock|pnpm-lock\.yaml|Cargo\.lock|Gemfile\.lock|poetry\.lock)$|(^|\/)\.env(\.[^/]*)?$|\.(pem|key|p12|pfx)$/i;
|
|
24
|
+
|
|
25
|
+
export interface PolicyDecision {
|
|
26
|
+
allowed: boolean;
|
|
27
|
+
reason?: string;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/** Decide whether `patch` may be applied under `ctx`. */
|
|
31
|
+
export function checkPatchPolicy(patch: Patch, ctx: PolicyContext): PolicyDecision {
|
|
32
|
+
const p = patch.path.replace(/\\/g, "/");
|
|
33
|
+
if (DENY_RE.test(p)) {
|
|
34
|
+
return { allowed: false, reason: `patch touches a protected path (${p})` };
|
|
35
|
+
}
|
|
36
|
+
if (ctx.findingFiles.has(p)) return { allowed: true };
|
|
37
|
+
if (ctx.manifestFiles.has(p)) return { allowed: true };
|
|
38
|
+
return {
|
|
39
|
+
allowed: false,
|
|
40
|
+
reason: `patch edits ${p}, which has no finding and is not a dependency manifest`,
|
|
41
|
+
};
|
|
42
|
+
}
|
package/src/redact.ts
ADDED
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Context redactor: turn a finding + its file into the payload the LLM sees,
|
|
3
|
+
* bounded to a chosen {@link ContextLevel} and with secrets ALWAYS stripped.
|
|
4
|
+
*
|
|
5
|
+
* This is a hard privacy boundary and lives in `@quantakrypto/core` (offline) so
|
|
6
|
+
* the deterministic MCP plane redacts identically to the networked CLI. A
|
|
7
|
+
* `sensitive` finding (the match IS key material) never emits code at any level.
|
|
8
|
+
*/
|
|
9
|
+
import type { Finding } from "./types.js";
|
|
10
|
+
import type { ContextLevel, RedactedContext } from "./agent-types.js";
|
|
11
|
+
|
|
12
|
+
/** Lines of context on each side of the match at `snippet` level. */
|
|
13
|
+
const SNIPPET_RADIUS = 8;
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* PEM blocks and long unbroken base64 runs (≥120 chars) are treated as secret
|
|
17
|
+
* material and masked, even inside otherwise-shareable code.
|
|
18
|
+
*/
|
|
19
|
+
const SECRET_RE =
|
|
20
|
+
/-----BEGIN [A-Z0-9 ]+-----[\s\S]*?-----END [A-Z0-9 ]+-----|[A-Za-z0-9+/]{120,}={0,2}/g;
|
|
21
|
+
|
|
22
|
+
function stripSecrets(text: string): { text: string; redacted: boolean } {
|
|
23
|
+
let redacted = false;
|
|
24
|
+
const out = text.replace(SECRET_RE, () => {
|
|
25
|
+
redacted = true;
|
|
26
|
+
return "«redacted-secret»";
|
|
27
|
+
});
|
|
28
|
+
return { text: out, redacted };
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/** Best-effort enclosing brace/colon block around a 0-based line index. */
|
|
32
|
+
function enclosingBlock(lines: string[], idx: number): string {
|
|
33
|
+
let start = idx;
|
|
34
|
+
while (start > 0 && !/[{:]\s*$/.test(lines[start - 1] ?? "")) start--;
|
|
35
|
+
let end = idx;
|
|
36
|
+
let depth = 0;
|
|
37
|
+
for (let i = start; i < lines.length; i++) {
|
|
38
|
+
const line = lines[i] ?? "";
|
|
39
|
+
depth += (line.match(/{/g) ?? []).length - (line.match(/}/g) ?? []).length;
|
|
40
|
+
end = i;
|
|
41
|
+
if (i > idx && depth <= 0) break;
|
|
42
|
+
}
|
|
43
|
+
return lines.slice(start, end + 1).join("\n");
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* Build the redacted context for `finding` at `level`. `fileContent` is the full
|
|
48
|
+
* text of the file the finding lives in (unused at `metadata` level). Secrets
|
|
49
|
+
* are always removed; a `sensitive` finding yields `code: null`.
|
|
50
|
+
*/
|
|
51
|
+
export function buildContext(
|
|
52
|
+
finding: Finding,
|
|
53
|
+
level: ContextLevel,
|
|
54
|
+
fileContent: string,
|
|
55
|
+
): RedactedContext {
|
|
56
|
+
const meta = {
|
|
57
|
+
ruleId: finding.ruleId,
|
|
58
|
+
algorithm: finding.algorithm,
|
|
59
|
+
severity: finding.severity,
|
|
60
|
+
hndl: finding.hndl,
|
|
61
|
+
file: finding.location.file,
|
|
62
|
+
line: finding.location.line,
|
|
63
|
+
message: finding.message,
|
|
64
|
+
};
|
|
65
|
+
// Sensitive findings never emit code, at any level.
|
|
66
|
+
if (finding.sensitive) return { level, meta, code: null, redactedSecret: true };
|
|
67
|
+
if (level === "metadata") return { level, meta, code: null, redactedSecret: false };
|
|
68
|
+
|
|
69
|
+
const lines = fileContent.split("\n");
|
|
70
|
+
let code: string;
|
|
71
|
+
if (level === "file") {
|
|
72
|
+
code = fileContent;
|
|
73
|
+
} else if (level === "function") {
|
|
74
|
+
code = enclosingBlock(lines, finding.location.line - 1);
|
|
75
|
+
} else {
|
|
76
|
+
const i = finding.location.line - 1;
|
|
77
|
+
code = lines.slice(Math.max(0, i - SNIPPET_RADIUS), i + SNIPPET_RADIUS + 1).join("\n");
|
|
78
|
+
}
|
|
79
|
+
const { text, redacted } = stripSecrets(code);
|
|
80
|
+
return { level, meta, code: text, redactedSecret: redacted };
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/** Render the exact payload text a `--dry-run` preflight would send. */
|
|
84
|
+
export function renderPreflight(contexts: RedactedContext[]): string {
|
|
85
|
+
return contexts
|
|
86
|
+
.map((c) => {
|
|
87
|
+
const flags = `level=${c.level}${c.redactedSecret ? ", secret-redacted" : ""}`;
|
|
88
|
+
const head = `[${c.meta.severity}] ${c.meta.ruleId} ${c.meta.file}:${c.meta.line} (${flags})`;
|
|
89
|
+
return c.code ? `${head}\n${c.code}` : head;
|
|
90
|
+
})
|
|
91
|
+
.join("\n\n---\n\n");
|
|
92
|
+
}
|
package/src/registry.ts
CHANGED
|
@@ -12,6 +12,13 @@
|
|
|
12
12
|
*/
|
|
13
13
|
import type { Detector, DetectorScope, RuleMeta } from "./types.js";
|
|
14
14
|
import { sourceDetectors } from "./detectors/source.js";
|
|
15
|
+
import { pythonDetector } from "./detectors/python.js";
|
|
16
|
+
import { goDetector } from "./detectors/go.js";
|
|
17
|
+
import { javaDetector } from "./detectors/java.js";
|
|
18
|
+
import { csharpDetector } from "./detectors/csharp.js";
|
|
19
|
+
import { rustDetector } from "./detectors/rust.js";
|
|
20
|
+
import { rubyDetector } from "./detectors/ruby.js";
|
|
21
|
+
import { cDetector } from "./detectors/c.js";
|
|
15
22
|
import { pemDetector } from "./detectors/pem.js";
|
|
16
23
|
|
|
17
24
|
/** Normalised scope of a detector (defaults to "source" when undeclared). */
|
|
@@ -102,8 +109,29 @@ export class DetectorRegistry {
|
|
|
102
109
|
}
|
|
103
110
|
|
|
104
111
|
/**
|
|
105
|
-
* The
|
|
106
|
-
* the
|
|
107
|
-
* The manifest (dependency) scanner is
|
|
112
|
+
* The built-in detectors, in run order: the JS/TS source + config detectors,
|
|
113
|
+
* then the per-language detectors (Python, Go, Java, C#, Rust, Ruby, C/C++),
|
|
114
|
+
* then the language-agnostic PEM detector. The manifest (dependency) scanner is
|
|
115
|
+
* handled separately by `scan()`.
|
|
116
|
+
*
|
|
117
|
+
* This is the single source of truth for the default detector set: both
|
|
118
|
+
* {@link defaultRegistry} and the public `detectors` export (re-exported from
|
|
119
|
+
* `scan.ts`) are built from it, so the two can never drift out of sync.
|
|
120
|
+
*/
|
|
121
|
+
export const builtinDetectors: Detector[] = [
|
|
122
|
+
...sourceDetectors,
|
|
123
|
+
pythonDetector,
|
|
124
|
+
goDetector,
|
|
125
|
+
javaDetector,
|
|
126
|
+
csharpDetector,
|
|
127
|
+
rustDetector,
|
|
128
|
+
rubyDetector,
|
|
129
|
+
cDetector,
|
|
130
|
+
pemDetector,
|
|
131
|
+
];
|
|
132
|
+
|
|
133
|
+
/**
|
|
134
|
+
* The default registry, preloaded with {@link builtinDetectors}. Used by
|
|
135
|
+
* `scan()` whenever `options.detectors` is not supplied.
|
|
108
136
|
*/
|
|
109
|
-
export const defaultRegistry = new DetectorRegistry(
|
|
137
|
+
export const defaultRegistry = new DetectorRegistry(builtinDetectors);
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The remediation pipeline: turn candidate patches (from codemods or the LLM)
|
|
3
|
+
* into VERIFIED, in-policy patches. Pure and deterministic — the verify gate
|
|
4
|
+
* runs `verifyFix` on the patched content in memory, so this needs no
|
|
5
|
+
* filesystem or git. Callers (the `qremediate` CLI) decide what to DO with the
|
|
6
|
+
* verified patches (print a diff, write them, or open a PR in a worktree).
|
|
7
|
+
*
|
|
8
|
+
* Two gates, both must pass:
|
|
9
|
+
* 1. patch-policy — the patch may only touch sanctioned files.
|
|
10
|
+
* 2. verify_fix — the patched content clears the target finding, adds no
|
|
11
|
+
* new finding type, and nets fewer findings than before.
|
|
12
|
+
*/
|
|
13
|
+
import type { Finding } from "./types.js";
|
|
14
|
+
import type { Patch } from "./agent-types.js";
|
|
15
|
+
import { checkPatchPolicy } from "./patch-policy.js";
|
|
16
|
+
import type { PolicyContext } from "./patch-policy.js";
|
|
17
|
+
import { verifyFix } from "./verify.js";
|
|
18
|
+
|
|
19
|
+
export interface VerifiedPatch {
|
|
20
|
+
finding: Finding;
|
|
21
|
+
patch: Patch;
|
|
22
|
+
}
|
|
23
|
+
export interface RejectedPatch {
|
|
24
|
+
finding: Finding;
|
|
25
|
+
reason: string;
|
|
26
|
+
}
|
|
27
|
+
export interface RemediationResult {
|
|
28
|
+
applied: VerifiedPatch[];
|
|
29
|
+
rejected: RejectedPatch[];
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export interface RemediateOptions {
|
|
33
|
+
/** Current content of the file a finding lives in. */
|
|
34
|
+
readContent: (finding: Finding) => Promise<string> | string;
|
|
35
|
+
/** Produce a candidate patch for a finding (codemod or LLM), or null. */
|
|
36
|
+
patchSource: (finding: Finding, content: string) => Promise<Patch | null> | Patch | null;
|
|
37
|
+
/** Which files a patch is allowed to touch. */
|
|
38
|
+
policy: PolicyContext;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/** True when `after` is a strict improvement over `before` for `finding`. */
|
|
42
|
+
function passesVerify(before: Finding[], after: Finding[], finding: Finding): boolean {
|
|
43
|
+
const targetGone = !after.some((x) => x.ruleId === finding.ruleId);
|
|
44
|
+
const noNewRuleTypes = after.every((x) => before.some((b) => b.ruleId === x.ruleId));
|
|
45
|
+
return targetGone && noNewRuleTypes && after.length < before.length;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* Run each finding through patchSource → policy → verify, collecting the patches
|
|
50
|
+
* that survive both gates and the reasons the rest were dropped.
|
|
51
|
+
*/
|
|
52
|
+
export async function remediateFindings(
|
|
53
|
+
findings: readonly Finding[],
|
|
54
|
+
opts: RemediateOptions,
|
|
55
|
+
): Promise<RemediationResult> {
|
|
56
|
+
const applied: VerifiedPatch[] = [];
|
|
57
|
+
const rejected: RejectedPatch[] = [];
|
|
58
|
+
|
|
59
|
+
for (const finding of findings) {
|
|
60
|
+
const content = await opts.readContent(finding);
|
|
61
|
+
const patch = await opts.patchSource(finding, content);
|
|
62
|
+
if (!patch) {
|
|
63
|
+
rejected.push({ finding, reason: "no deterministic fix available" });
|
|
64
|
+
continue;
|
|
65
|
+
}
|
|
66
|
+
const decision = checkPatchPolicy(patch, opts.policy);
|
|
67
|
+
if (!decision.allowed) {
|
|
68
|
+
rejected.push({ finding, reason: decision.reason ?? "rejected by patch policy" });
|
|
69
|
+
continue;
|
|
70
|
+
}
|
|
71
|
+
const before = verifyFix(content, { filename: finding.location.file }).findings;
|
|
72
|
+
const after = verifyFix(patch.newContent, { filename: patch.path }).findings;
|
|
73
|
+
if (!passesVerify(before, after, finding)) {
|
|
74
|
+
rejected.push({ finding, reason: "patch did not pass the verify_fix gate" });
|
|
75
|
+
continue;
|
|
76
|
+
}
|
|
77
|
+
applied.push({ finding, patch });
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
return { applied, rejected };
|
|
81
|
+
}
|