@dailephd/my-dev-kit-lab 0.2.1 → 0.2.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.
Files changed (31) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +35 -24
  3. package/dist/scripts/security/validate.js +68 -29
  4. package/dist/src/securityValidation/attackScenarios/attackProfile.js +20 -0
  5. package/dist/src/securityValidation/attackScenarios/attackResult.js +37 -0
  6. package/dist/src/securityValidation/attackScenarios/attackRunner.js +153 -0
  7. package/dist/src/securityValidation/attackScenarios/attackScenario.js +21 -0
  8. package/dist/src/securityValidation/attackScenarios/boundedSourceScan.js +100 -0
  9. package/dist/src/securityValidation/attackScenarios/exploitEvidence.js +69 -0
  10. package/dist/src/securityValidation/attackScenarios/payloadCorpus.js +0 -0
  11. package/dist/src/securityValidation/attackScenarios/profiles/localToolProfile.js +10 -0
  12. package/dist/src/securityValidation/attackScenarios/profiles/nodeCliPackageProfile.js +20 -0
  13. package/dist/src/securityValidation/attackScenarios/profiles/npmPackageProfile.js +10 -0
  14. package/dist/src/securityValidation/attackScenarios/reportSchemaGuard.js +41 -0
  15. package/dist/src/securityValidation/attackScenarios/scenarios/configInjectionScenario.js +144 -0
  16. package/dist/src/securityValidation/attackScenarios/scenarios/networkAssumptionScenario.js +129 -0
  17. package/dist/src/securityValidation/attackScenarios/scenarios/outputBoundaryScenario.js +69 -0
  18. package/dist/src/securityValidation/attackScenarios/scenarios/packageBoundaryScenario.js +118 -0
  19. package/dist/src/securityValidation/attackScenarios/scenarios/pathTraversalScenario.js +119 -0
  20. package/dist/src/securityValidation/attackScenarios/scenarios/reportPoisoningScenario.js +176 -0
  21. package/dist/src/securityValidation/attackScenarios/scenarios/secretLeakageScenario.js +143 -0
  22. package/dist/src/securityValidation/attackScenarios/scenarios/subprocessInjectionScenario.js +117 -0
  23. package/dist/src/securityValidation/attackScenarios/scenarios/targetSandboxScenario.js +118 -0
  24. package/dist/src/securityValidation/attackScenarios/targetSnapshot.js +70 -0
  25. package/dist/src/securityValidation/packageChecks/forbiddenPackageContents.js +4 -1
  26. package/dist/src/securityValidation/report/renderSecurityReport.js +135 -13
  27. package/dist/src/securityValidation/types.js +7 -0
  28. package/dist/src/securityValidation/validate/cliOptions.js +182 -0
  29. package/dist/src/securityValidation/validate/runSecurityValidation.js +134 -35
  30. package/dist/src/securityValidation/validate/verdict.js +147 -0
  31. package/package.json +16 -16
@@ -0,0 +1,118 @@
1
+ import path from "node:path";
2
+ import { makeEvidence } from "../exploitEvidence.js";
3
+ import { captureTargetSnapshot, diffTargetSnapshots, isGeneratedArtifactPath } from "../targetSnapshot.js";
4
+ // ---------------------------------------------------------------------------
5
+ // v0.2.2 Batch 3 — target sandbox / read-only validation scenario.
6
+ //
7
+ // Compares the target's git status "before" (captured by runSecurityValidation
8
+ // before any checks ran, threaded via ctx.targetSnapshotBefore) against a
9
+ // fresh snapshot taken here (after all other checks in this run finished —
10
+ // attack-scenario checks run last in the orchestrator). Never mutates the
11
+ // target: only `git status --porcelain` is executed, no clean/reset/restore.
12
+ //
13
+ // Known limitation: this only detects changes visible to `git status`. It
14
+ // cannot detect a file that was modified and then reverted to its original
15
+ // content, or changes in a non-git target (which is skipped, not silently
16
+ // assumed safe).
17
+ // ---------------------------------------------------------------------------
18
+ export const TARGET_SANDBOX_SCENARIO = {
19
+ id: "target-sandbox-read-only",
20
+ title: "Target sandbox: validation does not modify the target working tree",
21
+ description: "Compares target git status before and after the validation run to confirm the run did not modify or add tracked/untracked files, and did not write generated security artifacts inside the target.",
22
+ checkId: "boundary",
23
+ applicableProfiles: [],
24
+ severityBaseline: "blocker",
25
+ verdictImpact: "target-project-blocker",
26
+ expectedSafeBehavior: "git status for the target is identical before and after the run, except for pre-existing dirtiness which is reported separately and not treated as caused by this run.",
27
+ evidenceRequirements: ["filesystem", "target-modification", "skipped-tool"],
28
+ skipCondition: (ctx) => {
29
+ if (!ctx.target.hasGit) {
30
+ return "Target is not a git repository; git-status-based read-only verification is not applicable. This does not imply the target was modified.";
31
+ }
32
+ if (!ctx.targetSnapshotBefore || !ctx.targetSnapshotBefore.hasGit) {
33
+ return "No pre-run git status snapshot was available for comparison.";
34
+ }
35
+ return undefined;
36
+ },
37
+ run: async (ctx) => {
38
+ const before = ctx.targetSnapshotBefore;
39
+ const after = captureTargetSnapshot(ctx.target.targetRoot, ctx.target.hasGit);
40
+ const diff = diffTargetSnapshots(before, after);
41
+ if (!diff.comparable) {
42
+ return {
43
+ status: "blocked",
44
+ confidence: "low",
45
+ evidence: [
46
+ makeEvidence({
47
+ kind: "skipped-tool",
48
+ source: "git status --porcelain",
49
+ confidence: "low",
50
+ observedBehavior: diff.reason,
51
+ expectedBehavior: "git status should be readable before and after the run for comparison.",
52
+ }),
53
+ ],
54
+ recommendation: "Ensure git is available and the target is a valid git repository to enable this check.",
55
+ };
56
+ }
57
+ const newGeneratedArtifacts = diff.newEntries.filter((e) => isGeneratedArtifactPath(e.path));
58
+ const newOtherEntries = diff.newEntries.filter((e) => !isGeneratedArtifactPath(e.path));
59
+ const evidence = [];
60
+ if (diff.preExistingEntries.length > 0) {
61
+ evidence.push(makeEvidence({
62
+ kind: "observation",
63
+ source: "git status --porcelain (before)",
64
+ confidence: "high",
65
+ observedBehavior: `${diff.preExistingEntries.length} pre-existing dirty entr${diff.preExistingEntries.length === 1 ? "y" : "ies"} (not caused by this run).`,
66
+ rawPreview: diff.preExistingEntries.map((e) => `${e.code} ${e.path}`).join(", "),
67
+ }));
68
+ }
69
+ for (const entry of newGeneratedArtifacts.slice(0, 10)) {
70
+ evidence.push(makeEvidence({
71
+ kind: "target-modification",
72
+ source: "git status --porcelain (after)",
73
+ filePath: entry.path,
74
+ confidence: "high",
75
+ expectedBehavior: "Generated security artifacts must not be written inside the target working tree.",
76
+ observedBehavior: `New entry '${entry.code} ${entry.path}' appeared inside the target during validation.`,
77
+ }));
78
+ }
79
+ for (const entry of newOtherEntries.slice(0, 10)) {
80
+ evidence.push(makeEvidence({
81
+ kind: "target-modification",
82
+ source: "git status --porcelain (after)",
83
+ filePath: entry.path,
84
+ confidence: "high",
85
+ expectedBehavior: "Target tracked/untracked files must remain unchanged during validation.",
86
+ observedBehavior: `New entry '${entry.code} ${entry.path}' appeared inside the target during validation.`,
87
+ }));
88
+ }
89
+ if (newGeneratedArtifacts.length > 0 || newOtherEntries.length > 0) {
90
+ return {
91
+ status: "failed",
92
+ confidence: "high",
93
+ evidence,
94
+ category: "artifact-safety",
95
+ recommendation: newGeneratedArtifacts.length > 0
96
+ ? "Generated security report/artifact directories must only be written under the tool's own output area, never inside the target project."
97
+ : "Validation must be strictly read-only with respect to the target working tree.",
98
+ };
99
+ }
100
+ return {
101
+ status: "passed",
102
+ confidence: "high",
103
+ evidence,
104
+ category: "artifact-safety",
105
+ };
106
+ },
107
+ };
108
+ // Exposed for tests / reuse: default reportDir/rawOutputDir collision check
109
+ // used by outputBoundaryScenario, kept here since it reasons about the same
110
+ // "target boundary" concept.
111
+ export function reportDirIsInsideExternalTarget(reportDir, targetRoot, isSelf) {
112
+ if (isSelf)
113
+ return false;
114
+ const resolvedReportDir = path.resolve(reportDir);
115
+ const resolvedTargetRoot = path.resolve(targetRoot);
116
+ const relative = path.relative(resolvedTargetRoot, resolvedReportDir);
117
+ return relative === "" || (!relative.startsWith("..") && !path.isAbsolute(relative));
118
+ }
@@ -0,0 +1,70 @@
1
+ import { execSync } from "node:child_process";
2
+ // Reads `git status --porcelain=v1` for targetRoot without mutating anything.
3
+ // Returns hasGit:false (not a thrown error) when the target is not a git
4
+ // repository or git is unavailable — callers must treat that as "unknown",
5
+ // never as "crashed".
6
+ export function captureTargetSnapshot(targetRoot, hasGit) {
7
+ const takenAt = new Date().toISOString();
8
+ if (!hasGit) {
9
+ return { targetRoot, takenAt, hasGit: false, unreadableReason: "Target is not a git repository." };
10
+ }
11
+ try {
12
+ const raw = execSync("git status --porcelain=v1", {
13
+ cwd: targetRoot,
14
+ encoding: "utf8",
15
+ stdio: ["pipe", "pipe", "pipe"],
16
+ });
17
+ const entries = parsePorcelainStatus(raw);
18
+ return { targetRoot, takenAt, hasGit: true, entries };
19
+ }
20
+ catch (err) {
21
+ return {
22
+ targetRoot,
23
+ takenAt,
24
+ hasGit: false,
25
+ unreadableReason: `git status failed: ${err instanceof Error ? err.message : String(err)}`,
26
+ };
27
+ }
28
+ }
29
+ function parsePorcelainStatus(raw) {
30
+ const entries = [];
31
+ for (const line of raw.split(/\r?\n/)) {
32
+ if (!line)
33
+ continue;
34
+ const code = line.slice(0, 2);
35
+ const rawPath = line.slice(3).trim();
36
+ // Renames report as "old -> new"; keep the new path for comparisons.
37
+ const filePath = rawPath.includes(" -> ") ? rawPath.split(" -> ")[1] : rawPath;
38
+ entries.push({ code, path: filePath });
39
+ }
40
+ // Stable, deterministic ordering independent of git's own output order.
41
+ return entries.sort((a, b) => a.path.localeCompare(b.path));
42
+ }
43
+ const GENERATED_ARTIFACT_PATTERNS = [
44
+ "reports/security/",
45
+ "reports/audits/",
46
+ ".my-dev-kit/",
47
+ "lab-output/",
48
+ ];
49
+ export function isGeneratedArtifactPath(p) {
50
+ const normalized = p.replace(/\\/g, "/");
51
+ return (GENERATED_ARTIFACT_PATTERNS.some((pattern) => normalized.startsWith(pattern)) ||
52
+ /-security-validation\.(txt|json)$/i.test(normalized));
53
+ }
54
+ export function diffTargetSnapshots(before, after) {
55
+ if (!before.hasGit || !after.hasGit || !before.entries || !after.entries) {
56
+ return {
57
+ comparable: false,
58
+ reason: before.unreadableReason ?? after.unreadableReason ?? "git status was not available for comparison.",
59
+ newEntries: [],
60
+ preExistingEntries: [],
61
+ };
62
+ }
63
+ const beforeKeys = new Set(before.entries.map((e) => `${e.code}:${e.path}`));
64
+ const newEntries = after.entries.filter((e) => !beforeKeys.has(`${e.code}:${e.path}`));
65
+ return {
66
+ comparable: true,
67
+ newEntries,
68
+ preExistingEntries: before.entries,
69
+ };
70
+ }
@@ -56,7 +56,10 @@ export function detectForbiddenContents(options) {
56
56
  }));
57
57
  return { matches, findings };
58
58
  }
59
- function isCriticalForbidden(file) {
59
+ // Exported for reuse by packageBoundaryScenario.ts, which needs the same
60
+ // critical/major distinction to set its AttackResult severity correctly
61
+ // (v0.2.2 Batch 5).
62
+ export function isCriticalForbidden(file) {
60
63
  const f = file.toLowerCase();
61
64
  return (f.includes(".env") ||
62
65
  f.endsWith(".pem") ||
@@ -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
+ }