@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.
- package/LICENSE +21 -0
- package/README.md +35 -24
- package/dist/scripts/security/validate.js +68 -29
- 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 +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
|
-
|
|
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
|
+
}
|