@quantakrypto/core 0.5.0 → 0.7.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/crypto-agility.d.ts +158 -0
- package/dist/crypto-agility.d.ts.map +1 -0
- package/dist/crypto-agility.js +285 -0
- package/dist/crypto-agility.js.map +1 -0
- package/dist/dependencies.d.ts.map +1 -1
- package/dist/dependencies.js +180 -18
- package/dist/dependencies.js.map +1 -1
- package/dist/detectors/source.d.ts.map +1 -1
- package/dist/detectors/source.js +52 -0
- package/dist/detectors/source.js.map +1 -1
- package/dist/hndl.d.ts +241 -0
- package/dist/hndl.d.ts.map +1 -0
- package/dist/hndl.js +752 -0
- package/dist/hndl.js.map +1 -0
- package/dist/index.d.ts +6 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +6 -0
- package/dist/index.js.map +1 -1
- package/dist/mandates.d.ts +138 -0
- package/dist/mandates.d.ts.map +1 -0
- package/dist/mandates.js +228 -0
- package/dist/mandates.js.map +1 -0
- package/dist/parallel.d.ts.map +1 -1
- package/dist/parallel.js +22 -23
- package/dist/parallel.js.map +1 -1
- package/dist/report.d.ts +8 -0
- package/dist/report.d.ts.map +1 -1
- package/dist/report.js +75 -19
- package/dist/report.js.map +1 -1
- package/dist/types.d.ts +1 -1
- package/dist/types.d.ts.map +1 -1
- package/dist/types.js.map +1 -1
- package/dist/version.d.ts +1 -1
- package/dist/version.js +1 -1
- package/dist/version.js.map +1 -1
- package/dist/walk.d.ts +31 -1
- package/dist/walk.d.ts.map +1 -1
- package/dist/walk.js +30 -9
- package/dist/walk.js.map +1 -1
- package/package.json +1 -1
- package/src/crypto-agility.ts +407 -0
- package/src/dependencies.ts +182 -16
- package/src/detectors/source.ts +62 -0
- package/src/hndl.ts +1012 -0
- package/src/index.ts +71 -0
- package/src/mandates.ts +365 -0
- package/src/parallel.ts +21 -20
- package/src/report.ts +84 -19
- package/src/types.ts +9 -1
- package/src/version.ts +1 -1
- package/src/walk.ts +47 -10
package/src/index.ts
CHANGED
|
@@ -83,6 +83,42 @@ export { changedFiles } from "./changed.js";
|
|
|
83
83
|
export { loadConfig, ConfigError, CONFIG_FILENAME } from "./config.js";
|
|
84
84
|
export type { QuantakryptoFileConfig, LoadConfigResult } from "./config.js";
|
|
85
85
|
|
|
86
|
+
// HNDL (harvest-now-decrypt-later) data-risk quantifier (see docs/HNDL.md).
|
|
87
|
+
export {
|
|
88
|
+
HNDL_MODEL_VERSION,
|
|
89
|
+
HNDL_FILENAME,
|
|
90
|
+
SEVERITY_VULNERABILITY,
|
|
91
|
+
CONFIDENCE_WEIGHT,
|
|
92
|
+
CLASSIFICATION_SENSITIVITY,
|
|
93
|
+
NON_HNDL_DISCOUNT,
|
|
94
|
+
DEFAULT_QUANTUM_THREAT_YEARS,
|
|
95
|
+
DEFAULT_MIGRATION_HORIZON_YEARS,
|
|
96
|
+
DEFAULT_UNBOUND_CLASSIFICATION,
|
|
97
|
+
HndlError,
|
|
98
|
+
computeHndl,
|
|
99
|
+
vulnerabilityFactor,
|
|
100
|
+
moscaFactor,
|
|
101
|
+
globMatch,
|
|
102
|
+
findingFingerprint,
|
|
103
|
+
findingScope,
|
|
104
|
+
parseHndlMap,
|
|
105
|
+
loadHndlMap,
|
|
106
|
+
scaffoldHndlYaml,
|
|
107
|
+
} from "./hndl.js";
|
|
108
|
+
export type {
|
|
109
|
+
DataClassification,
|
|
110
|
+
HndlScope,
|
|
111
|
+
HndlDataAsset,
|
|
112
|
+
HndlHorizon,
|
|
113
|
+
HndlDefaults,
|
|
114
|
+
HndlMap,
|
|
115
|
+
ExposureRationale,
|
|
116
|
+
FindingExposure,
|
|
117
|
+
AssetExposure,
|
|
118
|
+
HndlSummary,
|
|
119
|
+
HndlReport,
|
|
120
|
+
} from "./hndl.js";
|
|
121
|
+
|
|
86
122
|
// Filesystem walker (relative POSIX paths, default ignores, size/binary filters).
|
|
87
123
|
export { walkFiles, isBinaryPath, looksMinified } from "./walk.js";
|
|
88
124
|
|
|
@@ -122,6 +158,22 @@ export { mergeCboms } from "./cbom-merge.js";
|
|
|
122
158
|
// OpenVEX 0.2.0 export — quantum-readiness posture as VEX statements.
|
|
123
159
|
export { toOpenVex } from "./vex.js";
|
|
124
160
|
export type { OpenVexDocument, OpenVexStatement, OpenVexOptions } from "./vex.js";
|
|
161
|
+
// Crypto-agility manifest: agent-consumable crypto-posture document.
|
|
162
|
+
export {
|
|
163
|
+
buildCryptoAgilityManifest,
|
|
164
|
+
validateCryptoAgilityManifest,
|
|
165
|
+
CRYPTO_AGILITY_MANIFEST_VERSION,
|
|
166
|
+
CRYPTO_AGILITY_WELL_KNOWN_PATH,
|
|
167
|
+
} from "./crypto-agility.js";
|
|
168
|
+
export type {
|
|
169
|
+
CryptoAgilityManifest,
|
|
170
|
+
CryptoAgilityManifestOptions,
|
|
171
|
+
CryptoAgilityPosture,
|
|
172
|
+
CryptoAgilityCbomSummary,
|
|
173
|
+
CryptoAgilityFamily,
|
|
174
|
+
CryptoAgilityPolicy,
|
|
175
|
+
ManifestValidation,
|
|
176
|
+
} from "./crypto-agility.js";
|
|
125
177
|
// ISO/IEC 27001 A.8.24 evidence-chain readiness report.
|
|
126
178
|
export { buildReadinessReport, signReadinessReport, verifyReadinessReport } from "./evidence.js";
|
|
127
179
|
export type {
|
|
@@ -137,6 +189,25 @@ export type {
|
|
|
137
189
|
export { buildPolicyMapping, parseCryptoPolicy } from "./policy.js";
|
|
138
190
|
export type { CryptoPolicy, PolicyVerdict, PolicyMapping, PolicyFindingVerdict } from "./policy.js";
|
|
139
191
|
|
|
192
|
+
// Compliance mandates → dated, clause-named verdicts + gate (policy-as-code).
|
|
193
|
+
export {
|
|
194
|
+
MANDATES,
|
|
195
|
+
mandateIds,
|
|
196
|
+
getMandate,
|
|
197
|
+
assertKnownMandates,
|
|
198
|
+
evaluateMandates,
|
|
199
|
+
mandateGateFails,
|
|
200
|
+
} from "./mandates.js";
|
|
201
|
+
export type {
|
|
202
|
+
Mandate,
|
|
203
|
+
MandateRule,
|
|
204
|
+
MandateRuleTier,
|
|
205
|
+
MandateStatus,
|
|
206
|
+
MandateFindingVerdict,
|
|
207
|
+
MandateEvaluation,
|
|
208
|
+
MandateGateOptions,
|
|
209
|
+
} from "./mandates.js";
|
|
210
|
+
|
|
140
211
|
// Remediation lookup (family + tier-aware + profile-aware) and stateful-HBS guidance.
|
|
141
212
|
export {
|
|
142
213
|
remediationFor,
|
package/src/mandates.ts
ADDED
|
@@ -0,0 +1,365 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Policy-as-code compliance mandates → dated, clause-named verdicts for findings.
|
|
3
|
+
*
|
|
4
|
+
* `CryptoPolicy` (policy.ts) classifies findings by algorithm family but is
|
|
5
|
+
* date-blind. A mandate adds the missing dimension: named clauses with an effective
|
|
6
|
+
* DATE ("CNSA 2.0 disallows classical public-key crypto after 2035"). The evaluator
|
|
7
|
+
* compares each finding's algorithm against the selected mandates and today's date,
|
|
8
|
+
* so a finding on a prohibited family reads as `due` (every deadline still ahead),
|
|
9
|
+
* `deprecated` (the DEPRECATE deadline has passed — a warning), or `violation` (the
|
|
10
|
+
* DISALLOW deadline has passed — a failure), always naming the governing clause,
|
|
11
|
+
* deadline, and citation. This is what turns the inventory into an enforceable,
|
|
12
|
+
* mandate-mapped gate rather than a neutral list.
|
|
13
|
+
*
|
|
14
|
+
* Pure and deterministic (the caller supplies `now`), so it is trivially testable.
|
|
15
|
+
* qScan consumes it today for the `--mandate` gate; because it operates on the
|
|
16
|
+
* shared `Finding[]`, qProbe or the GitHub Action can reuse it unchanged.
|
|
17
|
+
*
|
|
18
|
+
* Catalog scope: the two regimes that carry hard algorithm deadlines — CNSA 2.0 and
|
|
19
|
+
* NIST IR 8547. DORA / NIS2 / PCI DSS require approved cryptography but set no
|
|
20
|
+
* independent algorithm date; they inherit these timelines and are cited in docs.
|
|
21
|
+
*/
|
|
22
|
+
import type { AlgorithmFamily, Finding } from "./types.js";
|
|
23
|
+
import { PQC_STANDARDS } from "./standards.js";
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* All Shor-broken classical asymmetric families — the mandate's SCOPE. A finding
|
|
27
|
+
* on one of these is adjudicated against the selected mandates; findings on
|
|
28
|
+
* anything else (hashes, RNG, dependency, or TLS-configuration findings) are out
|
|
29
|
+
* of scope for a PQC-asymmetric mandate and are tallied as `notInScope` instead
|
|
30
|
+
* of inflating the conformant count.
|
|
31
|
+
*/
|
|
32
|
+
const CLASSICAL_PUBLIC_KEY: readonly AlgorithmFamily[] = [
|
|
33
|
+
"RSA",
|
|
34
|
+
"ECDH",
|
|
35
|
+
"ECDSA",
|
|
36
|
+
"EdDSA",
|
|
37
|
+
"DH",
|
|
38
|
+
"DSA",
|
|
39
|
+
"X25519",
|
|
40
|
+
"X448",
|
|
41
|
+
"ECIES",
|
|
42
|
+
];
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* The PROHIBITED subset the dated clauses apply to. X25519 and X448 are
|
|
46
|
+
* deliberately excluded: they are the classical half of the recommended hybrid
|
|
47
|
+
* key exchange (X25519MLKEM768 — permitted and recommended under the NIST
|
|
48
|
+
* profile), and a static scan cannot distinguish a standalone exchange from the
|
|
49
|
+
* hybrid's classical leg. Prohibiting them would false-positive exactly the orgs
|
|
50
|
+
* that hybridized correctly, so they stay in scope but read `conformant`.
|
|
51
|
+
*/
|
|
52
|
+
const PROHIBITED_FAMILIES: readonly AlgorithmFamily[] = CLASSICAL_PUBLIC_KEY.filter(
|
|
53
|
+
(family) => family !== "X25519" && family !== "X448",
|
|
54
|
+
);
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* Effective dates derived from the standards source of truth
|
|
58
|
+
* (`PQC_STANDARDS.transitionTimeline`), so a quarterly standards update moves the
|
|
59
|
+
* mandate deadlines automatically (test/standards.test.ts asserts they agree).
|
|
60
|
+
*
|
|
61
|
+
* Boundary choice: "deprecate AFTER 2030" leaves the whole stated year permitted,
|
|
62
|
+
* so each clause takes effect on the LAST day of its year (`YYYY-12-31`) —
|
|
63
|
+
* conservative by a single day, unlike `YYYY-01-01`, which would bite roughly a
|
|
64
|
+
* year early.
|
|
65
|
+
*/
|
|
66
|
+
const { deprecateAfter, disallowAfter } = PQC_STANDARDS.transitionTimeline;
|
|
67
|
+
const DEPRECATE_EFFECTIVE = `${deprecateAfter}-12-31`;
|
|
68
|
+
const DISALLOW_EFFECTIVE = `${disallowAfter}-12-31`;
|
|
69
|
+
|
|
70
|
+
/** Which enforcement tier a clause encodes: warn (`deprecate`) or fail (`disallow`). */
|
|
71
|
+
export type MandateRuleTier = "deprecate" | "disallow";
|
|
72
|
+
|
|
73
|
+
export interface MandateRule {
|
|
74
|
+
/** The named clause this rule encodes (verbatim in the gate's failure message). */
|
|
75
|
+
clause: string;
|
|
76
|
+
/** Enforcement tier: a passed `deprecate` date warns; a passed `disallow` date fails. */
|
|
77
|
+
tier: MandateRuleTier;
|
|
78
|
+
/** Algorithm families prohibited from the effective date onward. */
|
|
79
|
+
prohibits: AlgorithmFamily[];
|
|
80
|
+
/** ISO date (YYYY-MM-DD) the prohibition takes effect. */
|
|
81
|
+
effective: string;
|
|
82
|
+
/** One-line human description of the clause. */
|
|
83
|
+
note: string;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
export interface Mandate {
|
|
87
|
+
id: string;
|
|
88
|
+
name: string;
|
|
89
|
+
authority: string;
|
|
90
|
+
citation: string;
|
|
91
|
+
/** When this catalog entry was last reviewed against the source. */
|
|
92
|
+
asOf: string;
|
|
93
|
+
rules: MandateRule[];
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/** The bundled mandate catalog. Keyed by `--mandate <id>`. */
|
|
97
|
+
export const MANDATES: Record<string, Mandate> = {
|
|
98
|
+
"cnsa-2.0": {
|
|
99
|
+
id: "cnsa-2.0",
|
|
100
|
+
name: "CNSA 2.0",
|
|
101
|
+
authority: "NSA",
|
|
102
|
+
citation: "NSA Commercial National Security Algorithm Suite 2.0 (CNSA 2.0)",
|
|
103
|
+
asOf: "2026-07",
|
|
104
|
+
rules: [
|
|
105
|
+
{
|
|
106
|
+
clause: `CNSA 2.0 — deprecate classical PKC after ${deprecateAfter}`,
|
|
107
|
+
tier: "deprecate",
|
|
108
|
+
prohibits: [...PROHIBITED_FAMILIES],
|
|
109
|
+
effective: DEPRECATE_EFFECTIVE,
|
|
110
|
+
note: "Classical public-key cryptography deprecated; systems should use CNSA 2.0 PQC exclusively.",
|
|
111
|
+
},
|
|
112
|
+
{
|
|
113
|
+
clause: `CNSA 2.0 — disallow classical PKC after ${disallowAfter}`,
|
|
114
|
+
tier: "disallow",
|
|
115
|
+
prohibits: [...PROHIBITED_FAMILIES],
|
|
116
|
+
effective: DISALLOW_EFFECTIVE,
|
|
117
|
+
note: "Classical public-key cryptography disallowed; the migration must be complete.",
|
|
118
|
+
},
|
|
119
|
+
],
|
|
120
|
+
},
|
|
121
|
+
"nist-ir-8547": {
|
|
122
|
+
id: "nist-ir-8547",
|
|
123
|
+
name: "NIST IR 8547",
|
|
124
|
+
authority: "NIST",
|
|
125
|
+
citation: "NIST IR 8547 (Transition to Post-Quantum Cryptography Standards)",
|
|
126
|
+
asOf: "2026-07",
|
|
127
|
+
rules: [
|
|
128
|
+
{
|
|
129
|
+
clause: `NIST IR 8547 — deprecate classical PKC after ${deprecateAfter}`,
|
|
130
|
+
tier: "deprecate",
|
|
131
|
+
prohibits: [...PROHIBITED_FAMILIES],
|
|
132
|
+
effective: DEPRECATE_EFFECTIVE,
|
|
133
|
+
note: `112-bit-security classical public-key algorithms deprecated after ${deprecateAfter}.`,
|
|
134
|
+
},
|
|
135
|
+
{
|
|
136
|
+
clause: `NIST IR 8547 — disallow classical PKC after ${disallowAfter}`,
|
|
137
|
+
tier: "disallow",
|
|
138
|
+
prohibits: [...PROHIBITED_FAMILIES],
|
|
139
|
+
effective: DISALLOW_EFFECTIVE,
|
|
140
|
+
note: `Classical public-key algorithms disallowed after ${disallowAfter}.`,
|
|
141
|
+
},
|
|
142
|
+
],
|
|
143
|
+
},
|
|
144
|
+
};
|
|
145
|
+
|
|
146
|
+
export const mandateIds = (): string[] => Object.keys(MANDATES);
|
|
147
|
+
export const getMandate = (id: string): Mandate | undefined => MANDATES[id];
|
|
148
|
+
|
|
149
|
+
/**
|
|
150
|
+
* Validate mandate ids loudly, matching `parseCryptoPolicy`'s fail-loud
|
|
151
|
+
* convention: a mistyped id must never silently evaluate to an empty gate.
|
|
152
|
+
* Throws an `Error` naming every unknown id and the known catalog.
|
|
153
|
+
* `evaluateMandates` itself stays lenient (unknown ids are skipped) so callers
|
|
154
|
+
* decide where to fail.
|
|
155
|
+
*/
|
|
156
|
+
export function assertKnownMandates(ids: readonly string[]): void {
|
|
157
|
+
const unknown = ids.filter((id) => !MANDATES[id]);
|
|
158
|
+
if (unknown.length > 0) {
|
|
159
|
+
throw new Error(
|
|
160
|
+
`unknown mandate id(s): ${unknown.join(", ")}; known mandates: ${mandateIds().join(", ")}`,
|
|
161
|
+
);
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
/**
|
|
166
|
+
* A finding's status against a mandate, worst last:
|
|
167
|
+
* - `conformant` — in scope (classical asymmetric) but prohibited by no selected
|
|
168
|
+
* clause (e.g. X25519 as the presumed hybrid leg).
|
|
169
|
+
* - `due` — prohibited, with every deadline still ahead.
|
|
170
|
+
* - `deprecated` — the DEPRECATE deadline has passed; a warning, not a failure.
|
|
171
|
+
* - `violation` — the DISALLOW deadline has passed; fails the default gate.
|
|
172
|
+
*/
|
|
173
|
+
export type MandateStatus = "conformant" | "due" | "deprecated" | "violation";
|
|
174
|
+
|
|
175
|
+
export interface MandateFindingVerdict {
|
|
176
|
+
ruleId: string;
|
|
177
|
+
algorithm: AlgorithmFamily | "unknown";
|
|
178
|
+
file: string;
|
|
179
|
+
line: number;
|
|
180
|
+
/** Mandate id (e.g. "cnsa-2.0"). */
|
|
181
|
+
mandate: string;
|
|
182
|
+
/**
|
|
183
|
+
* The governing clause: the next upcoming clause when `due`, the passed
|
|
184
|
+
* DEPRECATE clause when `deprecated`, the passed DISALLOW clause on `violation`.
|
|
185
|
+
*/
|
|
186
|
+
clause: string;
|
|
187
|
+
/** ISO effective date of the governing clause. */
|
|
188
|
+
effective: string;
|
|
189
|
+
status: MandateStatus;
|
|
190
|
+
/** Whole months from `now` to the governing deadline; negative once it has passed. */
|
|
191
|
+
monthsUntil: number;
|
|
192
|
+
/**
|
|
193
|
+
* ISO effective date of this mandate's DISALLOW clause for the family, or null
|
|
194
|
+
* when the mandate carries none. The gate's `leadMonths` measures against this.
|
|
195
|
+
*/
|
|
196
|
+
disallowEffective: string | null;
|
|
197
|
+
/** Whole months from `now` to `disallowEffective`; null when there is none. */
|
|
198
|
+
monthsUntilDisallow: number | null;
|
|
199
|
+
citation: string;
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
export interface MandateEvaluation {
|
|
203
|
+
/** The `now` the evaluation was computed against (ISO). */
|
|
204
|
+
now: string;
|
|
205
|
+
/** Mandate ids evaluated. */
|
|
206
|
+
mandates: string[];
|
|
207
|
+
/**
|
|
208
|
+
* Counts of IN-SCOPE findings (classical asymmetric families) by their worst
|
|
209
|
+
* status across the selected mandates. Out-of-scope findings are excluded so
|
|
210
|
+
* the conformant count is an honest statement about asymmetric crypto only.
|
|
211
|
+
*/
|
|
212
|
+
summary: Record<MandateStatus, number>;
|
|
213
|
+
/**
|
|
214
|
+
* Findings outside the mandate's scope (hashes, RNG, dependency, TLS-config…),
|
|
215
|
+
* which a PQC-asymmetric mandate does not adjudicate.
|
|
216
|
+
*/
|
|
217
|
+
notInScope: number;
|
|
218
|
+
/** One row per (prohibited finding × applicable mandate). */
|
|
219
|
+
findings: MandateFindingVerdict[];
|
|
220
|
+
/** Earliest still-future deadline across non-violation rows, or null. */
|
|
221
|
+
nextDeadline: string | null;
|
|
222
|
+
/** True when at least one DISALLOW deadline has passed (a `violation` exists). */
|
|
223
|
+
hasViolation: boolean;
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
const MONTH_MS = 2_629_800_000; // average month
|
|
227
|
+
|
|
228
|
+
function monthsBetween(fromMs: number, toMs: number): number {
|
|
229
|
+
return Math.round((toMs - fromMs) / MONTH_MS);
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
const STATUS_RANK: Record<MandateStatus, number> = {
|
|
233
|
+
conformant: 0,
|
|
234
|
+
due: 1,
|
|
235
|
+
deprecated: 2,
|
|
236
|
+
violation: 3,
|
|
237
|
+
};
|
|
238
|
+
|
|
239
|
+
/**
|
|
240
|
+
* Evaluate findings against the selected mandates as of `now`. Unknown mandate
|
|
241
|
+
* ids are ignored — callers validate up front with {@link assertKnownMandates}.
|
|
242
|
+
* A finding outside the classical-asymmetric scope is counted in `notInScope`;
|
|
243
|
+
* an in-scope finding no selected mandate prohibits is `conformant`. Neither
|
|
244
|
+
* contributes a verdict row.
|
|
245
|
+
*/
|
|
246
|
+
export function evaluateMandates(
|
|
247
|
+
findings: readonly Finding[],
|
|
248
|
+
mandateIdList: readonly string[],
|
|
249
|
+
now: Date,
|
|
250
|
+
): MandateEvaluation {
|
|
251
|
+
const nowMs = now.getTime();
|
|
252
|
+
const selected = mandateIdList.map(getMandate).filter((m): m is Mandate => Boolean(m));
|
|
253
|
+
|
|
254
|
+
const rows: MandateFindingVerdict[] = [];
|
|
255
|
+
const perFindingWorst: MandateStatus[] = [];
|
|
256
|
+
let notInScope = 0;
|
|
257
|
+
let nextDeadlineMs: number | null = null;
|
|
258
|
+
|
|
259
|
+
for (const f of findings) {
|
|
260
|
+
const algo = f.algorithm ?? "unknown";
|
|
261
|
+
if (!CLASSICAL_PUBLIC_KEY.includes(algo as AlgorithmFamily)) {
|
|
262
|
+
notInScope++;
|
|
263
|
+
continue;
|
|
264
|
+
}
|
|
265
|
+
let worst: MandateStatus = "conformant";
|
|
266
|
+
for (const mandate of selected) {
|
|
267
|
+
// The applicable clauses for this family, earliest deadline first.
|
|
268
|
+
const applicable = mandate.rules
|
|
269
|
+
.filter((r) => r.prohibits.includes(algo as AlgorithmFamily))
|
|
270
|
+
.sort((a, b) => a.effective.localeCompare(b.effective));
|
|
271
|
+
if (applicable.length === 0) continue;
|
|
272
|
+
|
|
273
|
+
// Tier the clauses so both stay live: a passed DISALLOW clause is a
|
|
274
|
+
// violation; a passed DEPRECATE clause (disallow still ahead) is the
|
|
275
|
+
// deprecated warning tier; otherwise the finding is due against the next
|
|
276
|
+
// upcoming clause.
|
|
277
|
+
const passed = applicable.filter((r) => nowMs >= new Date(r.effective).getTime());
|
|
278
|
+
const passedDisallow = passed.filter((r) => r.tier === "disallow");
|
|
279
|
+
let status: MandateStatus;
|
|
280
|
+
let governing: MandateRule;
|
|
281
|
+
if (passedDisallow.length > 0) {
|
|
282
|
+
status = "violation";
|
|
283
|
+
governing = passedDisallow[0];
|
|
284
|
+
} else if (passed.length > 0) {
|
|
285
|
+
status = "deprecated";
|
|
286
|
+
governing = passed[passed.length - 1];
|
|
287
|
+
} else {
|
|
288
|
+
status = "due";
|
|
289
|
+
governing = applicable[0];
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
// The disallow clause (earliest, if several) anchors `leadMonths`.
|
|
293
|
+
const disallowRule = applicable.find((r) => r.tier === "disallow") ?? null;
|
|
294
|
+
const disallowMs = disallowRule ? new Date(disallowRule.effective).getTime() : null;
|
|
295
|
+
|
|
296
|
+
if (status !== "violation") {
|
|
297
|
+
for (const r of applicable) {
|
|
298
|
+
const effMs = new Date(r.effective).getTime();
|
|
299
|
+
if (effMs > nowMs && (nextDeadlineMs === null || effMs < nextDeadlineMs))
|
|
300
|
+
nextDeadlineMs = effMs;
|
|
301
|
+
}
|
|
302
|
+
}
|
|
303
|
+
if (STATUS_RANK[status] > STATUS_RANK[worst]) worst = status;
|
|
304
|
+
rows.push({
|
|
305
|
+
ruleId: f.ruleId,
|
|
306
|
+
algorithm: algo,
|
|
307
|
+
file: f.location.file,
|
|
308
|
+
line: f.location.line,
|
|
309
|
+
mandate: mandate.id,
|
|
310
|
+
clause: governing.clause,
|
|
311
|
+
effective: governing.effective,
|
|
312
|
+
status,
|
|
313
|
+
monthsUntil: monthsBetween(nowMs, new Date(governing.effective).getTime()),
|
|
314
|
+
disallowEffective: disallowRule ? disallowRule.effective : null,
|
|
315
|
+
monthsUntilDisallow: disallowMs !== null ? monthsBetween(nowMs, disallowMs) : null,
|
|
316
|
+
citation: mandate.citation,
|
|
317
|
+
});
|
|
318
|
+
}
|
|
319
|
+
perFindingWorst.push(worst);
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
const summary: Record<MandateStatus, number> = {
|
|
323
|
+
conformant: 0,
|
|
324
|
+
due: 0,
|
|
325
|
+
deprecated: 0,
|
|
326
|
+
violation: 0,
|
|
327
|
+
};
|
|
328
|
+
for (const s of perFindingWorst) summary[s]++;
|
|
329
|
+
|
|
330
|
+
return {
|
|
331
|
+
now: now.toISOString(),
|
|
332
|
+
mandates: selected.map((m) => m.id),
|
|
333
|
+
summary,
|
|
334
|
+
notInScope,
|
|
335
|
+
findings: rows,
|
|
336
|
+
nextDeadline:
|
|
337
|
+
nextDeadlineMs !== null ? new Date(nextDeadlineMs).toISOString().slice(0, 10) : null,
|
|
338
|
+
hasViolation: summary.violation > 0,
|
|
339
|
+
};
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
export interface MandateGateOptions {
|
|
343
|
+
/** Fail when a DISALLOW deadline is within this many months (early enforcement). */
|
|
344
|
+
leadMonths?: number;
|
|
345
|
+
/** Fail on any mandate-prohibited finding regardless of the deadlines. */
|
|
346
|
+
failNow?: boolean;
|
|
347
|
+
}
|
|
348
|
+
|
|
349
|
+
/**
|
|
350
|
+
* The gate decision under the "deadline-aware" default: fail only once a DISALLOW
|
|
351
|
+
* deadline has passed (`violation`). A passed DEPRECATE date (`deprecated`) is a
|
|
352
|
+
* warning and does not fail the build. `leadMonths` fails early when a disallow
|
|
353
|
+
* deadline is within the window; `failNow` fails on any prohibited finding
|
|
354
|
+
* immediately.
|
|
355
|
+
*/
|
|
356
|
+
export function mandateGateFails(ev: MandateEvaluation, opts: MandateGateOptions = {}): boolean {
|
|
357
|
+
if (ev.hasViolation) return true;
|
|
358
|
+
if (opts.failNow) return ev.findings.length > 0;
|
|
359
|
+
if (opts.leadMonths !== undefined) {
|
|
360
|
+
return ev.findings.some(
|
|
361
|
+
(v) => v.monthsUntilDisallow !== null && v.monthsUntilDisallow <= opts.leadMonths!,
|
|
362
|
+
);
|
|
363
|
+
}
|
|
364
|
+
return false;
|
|
365
|
+
}
|
package/src/parallel.ts
CHANGED
|
@@ -17,7 +17,7 @@ import { fileURLToPath } from "node:url";
|
|
|
17
17
|
import type { Worker as NodeWorker } from "node:worker_threads";
|
|
18
18
|
|
|
19
19
|
import type { Finding, ParallelScanOptions, ScanResult } from "./types.js";
|
|
20
|
-
import {
|
|
20
|
+
import { walkFilesSized } from "./walk.js";
|
|
21
21
|
import { isAnalyzableSource } from "./detect-utils.js";
|
|
22
22
|
import { buildInventory } from "./inventory.js";
|
|
23
23
|
import { compareFindings, filterExplicitFileList, scan } from "./scan.js";
|
|
@@ -115,30 +115,31 @@ function shouldParallelize(options: ParallelScanOptions, files: SizedFile[]): bo
|
|
|
115
115
|
* explicit `files` list or the walker. Sizes power byte-balanced chunking.
|
|
116
116
|
*/
|
|
117
117
|
async function enumerateFiles(options: ParallelScanOptions, baseDir: string): Promise<SizedFile[]> {
|
|
118
|
-
const
|
|
118
|
+
const sized: SizedFile[] = [];
|
|
119
119
|
if (options.files) {
|
|
120
|
+
// Explicit file list: the walker isn't involved, so stat each once here.
|
|
120
121
|
// Apply the SAME include/exclude/binary filtering the serial path uses via
|
|
121
|
-
// `
|
|
122
|
-
for (const rel of filterExplicitFileList(options.files, options))
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
rels.push(rel);
|
|
122
|
+
// `filterExplicitFileList`, so `--parallel` is byte-for-byte identical to serial.
|
|
123
|
+
for (const rel of filterExplicitFileList(options.files, options)) {
|
|
124
|
+
let size = 0;
|
|
125
|
+
try {
|
|
126
|
+
size = (await stat(path.join(baseDir, ...rel.split("/")))).size;
|
|
127
|
+
} catch {
|
|
128
|
+
// Unreadable now; keep with size 0 (the worker read will skip if it's gone).
|
|
129
|
+
}
|
|
130
|
+
sized.push({ rel, size });
|
|
131
131
|
}
|
|
132
|
+
return sized;
|
|
132
133
|
}
|
|
133
134
|
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
135
|
+
// Directory walk: reuse the size the walker already stat'd while enforcing the
|
|
136
|
+
// size limit, so files are not stat'd a second time here (double-stat fix).
|
|
137
|
+
for await (const { rel, size } of walkFilesSized(options.root, {
|
|
138
|
+
include: options.include,
|
|
139
|
+
exclude: options.exclude,
|
|
140
|
+
noDefaultIgnores: options.noDefaultIgnores,
|
|
141
|
+
maxFileSize: options.maxFileSize,
|
|
142
|
+
})) {
|
|
142
143
|
sized.push({ rel, size });
|
|
143
144
|
}
|
|
144
145
|
return sized;
|
package/src/report.ts
CHANGED
|
@@ -11,6 +11,8 @@ import { remediationFor, remediationForTier, remediationForProfile } from "./rem
|
|
|
11
11
|
import type { SecurityTier } from "./remediation.js";
|
|
12
12
|
import type { StandardsProfile } from "./standards-profiles.js";
|
|
13
13
|
import { fingerprintFinding } from "./baseline.js";
|
|
14
|
+
import { findingFingerprint } from "./hndl.js";
|
|
15
|
+
import type { FindingExposure, HndlReport } from "./hndl.js";
|
|
14
16
|
|
|
15
17
|
/** Minimal SARIF 2.1.0 log shape (kept permissive on purpose). */
|
|
16
18
|
export interface SarifLog {
|
|
@@ -36,6 +38,29 @@ export interface ReportOptions {
|
|
|
36
38
|
* {@link toJson}.
|
|
37
39
|
*/
|
|
38
40
|
catalog?: RuleMeta[];
|
|
41
|
+
/**
|
|
42
|
+
* Optional HNDL exposure analysis ({@link computeHndl}). When supplied, each
|
|
43
|
+
* finding gains its `exposure` fields (score, bound data asset, rationale)
|
|
44
|
+
* keyed by fingerprint, and the report carries the repo-level HNDL summary.
|
|
45
|
+
* Purely additive: it never changes finding identity, ordering, or exit codes.
|
|
46
|
+
*/
|
|
47
|
+
hndl?: HndlReport;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/** The per-finding exposure block emitted in JSON / SARIF, or undefined. */
|
|
51
|
+
function exposureFor(f: Finding, hndl: HndlReport | undefined): FindingExposure | undefined {
|
|
52
|
+
if (!hndl) return undefined;
|
|
53
|
+
return hndl.byFingerprint.get(findingFingerprint(f));
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/** The repo HNDL summary block shared by JSON output and the SARIF run. */
|
|
57
|
+
function hndlSummaryBlock(hndl: HndlReport): Record<string, unknown> {
|
|
58
|
+
return {
|
|
59
|
+
modelVersion: hndl.modelVersion,
|
|
60
|
+
horizon: hndl.horizon,
|
|
61
|
+
summary: hndl.summary,
|
|
62
|
+
assets: hndl.assets,
|
|
63
|
+
};
|
|
39
64
|
}
|
|
40
65
|
|
|
41
66
|
const SARIF_SCHEMA =
|
|
@@ -105,6 +130,16 @@ function sarifRule(spec: {
|
|
|
105
130
|
};
|
|
106
131
|
}
|
|
107
132
|
|
|
133
|
+
/** SARIF result.properties fragment for a finding's HNDL exposure, or empty. */
|
|
134
|
+
function exposureProperties(exposure: FindingExposure | undefined): Record<string, unknown> {
|
|
135
|
+
if (!exposure) return {};
|
|
136
|
+
return {
|
|
137
|
+
exposureScore: exposure.exposureScore,
|
|
138
|
+
dataAsset: exposure.dataAsset,
|
|
139
|
+
exposureRationale: exposure.rationale,
|
|
140
|
+
};
|
|
141
|
+
}
|
|
142
|
+
|
|
108
143
|
/** Serialize a scan result as SARIF 2.1.0. */
|
|
109
144
|
export function toSarif(result: ScanResult, opts?: ReportOptions): SarifLog {
|
|
110
145
|
const redactSnippets = opts?.redactSnippets ?? false;
|
|
@@ -171,6 +206,10 @@ export function toSarif(result: ScanResult, opts?: ReportOptions): SarifLog {
|
|
|
171
206
|
// edit above it. `quantakrypto/v1` names our scheme.
|
|
172
207
|
partialFingerprints: { "quantakrypto/v1": fingerprintFinding(f) },
|
|
173
208
|
properties: {
|
|
209
|
+
// Same stable identity mirrored into properties so non-GitHub SARIF
|
|
210
|
+
// consumers (our platform ingest) can read one uniform `fingerprint`
|
|
211
|
+
// field across JSON and SARIF without reaching into partialFingerprints.
|
|
212
|
+
fingerprint: fingerprintFinding(f),
|
|
174
213
|
category: f.category,
|
|
175
214
|
severity: f.severity,
|
|
176
215
|
confidence: f.confidence,
|
|
@@ -178,6 +217,7 @@ export function toSarif(result: ScanResult, opts?: ReportOptions): SarifLog {
|
|
|
178
217
|
...(f.algorithm ? { algorithm: f.algorithm } : {}),
|
|
179
218
|
...(f.remediation ? { remediation: f.remediation } : {}),
|
|
180
219
|
...(f.cwe ? { cwe: f.cwe } : {}),
|
|
220
|
+
...exposureProperties(exposureFor(f, opts?.hndl)),
|
|
181
221
|
},
|
|
182
222
|
...(f.cwe
|
|
183
223
|
? {
|
|
@@ -233,6 +273,7 @@ export function toSarif(result: ScanResult, opts?: ReportOptions): SarifLog {
|
|
|
233
273
|
},
|
|
234
274
|
},
|
|
235
275
|
...(taxonomies.length > 0 ? { taxonomies } : {}),
|
|
276
|
+
...(opts?.hndl ? { properties: { hndl: hndlSummaryBlock(opts.hndl) } } : {}),
|
|
236
277
|
results,
|
|
237
278
|
},
|
|
238
279
|
],
|
|
@@ -258,6 +299,7 @@ function securitySeverity(severity: Severity): string {
|
|
|
258
299
|
/** Serialize a scan result as a plain JSON-friendly object. */
|
|
259
300
|
export function toJson(result: ScanResult, opts?: ReportOptions): Record<string, unknown> {
|
|
260
301
|
const redactSnippets = opts?.redactSnippets ?? false;
|
|
302
|
+
const hndl = opts?.hndl;
|
|
261
303
|
return {
|
|
262
304
|
toolVersion: result.toolVersion,
|
|
263
305
|
root: result.root,
|
|
@@ -273,25 +315,48 @@ export function toJson(result: ScanResult, opts?: ReportOptions): Record<string,
|
|
|
273
315
|
byCategory: result.inventory.byCategory,
|
|
274
316
|
byAlgorithm: result.inventory.byAlgorithm,
|
|
275
317
|
},
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
318
|
+
...(hndl ? { hndl: hndlSummaryBlock(hndl) } : {}),
|
|
319
|
+
findings: result.findings.map((f) => {
|
|
320
|
+
const exposure = exposureFor(f, hndl);
|
|
321
|
+
return {
|
|
322
|
+
// Stable, line-INSENSITIVE identity of the finding: sha256 of
|
|
323
|
+
// ruleId | normalized-POSIX-repo-relative-path | normalized-snippet
|
|
324
|
+
// (the SARIF partialFingerprints trick, line number deliberately
|
|
325
|
+
// excluded). Reused verbatim from the baseline module so JSON identity,
|
|
326
|
+
// SARIF partialFingerprints, and the baseline suppression set are one and
|
|
327
|
+
// the same value. A line move does NOT change it; when no snippet context
|
|
328
|
+
// exists it falls back to ruleId|path. This is the cross-run identity the
|
|
329
|
+
// platform keys posture drift on.
|
|
330
|
+
fingerprint: fingerprintFinding(f),
|
|
331
|
+
ruleId: f.ruleId,
|
|
332
|
+
title: f.title,
|
|
333
|
+
category: f.category,
|
|
334
|
+
severity: f.severity,
|
|
335
|
+
confidence: f.confidence,
|
|
336
|
+
algorithm: f.algorithm,
|
|
337
|
+
hndl: f.hndl,
|
|
338
|
+
message: f.message,
|
|
339
|
+
remediation: f.remediation,
|
|
340
|
+
cwe: f.cwe,
|
|
341
|
+
location: {
|
|
342
|
+
file: f.location.file,
|
|
343
|
+
line: f.location.line,
|
|
344
|
+
column: f.location.column,
|
|
345
|
+
endLine: f.location.endLine,
|
|
346
|
+
snippet: emittedSnippet(f, redactSnippets),
|
|
347
|
+
},
|
|
348
|
+
...(exposure
|
|
349
|
+
? {
|
|
350
|
+
exposure: {
|
|
351
|
+
fingerprint: exposure.fingerprint,
|
|
352
|
+
exposureScore: exposure.exposureScore,
|
|
353
|
+
dataAsset: exposure.dataAsset,
|
|
354
|
+
rationale: exposure.rationale,
|
|
355
|
+
},
|
|
356
|
+
}
|
|
357
|
+
: {}),
|
|
358
|
+
};
|
|
359
|
+
}),
|
|
295
360
|
};
|
|
296
361
|
}
|
|
297
362
|
|
package/src/types.ts
CHANGED
|
@@ -95,7 +95,15 @@ export interface Finding {
|
|
|
95
95
|
}
|
|
96
96
|
|
|
97
97
|
/** Package ecosystems the dependency scanner understands. */
|
|
98
|
-
export type DependencyEcosystem =
|
|
98
|
+
export type DependencyEcosystem =
|
|
99
|
+
| "npm"
|
|
100
|
+
| "pypi"
|
|
101
|
+
| "cargo"
|
|
102
|
+
| "go"
|
|
103
|
+
| "maven"
|
|
104
|
+
| "rubygems"
|
|
105
|
+
| "nuget"
|
|
106
|
+
| "composer";
|
|
99
107
|
|
|
100
108
|
/** A known quantum-vulnerable dependency entry. */
|
|
101
109
|
export interface VulnerableDependency {
|