@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.
Files changed (66) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +36 -24
  3. package/dist/scripts/audits/runAudit.js +101 -0
  4. package/dist/scripts/security/validate.js +68 -29
  5. package/dist/src/audits/codeRot/codeRotDetectors.js +17 -0
  6. package/dist/src/audits/codeRot/detectors/architectureDriftDetector.js +271 -0
  7. package/dist/src/audits/codeRot/detectors/crossPlatformRotDetector.js +214 -0
  8. package/dist/src/audits/codeRot/detectors/deadCodeCandidateDetector.js +344 -0
  9. package/dist/src/audits/codeRot/detectors/dependencyEnvironmentRotDetector.js +244 -0
  10. package/dist/src/audits/codeRot/detectors/docsCodeMismatchDetector.js +219 -0
  11. package/dist/src/audits/codeRot/detectors/duplicateImplementationDetector.js +273 -0
  12. package/dist/src/audits/codeRot/detectors/packageReleaseRotDetector.js +325 -0
  13. package/dist/src/audits/codeRot/detectors/securityValidationAssumptionRotDetector.js +385 -0
  14. package/dist/src/audits/codeRot/detectors/staleCommandReferenceDetector.js +135 -0
  15. package/dist/src/audits/codeRot/detectors/testRotDetector.js +348 -0
  16. package/dist/src/audits/codeRot/utils/boundedRead.js +23 -0
  17. package/dist/src/audits/codeRot/utils/docClaimPatterns.js +62 -0
  18. package/dist/src/audits/codeRot/utils/docCommandReferences.js +64 -0
  19. package/dist/src/audits/codeRot/utils/filePatternUtils.js +47 -0
  20. package/dist/src/audits/codeRot/utils/issueFactories.js +38 -0
  21. package/dist/src/audits/codeRot/utils/textLines.js +12 -0
  22. package/dist/src/audits/core/auditConfig.js +125 -0
  23. package/dist/src/audits/core/auditExitCode.js +75 -0
  24. package/dist/src/audits/core/auditIssue.js +20 -0
  25. package/dist/src/audits/core/auditRegistry.js +40 -0
  26. package/dist/src/audits/core/auditRunner.js +71 -0
  27. package/dist/src/audits/core/auditTarget.js +19 -0
  28. package/dist/src/audits/core/auditTypes.js +30 -0
  29. package/dist/src/audits/core/projectInventory.js +308 -0
  30. package/dist/src/audits/core/sourceOfTruth.js +339 -0
  31. package/dist/src/audits/core/textLines.js +24 -0
  32. package/dist/src/audits/report/auditReportModel.js +241 -0
  33. package/dist/src/audits/report/auditReportPaths.js +13 -0
  34. package/dist/src/audits/report/renderAuditJsonReport.js +29 -0
  35. package/dist/src/audits/report/renderAuditTextReport.js +165 -0
  36. package/dist/src/audits/report/sanitizeAuditText.js +39 -0
  37. package/dist/src/audits/report/writeAuditReports.js +27 -0
  38. package/dist/src/core/resolveCommand.js +53 -2
  39. package/dist/src/securityValidation/attackScenarios/attackProfile.js +20 -0
  40. package/dist/src/securityValidation/attackScenarios/attackResult.js +37 -0
  41. package/dist/src/securityValidation/attackScenarios/attackRunner.js +153 -0
  42. package/dist/src/securityValidation/attackScenarios/attackScenario.js +21 -0
  43. package/dist/src/securityValidation/attackScenarios/boundedSourceScan.js +100 -0
  44. package/dist/src/securityValidation/attackScenarios/exploitEvidence.js +69 -0
  45. package/dist/src/securityValidation/attackScenarios/payloadCorpus.js +0 -0
  46. package/dist/src/securityValidation/attackScenarios/profiles/localToolProfile.js +10 -0
  47. package/dist/src/securityValidation/attackScenarios/profiles/nodeCliPackageProfile.js +20 -0
  48. package/dist/src/securityValidation/attackScenarios/profiles/npmPackageProfile.js +10 -0
  49. package/dist/src/securityValidation/attackScenarios/reportSchemaGuard.js +41 -0
  50. package/dist/src/securityValidation/attackScenarios/scenarios/configInjectionScenario.js +144 -0
  51. package/dist/src/securityValidation/attackScenarios/scenarios/networkAssumptionScenario.js +129 -0
  52. package/dist/src/securityValidation/attackScenarios/scenarios/outputBoundaryScenario.js +69 -0
  53. package/dist/src/securityValidation/attackScenarios/scenarios/packageBoundaryScenario.js +118 -0
  54. package/dist/src/securityValidation/attackScenarios/scenarios/pathTraversalScenario.js +119 -0
  55. package/dist/src/securityValidation/attackScenarios/scenarios/reportPoisoningScenario.js +176 -0
  56. package/dist/src/securityValidation/attackScenarios/scenarios/secretLeakageScenario.js +143 -0
  57. package/dist/src/securityValidation/attackScenarios/scenarios/subprocessInjectionScenario.js +117 -0
  58. package/dist/src/securityValidation/attackScenarios/scenarios/targetSandboxScenario.js +118 -0
  59. package/dist/src/securityValidation/attackScenarios/targetSnapshot.js +70 -0
  60. package/dist/src/securityValidation/packageChecks/forbiddenPackageContents.js +4 -1
  61. package/dist/src/securityValidation/report/renderSecurityReport.js +135 -13
  62. package/dist/src/securityValidation/types.js +7 -0
  63. package/dist/src/securityValidation/validate/cliOptions.js +182 -0
  64. package/dist/src/securityValidation/validate/runSecurityValidation.js +134 -35
  65. package/dist/src/securityValidation/validate/verdict.js +147 -0
  66. 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
- try {
38
- const depOutput = await runDependencyChecks({ cwd: target.targetRoot, config });
39
- collectMany(depOutput.checks);
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
- catch (err) {
42
- collect(errorCheck("dependency-checks", "Dependency checks", "dependency-audit", err));
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
- try {
48
- const pkgOutput = await runPackageChecks({ cwd: target.targetRoot, config });
49
- collectMany(pkgOutput.checks);
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
- catch (err) {
52
- collect(errorCheck("package-content", "Package content checks", "package-content", err));
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
- try {
56
- collect(await runCodeqlCheck({ cwd: toolRoot, targetRoot: target.targetRoot, timeoutMs: config.commandTimeoutMs }));
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
- catch (err) {
59
- collect(errorCheck("codeql-scan", "CodeQL static analysis", "static-scan", err));
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
- try {
62
- collect(await runSemgrepCheck({
63
- targetRoot: target.targetRoot,
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
- catch (err) {
69
- collect(errorCheck("semgrep-scan", "Semgrep static analysis", "static-scan", err));
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
- try {
81
- collect(await runAllFuzzTargets(ALL_FUZZ_TARGETS, {
82
- seed: options.fuzzSeed ?? 0xdeadbeef,
83
- iterations: options.fuzzIterations ?? 50,
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
- catch (err) {
87
- collect(errorCheck("fuzz-smoke", "Fuzz smoke", "fuzz-smoke", err));
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) {