@dailephd/my-dev-kit-lab 0.2.1 → 0.3.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/LICENSE +21 -0
- package/README.md +36 -24
- package/dist/scripts/audits/runAudit.js +101 -0
- package/dist/scripts/security/validate.js +68 -29
- package/dist/src/audits/codeRot/codeRotDetectors.js +17 -0
- package/dist/src/audits/codeRot/detectors/architectureDriftDetector.js +271 -0
- package/dist/src/audits/codeRot/detectors/crossPlatformRotDetector.js +214 -0
- package/dist/src/audits/codeRot/detectors/deadCodeCandidateDetector.js +344 -0
- package/dist/src/audits/codeRot/detectors/dependencyEnvironmentRotDetector.js +244 -0
- package/dist/src/audits/codeRot/detectors/docsCodeMismatchDetector.js +219 -0
- package/dist/src/audits/codeRot/detectors/duplicateImplementationDetector.js +273 -0
- package/dist/src/audits/codeRot/detectors/packageReleaseRotDetector.js +325 -0
- package/dist/src/audits/codeRot/detectors/securityValidationAssumptionRotDetector.js +385 -0
- package/dist/src/audits/codeRot/detectors/staleCommandReferenceDetector.js +135 -0
- package/dist/src/audits/codeRot/detectors/testRotDetector.js +348 -0
- package/dist/src/audits/codeRot/utils/boundedRead.js +23 -0
- package/dist/src/audits/codeRot/utils/docClaimPatterns.js +62 -0
- package/dist/src/audits/codeRot/utils/docCommandReferences.js +64 -0
- package/dist/src/audits/codeRot/utils/filePatternUtils.js +47 -0
- package/dist/src/audits/codeRot/utils/issueFactories.js +38 -0
- package/dist/src/audits/codeRot/utils/textLines.js +12 -0
- package/dist/src/audits/core/auditConfig.js +125 -0
- package/dist/src/audits/core/auditExitCode.js +75 -0
- package/dist/src/audits/core/auditIssue.js +20 -0
- package/dist/src/audits/core/auditRegistry.js +40 -0
- package/dist/src/audits/core/auditRunner.js +71 -0
- package/dist/src/audits/core/auditTarget.js +19 -0
- package/dist/src/audits/core/auditTypes.js +30 -0
- package/dist/src/audits/core/projectInventory.js +308 -0
- package/dist/src/audits/core/sourceOfTruth.js +339 -0
- package/dist/src/audits/core/textLines.js +24 -0
- package/dist/src/audits/report/auditReportModel.js +241 -0
- package/dist/src/audits/report/auditReportPaths.js +13 -0
- package/dist/src/audits/report/renderAuditJsonReport.js +29 -0
- package/dist/src/audits/report/renderAuditTextReport.js +165 -0
- package/dist/src/audits/report/sanitizeAuditText.js +39 -0
- package/dist/src/audits/report/writeAuditReports.js +27 -0
- package/dist/src/core/resolveCommand.js +53 -2
- package/dist/src/securityValidation/attackScenarios/attackProfile.js +20 -0
- package/dist/src/securityValidation/attackScenarios/attackResult.js +37 -0
- package/dist/src/securityValidation/attackScenarios/attackRunner.js +153 -0
- package/dist/src/securityValidation/attackScenarios/attackScenario.js +21 -0
- package/dist/src/securityValidation/attackScenarios/boundedSourceScan.js +100 -0
- package/dist/src/securityValidation/attackScenarios/exploitEvidence.js +69 -0
- package/dist/src/securityValidation/attackScenarios/payloadCorpus.js +0 -0
- package/dist/src/securityValidation/attackScenarios/profiles/localToolProfile.js +10 -0
- package/dist/src/securityValidation/attackScenarios/profiles/nodeCliPackageProfile.js +20 -0
- package/dist/src/securityValidation/attackScenarios/profiles/npmPackageProfile.js +10 -0
- package/dist/src/securityValidation/attackScenarios/reportSchemaGuard.js +41 -0
- package/dist/src/securityValidation/attackScenarios/scenarios/configInjectionScenario.js +144 -0
- package/dist/src/securityValidation/attackScenarios/scenarios/networkAssumptionScenario.js +129 -0
- package/dist/src/securityValidation/attackScenarios/scenarios/outputBoundaryScenario.js +69 -0
- package/dist/src/securityValidation/attackScenarios/scenarios/packageBoundaryScenario.js +118 -0
- package/dist/src/securityValidation/attackScenarios/scenarios/pathTraversalScenario.js +119 -0
- package/dist/src/securityValidation/attackScenarios/scenarios/reportPoisoningScenario.js +176 -0
- package/dist/src/securityValidation/attackScenarios/scenarios/secretLeakageScenario.js +143 -0
- package/dist/src/securityValidation/attackScenarios/scenarios/subprocessInjectionScenario.js +117 -0
- package/dist/src/securityValidation/attackScenarios/scenarios/targetSandboxScenario.js +118 -0
- package/dist/src/securityValidation/attackScenarios/targetSnapshot.js +70 -0
- package/dist/src/securityValidation/packageChecks/forbiddenPackageContents.js +4 -1
- package/dist/src/securityValidation/report/renderSecurityReport.js +135 -13
- package/dist/src/securityValidation/types.js +7 -0
- package/dist/src/securityValidation/validate/cliOptions.js +182 -0
- package/dist/src/securityValidation/validate/runSecurityValidation.js +134 -35
- package/dist/src/securityValidation/validate/verdict.js +147 -0
- package/package.json +18 -17
|
@@ -1,7 +1,16 @@
|
|
|
1
1
|
import { verdictToHumanLabel } from "../validate/verdict.js";
|
|
2
|
+
import { stripUnsafeControlChars } from "../attackScenarios/exploitEvidence.js";
|
|
2
3
|
// ---------------------------------------------------------------------------
|
|
3
4
|
// Human-readable text report
|
|
4
5
|
// ---------------------------------------------------------------------------
|
|
6
|
+
// v0.2.2 Batch 4 — sanitizes text-report content that may originate from
|
|
7
|
+
// scanned source, target output, or an attack-scenario payload before it is
|
|
8
|
+
// printed. Strips ANSI/control bytes (report-poisoning defense); preserves
|
|
9
|
+
// ordinary whitespace. JSON output needs no equivalent helper — JSON.stringify
|
|
10
|
+
// already escapes control characters by construction.
|
|
11
|
+
export function sanitizeForTextReport(raw) {
|
|
12
|
+
return stripUnsafeControlChars(raw);
|
|
13
|
+
}
|
|
5
14
|
function pad(label, width = 36) {
|
|
6
15
|
return label.padEnd(width, " ");
|
|
7
16
|
}
|
|
@@ -24,7 +33,7 @@ function divider(char = "-", width = 72) {
|
|
|
24
33
|
}
|
|
25
34
|
export function renderTextReport(report) {
|
|
26
35
|
const lines = [];
|
|
27
|
-
const { metadata, allChecks, allFindings, verdict, recommendedNextStep } = report;
|
|
36
|
+
const { metadata, allChecks, allFindings, verdict, recommendedNextStep, attackResults, verdictReasonSummary } = report;
|
|
28
37
|
lines.push(divider("="));
|
|
29
38
|
lines.push("SECURITY VALIDATION REPORT");
|
|
30
39
|
lines.push(divider("="));
|
|
@@ -41,6 +50,23 @@ export function renderTextReport(report) {
|
|
|
41
50
|
lines.push(`Commit : ${metadata.commit}`);
|
|
42
51
|
lines.push(`Generated : ${metadata.generatedAt}`);
|
|
43
52
|
lines.push(`Duration : ${formatDuration(metadata.totalDurationMs)}`);
|
|
53
|
+
if (metadata.profile) {
|
|
54
|
+
lines.push(`Profile : ${metadata.profile}`);
|
|
55
|
+
}
|
|
56
|
+
if (metadata.selectedChecks && metadata.selectedChecks.length > 0) {
|
|
57
|
+
lines.push(`Checks : ${metadata.selectedChecks.join(", ")}`);
|
|
58
|
+
}
|
|
59
|
+
if (metadata.failOnThreshold) {
|
|
60
|
+
const breachNote = metadata.failOnBreached === true
|
|
61
|
+
? " — BREACHED"
|
|
62
|
+
: metadata.failOnBreached === false
|
|
63
|
+
? " — not breached"
|
|
64
|
+
: "";
|
|
65
|
+
lines.push(`Fail-on : ${metadata.failOnThreshold}${breachNote}`);
|
|
66
|
+
}
|
|
67
|
+
if (metadata.isFullReleaseGate === false) {
|
|
68
|
+
lines.push(`Scope : NARROWED — selected checks do not include the full classic release gate (deps, package, static, cli-adversarial, fuzz). This run does not represent full release readiness.`);
|
|
69
|
+
}
|
|
44
70
|
lines.push("");
|
|
45
71
|
// --- 1. Executive Summary ---
|
|
46
72
|
lines.push(divider());
|
|
@@ -60,7 +86,7 @@ export function renderTextReport(report) {
|
|
|
60
86
|
lines.push(` ${sev.padEnd(16)} : ${count}`);
|
|
61
87
|
}
|
|
62
88
|
}
|
|
63
|
-
lines.push(`Next step : ${recommendedNextStep}`);
|
|
89
|
+
lines.push(`Next step : ${sanitizeForTextReport(recommendedNextStep)}`);
|
|
64
90
|
lines.push("");
|
|
65
91
|
// --- Check sections ---
|
|
66
92
|
const sectionDefs = [
|
|
@@ -143,7 +169,7 @@ export function renderTextReport(report) {
|
|
|
143
169
|
continue;
|
|
144
170
|
}
|
|
145
171
|
for (const check of sectionChecks) {
|
|
146
|
-
lines.push(`[${statusIcon(check.status)}] ${pad(check.name)} ${formatDuration(check.durationMs)}`);
|
|
172
|
+
lines.push(`[${statusIcon(check.status)}] ${pad(sanitizeForTextReport(check.name))} ${formatDuration(check.durationMs)}`);
|
|
147
173
|
if (check.command) {
|
|
148
174
|
lines.push(` Command: ${check.command}`);
|
|
149
175
|
}
|
|
@@ -154,24 +180,87 @@ export function renderTextReport(report) {
|
|
|
154
180
|
lines.push(` Exit code: ${String(check.exitCode)}`);
|
|
155
181
|
}
|
|
156
182
|
if (check.stdoutSummary) {
|
|
157
|
-
lines.push(` Stdout: ${check.stdoutSummary.slice(0, 200)}`);
|
|
183
|
+
lines.push(` Stdout: ${sanitizeForTextReport(check.stdoutSummary).slice(0, 200)}`);
|
|
158
184
|
}
|
|
159
185
|
if (check.stderrSummary) {
|
|
160
|
-
lines.push(` Stderr: ${check.stderrSummary.slice(0, 200)}`);
|
|
186
|
+
lines.push(` Stderr: ${sanitizeForTextReport(check.stderrSummary).slice(0, 200)}`);
|
|
161
187
|
}
|
|
162
188
|
if (check.status === "skipped" && check.skippedReason) {
|
|
163
|
-
lines.push(` Reason: ${check.skippedReason}`);
|
|
189
|
+
lines.push(` Reason: ${sanitizeForTextReport(check.skippedReason)}`);
|
|
164
190
|
}
|
|
165
191
|
if (check.findings.length > 0) {
|
|
166
192
|
for (const f of check.findings) {
|
|
167
|
-
lines.push(` [${f.severity.toUpperCase()}] ${f.title}`);
|
|
193
|
+
lines.push(` [${f.severity.toUpperCase()}] ${sanitizeForTextReport(f.title)}`);
|
|
168
194
|
if (f.description)
|
|
169
|
-
lines.push(` ${f.description.slice(0, 120)}`);
|
|
195
|
+
lines.push(` ${sanitizeForTextReport(f.description).slice(0, 120)}`);
|
|
170
196
|
}
|
|
171
197
|
}
|
|
172
198
|
}
|
|
173
199
|
lines.push("");
|
|
174
200
|
}
|
|
201
|
+
// --- Attack Scenario Framework (v0.2.2) ---
|
|
202
|
+
// Only rendered when the run selected attack-scenario-shaped checks.
|
|
203
|
+
// Batch 3 registered concrete scenarios for boundary/subprocess; secrets
|
|
204
|
+
// and network still resolve to an explicit "no scenarios registered"
|
|
205
|
+
// placeholder (scenarioId ending in "-no-scenarios-registered") — the
|
|
206
|
+
// pending-framework note is only shown when at least one such placeholder
|
|
207
|
+
// is present in this run, not unconditionally.
|
|
208
|
+
if (attackResults && attackResults.length > 0) {
|
|
209
|
+
const pendingResults = attackResults.filter((r) => r.scenarioId.endsWith("-no-scenarios-registered"));
|
|
210
|
+
const concreteResults = attackResults.filter((r) => !r.scenarioId.endsWith("-no-scenarios-registered"));
|
|
211
|
+
lines.push(divider());
|
|
212
|
+
lines.push("ATTACK SCENARIO FRAMEWORK (v0.2.2)");
|
|
213
|
+
lines.push(divider());
|
|
214
|
+
if (concreteResults.length > 0) {
|
|
215
|
+
lines.push(`${concreteResults.length} concrete attack scenario(s) executed.`);
|
|
216
|
+
}
|
|
217
|
+
if (pendingResults.length > 0) {
|
|
218
|
+
const pendingChecks = [...new Set(pendingResults.map((r) => r.checkId))].join(", ");
|
|
219
|
+
lines.push(`Concrete attack scenarios are still pending for: ${pendingChecks}. This is not evidence that those check groups are safe.`);
|
|
220
|
+
}
|
|
221
|
+
lines.push("");
|
|
222
|
+
for (const result of attackResults) {
|
|
223
|
+
lines.push(`[${statusIcon(result.status === "blocked" ? "failed" : result.status)}] ${pad(sanitizeForTextReport(result.scenarioTitle))} (check: ${result.checkId}, profile: ${result.profileId})`);
|
|
224
|
+
if (result.skippedReason) {
|
|
225
|
+
lines.push(` Reason: ${sanitizeForTextReport(result.skippedReason)}`);
|
|
226
|
+
}
|
|
227
|
+
if (result.errorSummary) {
|
|
228
|
+
lines.push(` Error: ${sanitizeForTextReport(result.errorSummary)}`);
|
|
229
|
+
}
|
|
230
|
+
if (result.recommendation) {
|
|
231
|
+
lines.push(` Recommendation: ${sanitizeForTextReport(result.recommendation)}`);
|
|
232
|
+
}
|
|
233
|
+
if (result.evidence.length > 0) {
|
|
234
|
+
for (const e of result.evidence.slice(0, 10)) {
|
|
235
|
+
const preview = e.redactedPreview ?? e.observedBehavior ?? "(no preview)";
|
|
236
|
+
lines.push(` [${e.kind}] ${sanitizeForTextReport(preview)}`);
|
|
237
|
+
}
|
|
238
|
+
if (result.evidence.length > 10) {
|
|
239
|
+
lines.push(` ... ${result.evidence.length - 10} more evidence item(s)`);
|
|
240
|
+
}
|
|
241
|
+
}
|
|
242
|
+
}
|
|
243
|
+
lines.push("");
|
|
244
|
+
}
|
|
245
|
+
// --- Verdict Reasoning (v0.2.2 Batch 5) ---
|
|
246
|
+
// Only rendered when the caller supplied the summary (additive; existing
|
|
247
|
+
// report construction that omits it is unaffected).
|
|
248
|
+
if (verdictReasonSummary) {
|
|
249
|
+
lines.push(divider());
|
|
250
|
+
lines.push("VERDICT REASONING (v0.2.2)");
|
|
251
|
+
lines.push(divider());
|
|
252
|
+
lines.push(`Release blockers : ${verdictReasonSummary.releaseBlockerCount}`);
|
|
253
|
+
lines.push(`Target-project blockers : ${verdictReasonSummary.targetProjectBlockerCount}`);
|
|
254
|
+
lines.push(`Tool-framework blockers : ${verdictReasonSummary.toolFrameworkBlockerCount}`);
|
|
255
|
+
lines.push(`Environment inconclusive : ${verdictReasonSummary.environmentInconclusiveCount}`);
|
|
256
|
+
lines.push(`Required evidence missing : ${verdictReasonSummary.requiredEvidenceMissingCount}`);
|
|
257
|
+
lines.push(`Scanner findings : ${verdictReasonSummary.scannerFindingCount}`);
|
|
258
|
+
lines.push(`Adversarial scenario fail : ${verdictReasonSummary.adversarialScenarioFailureCount}`);
|
|
259
|
+
lines.push(`Adversarial scenario skip : ${verdictReasonSummary.adversarialScenarioSkippedCount}`);
|
|
260
|
+
lines.push(`Optional skipped tools : ${verdictReasonSummary.optionalSkippedToolCount}`);
|
|
261
|
+
lines.push(`Informational evidence : ${verdictReasonSummary.informationalEvidenceCount}`);
|
|
262
|
+
lines.push("");
|
|
263
|
+
}
|
|
175
264
|
// --- 20. Findings by Severity ---
|
|
176
265
|
lines.push(divider());
|
|
177
266
|
lines.push("20. FINDINGS BY SEVERITY");
|
|
@@ -186,11 +275,11 @@ export function renderTextReport(report) {
|
|
|
186
275
|
continue;
|
|
187
276
|
lines.push(`${sev.toUpperCase()} (${sevFindings.length}):`);
|
|
188
277
|
for (const f of sevFindings) {
|
|
189
|
-
lines.push(` [${f.id}] ${f.title}`);
|
|
278
|
+
lines.push(` [${f.id}] ${sanitizeForTextReport(f.title)}`);
|
|
190
279
|
if (f.description)
|
|
191
|
-
lines.push(` ${f.description.slice(0, 120)}`);
|
|
280
|
+
lines.push(` ${sanitizeForTextReport(f.description).slice(0, 120)}`);
|
|
192
281
|
if (f.recommendation)
|
|
193
|
-
lines.push(` Recommendation: ${f.recommendation}`);
|
|
282
|
+
lines.push(` Recommendation: ${sanitizeForTextReport(f.recommendation)}`);
|
|
194
283
|
}
|
|
195
284
|
}
|
|
196
285
|
}
|
|
@@ -205,7 +294,7 @@ export function renderTextReport(report) {
|
|
|
205
294
|
lines.push(divider());
|
|
206
295
|
lines.push("22. RECOMMENDED NEXT STEP");
|
|
207
296
|
lines.push(divider());
|
|
208
|
-
lines.push(recommendedNextStep);
|
|
297
|
+
lines.push(sanitizeForTextReport(recommendedNextStep));
|
|
209
298
|
lines.push("");
|
|
210
299
|
lines.push(divider("="));
|
|
211
300
|
return lines.join("\n");
|
|
@@ -214,7 +303,7 @@ export function renderTextReport(report) {
|
|
|
214
303
|
// JSON report
|
|
215
304
|
// ---------------------------------------------------------------------------
|
|
216
305
|
export function renderJsonReport(report) {
|
|
217
|
-
const { metadata, allChecks, allFindings, verdict, recommendedNextStep } = report;
|
|
306
|
+
const { metadata, allChecks, allFindings, verdict, recommendedNextStep, attackResults, verdictReasonSummary } = report;
|
|
218
307
|
const sanitizedChecks = allChecks.map((c) => ({
|
|
219
308
|
id: c.id,
|
|
220
309
|
name: c.name,
|
|
@@ -243,6 +332,33 @@ export function renderJsonReport(report) {
|
|
|
243
332
|
recommendation: f.recommendation ?? undefined,
|
|
244
333
|
releaseImpact: f.releaseImpact,
|
|
245
334
|
}));
|
|
335
|
+
const sanitizedAttackResults = (attackResults ?? []).map((r) => ({
|
|
336
|
+
scenarioId: r.scenarioId,
|
|
337
|
+
scenarioTitle: r.scenarioTitle,
|
|
338
|
+
checkId: r.checkId,
|
|
339
|
+
profileId: r.profileId,
|
|
340
|
+
status: r.status,
|
|
341
|
+
severity: r.severity,
|
|
342
|
+
confidence: r.confidence,
|
|
343
|
+
category: r.category,
|
|
344
|
+
recommendation: r.recommendation ?? null,
|
|
345
|
+
skippedReason: r.skippedReason ?? null,
|
|
346
|
+
errorSummary: r.errorSummary ?? null,
|
|
347
|
+
evidence: r.evidence.map((e) => ({
|
|
348
|
+
id: e.id,
|
|
349
|
+
kind: e.kind,
|
|
350
|
+
source: e.source,
|
|
351
|
+
filePath: e.filePath ?? null,
|
|
352
|
+
line: e.line ?? null,
|
|
353
|
+
commandCwd: e.commandCwd ?? null,
|
|
354
|
+
commandSummary: e.commandSummary ?? null,
|
|
355
|
+
exitCode: e.exitCode ?? null,
|
|
356
|
+
expectedBehavior: e.expectedBehavior ?? null,
|
|
357
|
+
observedBehavior: e.observedBehavior ?? null,
|
|
358
|
+
redactedPreview: e.redactedPreview ?? null,
|
|
359
|
+
confidence: e.confidence,
|
|
360
|
+
})),
|
|
361
|
+
}));
|
|
246
362
|
const output = {
|
|
247
363
|
schemaVersion: 1,
|
|
248
364
|
metadata,
|
|
@@ -256,12 +372,18 @@ export function renderJsonReport(report) {
|
|
|
256
372
|
blockerFindings: allFindings.filter((f) => f.severity === "blocker").length,
|
|
257
373
|
majorFindings: allFindings.filter((f) => f.severity === "major").length,
|
|
258
374
|
minorFindings: allFindings.filter((f) => f.severity === "minor").length,
|
|
375
|
+
informationalFindings: allFindings.filter((f) => f.severity === "informational").length,
|
|
259
376
|
},
|
|
260
377
|
verdict,
|
|
261
378
|
verdictLabel: verdictToHumanLabel(verdict),
|
|
262
379
|
recommendedNextStep,
|
|
263
380
|
checks: sanitizedChecks,
|
|
264
381
|
findings: sanitizedFindings,
|
|
382
|
+
attackScenarios: {
|
|
383
|
+
count: sanitizedAttackResults.length,
|
|
384
|
+
results: sanitizedAttackResults,
|
|
385
|
+
},
|
|
386
|
+
verdictReasonSummary: verdictReasonSummary ?? null,
|
|
265
387
|
};
|
|
266
388
|
return JSON.stringify(output, null, 2);
|
|
267
389
|
}
|
|
@@ -32,3 +32,10 @@ export const SECURITY_CHECK_CATEGORIES = [
|
|
|
32
32
|
"artifact-safety",
|
|
33
33
|
"report-generation",
|
|
34
34
|
];
|
|
35
|
+
export const VERDICT_IMPACTS = [
|
|
36
|
+
"release-blocker",
|
|
37
|
+
"target-project-blocker",
|
|
38
|
+
"tool-framework-blocker",
|
|
39
|
+
"adversarial-scenario-failure",
|
|
40
|
+
"informational-evidence",
|
|
41
|
+
];
|
|
@@ -0,0 +1,182 @@
|
|
|
1
|
+
import path from "node:path";
|
|
2
|
+
// ---------------------------------------------------------------------------
|
|
3
|
+
// v0.2.2 Batch 1 — security:validate command/config surface.
|
|
4
|
+
//
|
|
5
|
+
// This module parses and normalizes --checks, --profile, --format, --fail-on,
|
|
6
|
+
// and --out. It intentionally does not implement attack scenarios, profile
|
|
7
|
+
// behavior, or verdict-policy changes — those are later v0.2.2 batches. This
|
|
8
|
+
// batch only builds a stable, extensible config surface.
|
|
9
|
+
// ---------------------------------------------------------------------------
|
|
10
|
+
export const SECURITY_CHECK_IDS = [
|
|
11
|
+
"deps",
|
|
12
|
+
"package",
|
|
13
|
+
"static",
|
|
14
|
+
"cli-adversarial",
|
|
15
|
+
"fuzz",
|
|
16
|
+
"boundary",
|
|
17
|
+
"subprocess",
|
|
18
|
+
"secrets",
|
|
19
|
+
"network",
|
|
20
|
+
];
|
|
21
|
+
// Checks with real behavior wired in Batch 1.
|
|
22
|
+
export const IMPLEMENTED_SECURITY_CHECK_IDS = [
|
|
23
|
+
"deps",
|
|
24
|
+
"package",
|
|
25
|
+
"static",
|
|
26
|
+
"cli-adversarial",
|
|
27
|
+
"fuzz",
|
|
28
|
+
];
|
|
29
|
+
// Checks accepted as valid --checks values but not yet implemented. Selecting
|
|
30
|
+
// one of these must never be reported as passed.
|
|
31
|
+
export const PLANNED_SECURITY_CHECK_IDS = [
|
|
32
|
+
"boundary",
|
|
33
|
+
"subprocess",
|
|
34
|
+
"secrets",
|
|
35
|
+
"network",
|
|
36
|
+
];
|
|
37
|
+
export const DEFAULT_SECURITY_CHECKS = IMPLEMENTED_SECURITY_CHECK_IDS;
|
|
38
|
+
export const SECURITY_PROFILE_IDS = ["node-cli-package", "local-tool", "npm-package"];
|
|
39
|
+
export const DEFAULT_SECURITY_PROFILE = "node-cli-package";
|
|
40
|
+
export const SECURITY_OUTPUT_FORMATS = ["text", "json"];
|
|
41
|
+
export const DEFAULT_SECURITY_OUTPUT_FORMATS = ["text", "json"];
|
|
42
|
+
// Distinct vocabulary from SecuritySeverity (blocker/major/minor/informational/skipped).
|
|
43
|
+
// Batch 1 only carries this value through config; verdict-policy integration is later.
|
|
44
|
+
export const SECURITY_FAIL_ON_THRESHOLDS = ["blocker", "high", "medium", "low"];
|
|
45
|
+
export const DEFAULT_SECURITY_FAIL_ON_THRESHOLD = "blocker";
|
|
46
|
+
const FLAGS_WITH_VALUE = {
|
|
47
|
+
"--target": "target",
|
|
48
|
+
"-t": "target",
|
|
49
|
+
"--out": "out",
|
|
50
|
+
"--report-prefix": "reportPrefix",
|
|
51
|
+
"--checks": "checks",
|
|
52
|
+
"--profile": "profile",
|
|
53
|
+
"--format": "format",
|
|
54
|
+
"--fail-on": "failOn",
|
|
55
|
+
};
|
|
56
|
+
export function parseSecurityValidateArgs(argv) {
|
|
57
|
+
const result = {};
|
|
58
|
+
for (let i = 0; i < argv.length; i++) {
|
|
59
|
+
const arg = argv[i];
|
|
60
|
+
const key = FLAGS_WITH_VALUE[arg];
|
|
61
|
+
if (!key)
|
|
62
|
+
continue;
|
|
63
|
+
if (i + 1 >= argv.length) {
|
|
64
|
+
throw new Error(`Missing value for ${arg}. Expected a value after ${arg}.`);
|
|
65
|
+
}
|
|
66
|
+
result[key] = argv[++i];
|
|
67
|
+
}
|
|
68
|
+
return result;
|
|
69
|
+
}
|
|
70
|
+
export function normalizeSecurityValidateConfig(raw, toolRoot) {
|
|
71
|
+
const checks = parseChecksOption(raw.checks);
|
|
72
|
+
const profile = parseProfileOption(raw.profile);
|
|
73
|
+
const formats = parseFormatOption(raw.format);
|
|
74
|
+
const failOn = parseFailOnOption(raw.failOn);
|
|
75
|
+
const out = raw.out !== undefined ? resolveOutDir(raw.out) : path.join(toolRoot, "reports", "security");
|
|
76
|
+
return {
|
|
77
|
+
target: raw.target,
|
|
78
|
+
out,
|
|
79
|
+
outWasDefault: raw.out === undefined,
|
|
80
|
+
reportPrefix: raw.reportPrefix,
|
|
81
|
+
checks: checks.value,
|
|
82
|
+
checksWereDefault: checks.wasDefault,
|
|
83
|
+
implementedChecks: checks.value.filter((c) => IMPLEMENTED_SECURITY_CHECK_IDS.includes(c)),
|
|
84
|
+
plannedChecksRequested: checks.value.filter((c) => PLANNED_SECURITY_CHECK_IDS.includes(c)),
|
|
85
|
+
profile: profile.value,
|
|
86
|
+
profileWasDefault: profile.wasDefault,
|
|
87
|
+
formats: formats.value,
|
|
88
|
+
formatsWereDefault: formats.wasDefault,
|
|
89
|
+
failOnThreshold: failOn.value,
|
|
90
|
+
failOnWasDefault: failOn.wasDefault,
|
|
91
|
+
};
|
|
92
|
+
}
|
|
93
|
+
// ---------------------------------------------------------------------------
|
|
94
|
+
// v0.2.2 Batch 5 — profile-aware default checks
|
|
95
|
+
// ---------------------------------------------------------------------------
|
|
96
|
+
// Applies a profile's declared default check ids when the user provided
|
|
97
|
+
// --profile but omitted --checks. Deliberately takes the profile's
|
|
98
|
+
// defaultCheckIds as a plain parameter rather than importing
|
|
99
|
+
// attackScenarios/attackProfile.ts here — that module already imports this
|
|
100
|
+
// one (SecurityProfileId/SECURITY_PROFILE_IDS), so importing it back would
|
|
101
|
+
// create a circular import. Callers (scripts/security/validate.ts) resolve
|
|
102
|
+
// the profile definition via resolveAttackProfile() and pass its
|
|
103
|
+
// defaultCheckIds in.
|
|
104
|
+
//
|
|
105
|
+
// No-op (returns config unchanged) unless BOTH:
|
|
106
|
+
// - the user did not pass --checks (checksWereDefault), AND
|
|
107
|
+
// - the user did pass --profile (profileWasDefault === false)
|
|
108
|
+
// This guarantees the true no-flag case (no --profile, no --checks) and any
|
|
109
|
+
// explicit --checks case are always unaffected.
|
|
110
|
+
export function applyProfileDefaultChecksIfApplicable(config, profileDefaultCheckIds) {
|
|
111
|
+
if (!config.checksWereDefault || config.profileWasDefault) {
|
|
112
|
+
return config;
|
|
113
|
+
}
|
|
114
|
+
const uniqueRequested = new Set(profileDefaultCheckIds);
|
|
115
|
+
const ordered = SECURITY_CHECK_IDS.filter((id) => uniqueRequested.has(id));
|
|
116
|
+
return {
|
|
117
|
+
...config,
|
|
118
|
+
checks: ordered,
|
|
119
|
+
implementedChecks: ordered.filter((c) => IMPLEMENTED_SECURITY_CHECK_IDS.includes(c)),
|
|
120
|
+
plannedChecksRequested: ordered.filter((c) => PLANNED_SECURITY_CHECK_IDS.includes(c)),
|
|
121
|
+
};
|
|
122
|
+
}
|
|
123
|
+
function splitAndNormalizeList(raw, flagName) {
|
|
124
|
+
const parts = raw.split(",").map((p) => p.trim());
|
|
125
|
+
if (parts.some((p) => p.length === 0)) {
|
|
126
|
+
throw new Error(`Invalid ${flagName} value: empty entries are not allowed. Got "${raw}".`);
|
|
127
|
+
}
|
|
128
|
+
return parts;
|
|
129
|
+
}
|
|
130
|
+
function parseChecksOption(raw) {
|
|
131
|
+
if (raw === undefined) {
|
|
132
|
+
return { value: [...DEFAULT_SECURITY_CHECKS], wasDefault: true };
|
|
133
|
+
}
|
|
134
|
+
const parts = splitAndNormalizeList(raw, "--checks");
|
|
135
|
+
const invalid = parts.filter((p) => !SECURITY_CHECK_IDS.includes(p));
|
|
136
|
+
if (invalid.length > 0) {
|
|
137
|
+
throw new Error(`Invalid --checks value(s): ${invalid.join(", ")}. Valid values are: ${SECURITY_CHECK_IDS.join(", ")}.`);
|
|
138
|
+
}
|
|
139
|
+
const unique = new Set(parts);
|
|
140
|
+
const ordered = SECURITY_CHECK_IDS.filter((id) => unique.has(id));
|
|
141
|
+
return { value: ordered, wasDefault: false };
|
|
142
|
+
}
|
|
143
|
+
function parseProfileOption(raw) {
|
|
144
|
+
if (raw === undefined) {
|
|
145
|
+
return { value: DEFAULT_SECURITY_PROFILE, wasDefault: true };
|
|
146
|
+
}
|
|
147
|
+
const value = raw.trim();
|
|
148
|
+
if (!SECURITY_PROFILE_IDS.includes(value)) {
|
|
149
|
+
throw new Error(`Invalid --profile value: "${raw}". Valid values are: ${SECURITY_PROFILE_IDS.join(", ")}.`);
|
|
150
|
+
}
|
|
151
|
+
return { value: value, wasDefault: false };
|
|
152
|
+
}
|
|
153
|
+
function parseFormatOption(raw) {
|
|
154
|
+
if (raw === undefined) {
|
|
155
|
+
return { value: [...DEFAULT_SECURITY_OUTPUT_FORMATS], wasDefault: true };
|
|
156
|
+
}
|
|
157
|
+
const parts = splitAndNormalizeList(raw, "--format");
|
|
158
|
+
const invalid = parts.filter((p) => !SECURITY_OUTPUT_FORMATS.includes(p));
|
|
159
|
+
if (invalid.length > 0) {
|
|
160
|
+
throw new Error(`Invalid --format value(s): ${invalid.join(", ")}. Valid values are: ${SECURITY_OUTPUT_FORMATS.join(", ")}.`);
|
|
161
|
+
}
|
|
162
|
+
const unique = new Set(parts);
|
|
163
|
+
const ordered = SECURITY_OUTPUT_FORMATS.filter((id) => unique.has(id));
|
|
164
|
+
return { value: ordered, wasDefault: false };
|
|
165
|
+
}
|
|
166
|
+
function parseFailOnOption(raw) {
|
|
167
|
+
if (raw === undefined) {
|
|
168
|
+
return { value: DEFAULT_SECURITY_FAIL_ON_THRESHOLD, wasDefault: true };
|
|
169
|
+
}
|
|
170
|
+
const value = raw.trim();
|
|
171
|
+
if (!SECURITY_FAIL_ON_THRESHOLDS.includes(value)) {
|
|
172
|
+
throw new Error(`Invalid --fail-on value: "${raw}". Valid values are: ${SECURITY_FAIL_ON_THRESHOLDS.join(", ")}.`);
|
|
173
|
+
}
|
|
174
|
+
return { value: value, wasDefault: false };
|
|
175
|
+
}
|
|
176
|
+
function resolveOutDir(raw) {
|
|
177
|
+
const trimmed = raw.trim();
|
|
178
|
+
if (trimmed.length === 0) {
|
|
179
|
+
throw new Error("Invalid --out value: path must not be empty.");
|
|
180
|
+
}
|
|
181
|
+
return path.resolve(trimmed);
|
|
182
|
+
}
|
|
@@ -6,10 +6,30 @@ import { runCodeqlCheck } from "../staticScans/codeql.js";
|
|
|
6
6
|
import { runSemgrepCheck } from "../staticScans/semgrep.js";
|
|
7
7
|
import { runAllFuzzTargets } from "../fuzz/fuzzHarness.js";
|
|
8
8
|
import { ALL_FUZZ_TARGETS } from "../fuzz/fuzzTargets.js";
|
|
9
|
-
import { calculateVerdict } from "./verdict.js";
|
|
9
|
+
import { calculateVerdict, summarizeVerdictReasoning } from "./verdict.js";
|
|
10
10
|
import { resolveValidationTarget, targetDescription as describeTarget } from "./resolveTarget.js";
|
|
11
11
|
import { DEFAULT_SECURITY_CONFIG } from "../config.js";
|
|
12
12
|
import { runCliSecuritySuiteCheck } from "./runCliSecuritySuiteCheck.js";
|
|
13
|
+
import { DEFAULT_SECURITY_CHECKS, DEFAULT_SECURITY_PROFILE, IMPLEMENTED_SECURITY_CHECK_IDS, PLANNED_SECURITY_CHECK_IDS, } from "./cliOptions.js";
|
|
14
|
+
import { runAttackScenarios } from "../attackScenarios/attackRunner.js";
|
|
15
|
+
import { toSecurityCheckResult } from "../attackScenarios/attackResult.js";
|
|
16
|
+
import { captureTargetSnapshot } from "../attackScenarios/targetSnapshot.js";
|
|
17
|
+
const NOT_SELECTED_REASON = "Not selected via --checks.";
|
|
18
|
+
function skippedCheck(id, name, category, skippedReason) {
|
|
19
|
+
const now = new Date().toISOString();
|
|
20
|
+
return {
|
|
21
|
+
id,
|
|
22
|
+
name,
|
|
23
|
+
category,
|
|
24
|
+
status: "skipped",
|
|
25
|
+
severity: "skipped",
|
|
26
|
+
startedAt: now,
|
|
27
|
+
finishedAt: now,
|
|
28
|
+
durationMs: 0,
|
|
29
|
+
findings: [],
|
|
30
|
+
skippedReason,
|
|
31
|
+
};
|
|
32
|
+
}
|
|
13
33
|
export async function runSecurityValidation(options) {
|
|
14
34
|
const toolRoot = options.cwd;
|
|
15
35
|
const config = {
|
|
@@ -20,9 +40,15 @@ export async function runSecurityValidation(options) {
|
|
|
20
40
|
};
|
|
21
41
|
// Resolve and validate the target project
|
|
22
42
|
const target = resolveValidationTarget(options.targetPath, toolRoot);
|
|
43
|
+
// v0.2.2 Batch 3: read-only git-status snapshot taken before any checks run,
|
|
44
|
+
// so the target-sandbox attack scenario (which runs last, among the
|
|
45
|
+
// attack-scenario checks) can distinguish pre-existing target dirtiness
|
|
46
|
+
// from changes caused by this validation run.
|
|
47
|
+
const targetSnapshotBefore = captureTargetSnapshot(target.targetRoot, target.hasGit);
|
|
23
48
|
const startedAt = new Date().toISOString();
|
|
24
49
|
const allChecks = [];
|
|
25
50
|
const allFindings = [];
|
|
51
|
+
const selectedChecks = new Set(options.selectedChecks ?? DEFAULT_SECURITY_CHECKS);
|
|
26
52
|
function collect(check) {
|
|
27
53
|
allChecks.push(check);
|
|
28
54
|
allFindings.push(...check.findings);
|
|
@@ -34,57 +60,122 @@ export async function runSecurityValidation(options) {
|
|
|
34
60
|
// --- Dependency checks (runs in target root) ---
|
|
35
61
|
// If target has no package.json/lockfile, runDependencyChecks will surface that
|
|
36
62
|
// as a structured finding rather than crashing.
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
63
|
+
if (selectedChecks.has("deps")) {
|
|
64
|
+
try {
|
|
65
|
+
const depOutput = await runDependencyChecks({ cwd: target.targetRoot, config });
|
|
66
|
+
collectMany(depOutput.checks);
|
|
67
|
+
}
|
|
68
|
+
catch (err) {
|
|
69
|
+
collect(errorCheck("dependency-checks", "Dependency checks", "dependency-audit", err));
|
|
70
|
+
}
|
|
40
71
|
}
|
|
41
|
-
|
|
42
|
-
|
|
72
|
+
else {
|
|
73
|
+
// Preserve the mandatory-check IDs verdict.ts relies on so deselecting this
|
|
74
|
+
// group still shows up as a skipped mandatory check, not a silent gap.
|
|
75
|
+
collect(skippedCheck("npm-audit-full", "npm audit (full)", "dependency-audit", NOT_SELECTED_REASON));
|
|
76
|
+
collect(skippedCheck("npm-audit-runtime", "npm audit (runtime)", "dependency-audit", NOT_SELECTED_REASON));
|
|
43
77
|
}
|
|
44
78
|
// --- Package content checks (runs in target root) ---
|
|
45
79
|
// Not all projects are publishable npm packages; runPackageChecks handles
|
|
46
80
|
// missing package.json gracefully.
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
81
|
+
if (selectedChecks.has("package")) {
|
|
82
|
+
try {
|
|
83
|
+
const pkgOutput = await runPackageChecks({ cwd: target.targetRoot, config });
|
|
84
|
+
collectMany(pkgOutput.checks);
|
|
85
|
+
}
|
|
86
|
+
catch (err) {
|
|
87
|
+
collect(errorCheck("package-content", "Package content checks", "package-content", err));
|
|
88
|
+
}
|
|
50
89
|
}
|
|
51
|
-
|
|
52
|
-
collect(
|
|
90
|
+
else {
|
|
91
|
+
collect(skippedCheck("npm-pack-dry-run", "npm pack --dry-run", "package-content", NOT_SELECTED_REASON));
|
|
53
92
|
}
|
|
54
93
|
// --- Static scans (scan target root; config from tool root) ---
|
|
55
|
-
|
|
56
|
-
|
|
94
|
+
if (selectedChecks.has("static")) {
|
|
95
|
+
try {
|
|
96
|
+
collect(await runCodeqlCheck({ cwd: toolRoot, targetRoot: target.targetRoot, timeoutMs: config.commandTimeoutMs }));
|
|
97
|
+
}
|
|
98
|
+
catch (err) {
|
|
99
|
+
collect(errorCheck("codeql-scan", "CodeQL static analysis", "static-scan", err));
|
|
100
|
+
}
|
|
101
|
+
try {
|
|
102
|
+
collect(await runSemgrepCheck({
|
|
103
|
+
targetRoot: target.targetRoot,
|
|
104
|
+
toolRoot,
|
|
105
|
+
timeoutMs: config.commandTimeoutMs,
|
|
106
|
+
}));
|
|
107
|
+
}
|
|
108
|
+
catch (err) {
|
|
109
|
+
collect(errorCheck("semgrep-scan", "Semgrep static analysis", "static-scan", err));
|
|
110
|
+
}
|
|
57
111
|
}
|
|
58
|
-
|
|
59
|
-
collect(
|
|
112
|
+
else {
|
|
113
|
+
collect(skippedCheck("codeql-scan", "CodeQL static analysis", "static-scan", NOT_SELECTED_REASON));
|
|
114
|
+
collect(skippedCheck("semgrep-scan", "Semgrep static analysis", "static-scan", NOT_SELECTED_REASON));
|
|
60
115
|
}
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
116
|
+
// --- Security suite check ---
|
|
117
|
+
// Self-validation runs the lab's own test:security suite.
|
|
118
|
+
// External validation runs the target project's test:security script when present.
|
|
119
|
+
if (selectedChecks.has("cli-adversarial")) {
|
|
120
|
+
collect(await runCliSecuritySuiteCheck({
|
|
64
121
|
toolRoot,
|
|
122
|
+
target,
|
|
65
123
|
timeoutMs: config.commandTimeoutMs,
|
|
66
124
|
}));
|
|
67
125
|
}
|
|
68
|
-
|
|
69
|
-
collect(
|
|
126
|
+
else {
|
|
127
|
+
collect(skippedCheck("cli-adversarial-suite", "Target security test suite", "cli-adversarial", NOT_SELECTED_REASON));
|
|
70
128
|
}
|
|
71
|
-
// --- Security suite check ---
|
|
72
|
-
// Self-validation runs the lab's own test:security suite.
|
|
73
|
-
// External validation runs the target project's test:security script when present.
|
|
74
|
-
collect(await runCliSecuritySuiteCheck({
|
|
75
|
-
toolRoot,
|
|
76
|
-
target,
|
|
77
|
-
timeoutMs: config.commandTimeoutMs,
|
|
78
|
-
}));
|
|
79
129
|
// --- Fuzz smoke (always tool-internal; tests library parser/helper code) ---
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
130
|
+
if (selectedChecks.has("fuzz")) {
|
|
131
|
+
try {
|
|
132
|
+
collect(await runAllFuzzTargets(ALL_FUZZ_TARGETS, {
|
|
133
|
+
seed: options.fuzzSeed ?? 0xdeadbeef,
|
|
134
|
+
iterations: options.fuzzIterations ?? 50,
|
|
135
|
+
}));
|
|
136
|
+
}
|
|
137
|
+
catch (err) {
|
|
138
|
+
collect(errorCheck("fuzz-smoke", "Fuzz smoke", "fuzz-smoke", err));
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
else {
|
|
142
|
+
collect(skippedCheck("fuzz-smoke", "Fuzz smoke", "fuzz-smoke", NOT_SELECTED_REASON));
|
|
85
143
|
}
|
|
86
|
-
|
|
87
|
-
|
|
144
|
+
// --- Attack-scenario checks (boundary, subprocess, secrets, network) ---
|
|
145
|
+
// v0.2.2 Batch 2: routed through the attack-scenario runner. The registry
|
|
146
|
+
// is empty in Batch 2, so these currently resolve to explicit "no
|
|
147
|
+
// scenarios registered yet" skipped results — never "passed".
|
|
148
|
+
const profile = options.profile ?? DEFAULT_SECURITY_PROFILE;
|
|
149
|
+
const attackScenarioChecks = PLANNED_SECURITY_CHECK_IDS.filter((id) => selectedChecks.has(id));
|
|
150
|
+
let attackResults = [];
|
|
151
|
+
if (attackScenarioChecks.length > 0) {
|
|
152
|
+
try {
|
|
153
|
+
attackResults = await runAttackScenarios({
|
|
154
|
+
selectedChecks: attackScenarioChecks,
|
|
155
|
+
profile,
|
|
156
|
+
toolRoot,
|
|
157
|
+
target,
|
|
158
|
+
config,
|
|
159
|
+
targetSnapshotBefore,
|
|
160
|
+
});
|
|
161
|
+
}
|
|
162
|
+
catch (err) {
|
|
163
|
+
attackResults = attackScenarioChecks.map((checkId) => ({
|
|
164
|
+
scenarioId: `${checkId}-attack-runner-error`,
|
|
165
|
+
scenarioTitle: `Attack scenario runner error for '${checkId}'`,
|
|
166
|
+
checkId,
|
|
167
|
+
profileId: profile,
|
|
168
|
+
status: "blocked",
|
|
169
|
+
severity: "major",
|
|
170
|
+
confidence: "low",
|
|
171
|
+
evidence: [],
|
|
172
|
+
category: "cli-adversarial",
|
|
173
|
+
errorSummary: err instanceof Error ? err.message : String(err),
|
|
174
|
+
}));
|
|
175
|
+
}
|
|
176
|
+
for (const result of attackResults) {
|
|
177
|
+
collect(toSecurityCheckResult(result));
|
|
178
|
+
}
|
|
88
179
|
}
|
|
89
180
|
const finishedAt = new Date().toISOString();
|
|
90
181
|
// --- Tool root metadata ---
|
|
@@ -102,6 +193,11 @@ export async function runSecurityValidation(options) {
|
|
|
102
193
|
// Use defaults.
|
|
103
194
|
}
|
|
104
195
|
const { verdict, recommendedNextStep } = calculateVerdict(allChecks, allFindings);
|
|
196
|
+
const verdictReasonSummary = summarizeVerdictReasoning(allChecks, allFindings);
|
|
197
|
+
// "Full release gate" = every classic pre-v0.2.2 check group was selected.
|
|
198
|
+
// Attack-scenario checks (boundary/subprocess/secrets/network) are
|
|
199
|
+
// additional coverage, not part of that traditional baseline gate.
|
|
200
|
+
const isFullReleaseGate = IMPLEMENTED_SECURITY_CHECK_IDS.every((id) => selectedChecks.has(id));
|
|
105
201
|
return {
|
|
106
202
|
toolRoot,
|
|
107
203
|
toolPackageName,
|
|
@@ -119,6 +215,9 @@ export async function runSecurityValidation(options) {
|
|
|
119
215
|
findings: allFindings,
|
|
120
216
|
verdict,
|
|
121
217
|
recommendedNextStep,
|
|
218
|
+
attackResults,
|
|
219
|
+
verdictReasonSummary,
|
|
220
|
+
isFullReleaseGate,
|
|
122
221
|
};
|
|
123
222
|
}
|
|
124
223
|
function errorCheck(id, name, category, err) {
|