@quantakrypto/core 0.4.0 → 0.4.2

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/src/redact.ts CHANGED
@@ -12,20 +12,96 @@ import type { ContextLevel, RedactedContext } from "./agent-types.js";
12
12
  /** Lines of context on each side of the match at `snippet` level. */
13
13
  const SNIPPET_RADIUS = 8;
14
14
 
15
+ /** The placeholder that replaces any redacted secret. */
16
+ const REDACTED = "«redacted-secret»";
17
+
15
18
  /**
16
- * PEM blocks and long unbroken base64 runs (≥120 chars) are treated as secret
17
- * material and masked, even inside otherwise-shareable code.
19
+ * Text longer than this is not scanned pattern-by-pattern we fail CLOSED
20
+ * (redact the whole thing). Bounds worst-case work and sidesteps any engine
21
+ * limit on pathological single-token runs.
18
22
  */
19
- const SECRET_RE =
20
- /-----BEGIN [A-Z0-9 ]+-----[\s\S]*?-----END [A-Z0-9 ]+-----|[A-Za-z0-9+/]{120,}={0,2}/g;
23
+ const MAX_SECRET_SCAN = 2_000_000;
21
24
 
22
- function stripSecrets(text: string): { text: string; redacted: boolean } {
25
+ /**
26
+ * High-signal secret shapes. EVERY quantifier has an explicit upper bound so
27
+ * matching stays linear — no catastrophic backtracking and no regex-engine
28
+ * stack overflow on multi-megabyte runs (both were real DoS vectors with the
29
+ * old unbounded `{120,}` / `[\s\S]*?` patterns). Private-key BLOCKS are handled
30
+ * separately, line-by-line, so a truncated key (missing `-----END-----`) is
31
+ * still caught.
32
+ */
33
+ const TOKEN_PATTERNS: readonly RegExp[] = [
34
+ /\b(?:AKIA|ASIA)[A-Z0-9]{16}\b/g, // AWS access key id
35
+ /\bgh[posru]_[A-Za-z0-9]{20,255}\b/g, // GitHub token
36
+ /\bgithub_pat_[A-Za-z0-9_]{20,255}\b/g, // GitHub fine-grained PAT
37
+ /\bxox[baprs]-[A-Za-z0-9-]{10,255}\b/g, // Slack
38
+ /\bsk-(?:proj-)?[A-Za-z0-9_-]{20,255}\b/g, // OpenAI
39
+ /\b[rs]k_live_[A-Za-z0-9]{20,255}\b/g, // Stripe
40
+ /\bAIza[A-Za-z0-9_-]{35}\b/g, // Google API key
41
+ /\bglpat-[A-Za-z0-9_-]{20,255}\b/g, // GitLab PAT
42
+ /\beyJ[A-Za-z0-9_-]{8,4096}\.[A-Za-z0-9_-]{8,4096}\.[A-Za-z0-9_-]{6,4096}\b/g, // JWT
43
+ // Assignment of a secret-looking key (.env / config lines).
44
+ /(?:secret|token|passwd|password|api[_-]?key|access[_-]?key|client[_-]?secret|private[_-]?key)["'`]?\s*[:=]\s*["'`]?[^\s"'`,;]{6,4096}/gi,
45
+ /\b[0-9a-fA-F]{40,4096}\b/g, // long hex run (≥20 bytes)
46
+ /[A-Za-z0-9+/]{44,4096}={0,2}/g, // long base64 run (≥32 bytes)
47
+ ];
48
+
49
+ /** Redact PEM/OpenSSH/PGP private-key blocks line-by-line (linear; tolerant of
50
+ * a missing END marker — a truncated key is still fully redacted). */
51
+ function redactPrivateKeyBlocks(text: string): { text: string; redacted: boolean } {
52
+ const begin =
53
+ /-----BEGIN (?:[A-Z0-9 ]*PRIVATE KEY|OPENSSH PRIVATE KEY|PGP PRIVATE KEY BLOCK)-----/;
54
+ const end = /-----END /;
23
55
  let redacted = false;
24
- const out = text.replace(SECRET_RE, () => {
25
- redacted = true;
26
- return "«redacted-secret»";
27
- });
28
- return { text: out, redacted };
56
+ let inKey = false;
57
+ const out: string[] = [];
58
+ for (const line of text.split("\n")) {
59
+ if (!inKey && begin.test(line)) {
60
+ inKey = true;
61
+ redacted = true;
62
+ out.push(REDACTED);
63
+ continue;
64
+ }
65
+ if (inKey) {
66
+ if (end.test(line)) inKey = false;
67
+ continue; // drop key-body / END lines
68
+ }
69
+ out.push(line);
70
+ }
71
+ return { text: out.join("\n"), redacted };
72
+ }
73
+
74
+ // Single-entry memo: same-file findings re-request the identical file content;
75
+ // this makes the whole-file redaction O(n) across all of them instead of O(n·k).
76
+ let memoInput: string | undefined;
77
+ let memoResult: { text: string; redacted: boolean } | undefined;
78
+
79
+ /** Replace every secret-looking token/block in `text` with {@link REDACTED}.
80
+ * Fails CLOSED: on oversized input or any error, the whole text is redacted. */
81
+ function stripSecrets(text: string): { text: string; redacted: boolean } {
82
+ if (memoInput === text && memoResult) return memoResult;
83
+ let result: { text: string; redacted: boolean };
84
+ if (text.length > MAX_SECRET_SCAN) {
85
+ result = { text: REDACTED, redacted: true };
86
+ } else {
87
+ try {
88
+ const pem = redactPrivateKeyBlocks(text);
89
+ let out = pem.text;
90
+ let redacted = pem.redacted;
91
+ for (const re of TOKEN_PATTERNS) {
92
+ out = out.replace(re, () => {
93
+ redacted = true;
94
+ return REDACTED;
95
+ });
96
+ }
97
+ result = { text: out, redacted };
98
+ } catch {
99
+ result = { text: REDACTED, redacted: true };
100
+ }
101
+ }
102
+ memoInput = text;
103
+ memoResult = result;
104
+ return result;
29
105
  }
30
106
 
31
107
  /** Best-effort enclosing brace/colon block around a 0-based line index. */
@@ -160,7 +160,24 @@ export function remediationForTier(
160
160
  const base = REMEDIATIONS[algorithm];
161
161
  const params = TIER_PARAMS[tier];
162
162
  // Confidentiality families lean on the KEM; signature families on the signer.
163
- const primary = isConfidentialityFamily(algorithm) ? params.kem : params.signature;
163
+ const isConf = isConfidentialityFamily(algorithm);
164
+ const primary = isConf ? params.kem : params.signature;
165
+
166
+ // Category-5 (CNSA 2.0 / NSS) mandates the ML-KEM-1024 / ML-DSA-87 parameter
167
+ // sets and must NOT surface the category-3 X25519MLKEM768 hybrid (its PQC
168
+ // component is ML-KEM-768 — sub-CNSA). Lead with the mandated set; only point
169
+ // at a hybrid TLS group at the 1024 level (audit: quantum #1, crypto S5).
170
+ if (tier === "category-5") {
171
+ const hybridNote = isConf
172
+ ? " If a hybrid TLS group is required, use SecP384r1MLKEM1024 (draft-ietf-tls-ecdhe-mlkem) — not X25519MLKEM768, whose ML-KEM-768 component does not meet CNSA 2.0."
173
+ : "";
174
+ return {
175
+ algorithm,
176
+ recommendation: `${primary} — CNSA 2.0 mandates this parameter set (hybrids optional)`,
177
+ detail: `${base.detail} ${params.note} For CNSA 2.0 / national-security systems use ${params.kem} (KEM) and ${params.signature} (signatures).${hybridNote}`,
178
+ };
179
+ }
180
+
164
181
  return {
165
182
  algorithm,
166
183
  recommendation: `${base.recommendation} — ${tier}: ${primary}`,
package/src/scan.ts CHANGED
@@ -118,7 +118,14 @@ export async function scan(options: ScanOptions): Promise<ScanResult> {
118
118
  const cacheFile = options.cacheFile;
119
119
  const ruleset = cacheFile ? rulesetFingerprint(dets, options.disabledRules) : "";
120
120
  const cache = cacheFile ? await loadCache(cacheFile, ruleset) : null;
121
- const nextEntries: Map<string, CacheEntry> | null = cacheFile ? new Map() : null;
121
+ // A full scan starts with an empty next-cache so files that vanished are
122
+ // evicted. An INCREMENTAL scan (explicit `files` list) only visits a subset,
123
+ // so seed the next-cache with the existing entries — otherwise saving would
124
+ // drop every file we didn't happen to scan this run (audit: arch #4).
125
+ const incremental = Array.isArray(options.files);
126
+ const nextEntries: Map<string, CacheEntry> | null = cacheFile
127
+ ? new Map(incremental && cache ? cache : [])
128
+ : null;
122
129
 
123
130
  // Work-budget / cancellation controls (all optional, unlimited when omitted).
124
131
  const signal = options.signal;
package/src/types.ts CHANGED
@@ -107,6 +107,13 @@ export interface VulnerableDependency {
107
107
  /** Algorithm families the package primarily exposes. */
108
108
  algorithms: AlgorithmFamily[];
109
109
  severity: Severity;
110
+ /**
111
+ * Explicit harvest-now-decrypt-later override. When omitted, HNDL is inferred
112
+ * from whether any listed family is a confidentiality family. Set `false` for
113
+ * signing-only packages (e.g. JWS/JWT libraries) that list RSA/EC as families
114
+ * but never do key transport or key agreement — signatures are not HNDL-exposed.
115
+ */
116
+ hndl?: boolean;
110
117
  }
111
118
 
112
119
  /**
package/src/version.ts CHANGED
@@ -3,4 +3,4 @@
3
3
  * the scan orchestrator can import it without creating a cycle through index.ts.
4
4
  * Keep in sync with packages/core/package.json.
5
5
  */
6
- export const VERSION = "0.4.0";
6
+ export const VERSION = "0.4.2";
package/src/worktree.ts CHANGED
@@ -31,8 +31,10 @@ export async function withWorktree<T>(
31
31
  }
32
32
  const base = await mkdtemp(join(tmpdir(), "quantakrypto-wt-"));
33
33
  const dir = join(base, "wt");
34
- await git(["worktree", "add", "--detach", dir], repoRoot);
34
+ // `worktree add` is INSIDE the try so a failure (locked index, disk full)
35
+ // still cleans up the temp dir in `finally` (audit: arch #6).
35
36
  try {
37
+ await git(["worktree", "add", "--detach", dir], repoRoot);
36
38
  return await fn(dir);
37
39
  } finally {
38
40
  try {
@@ -40,6 +42,12 @@ export async function withWorktree<T>(
40
42
  } catch {
41
43
  // best effort — the temp dir removal below still reclaims the disk.
42
44
  }
45
+ // Prune stale `.git/worktrees/<name>` metadata a failed add/remove can leave.
46
+ try {
47
+ await git(["worktree", "prune"], repoRoot);
48
+ } catch {
49
+ // best effort
50
+ }
43
51
  await rm(base, { recursive: true, force: true });
44
52
  }
45
53
  }