@dailephd/my-dev-kit-lab 0.2.0 → 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 -22
- package/dist/scripts/security/resolveToolRoot.js +16 -0
- package/dist/scripts/security/runCodeql.js +2 -1
- package/dist/scripts/security/runDependencyChecks.js +2 -1
- package/dist/scripts/security/runPackageChecks.js +2 -1
- package/dist/scripts/security/runSemgrep.js +2 -1
- package/dist/scripts/security/validate.js +70 -30
- package/dist/src/core/localProjectTarget.js +4 -0
- package/dist/src/core/runMeasuredCommand.js +10 -6
- 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 +153 -12
- package/dist/src/securityValidation/types.js +7 -0
- package/dist/src/securityValidation/validate/cliOptions.js +182 -0
- package/dist/src/securityValidation/validate/runCliSecuritySuiteCheck.js +124 -0
- package/dist/src/securityValidation/validate/runSecurityValidation.js +135 -67
- package/dist/src/securityValidation/validate/verdict.js +147 -0
- package/package.json +14 -10
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
import { makeEvidence } from "../exploitEvidence.js";
|
|
2
|
+
import { collectBoundedSourceFiles, lineNumberAt } from "../boundedSourceScan.js";
|
|
3
|
+
// ---------------------------------------------------------------------------
|
|
4
|
+
// v0.2.2 Batch 4 — network / local-first assumption scenario.
|
|
5
|
+
//
|
|
6
|
+
// Careful, narrow claim only: a bounded static scan of local-first source
|
|
7
|
+
// paths (manifests, src/, scripts/) finding no obvious unexpected network-API
|
|
8
|
+
// usage. This is NOT proof of runtime network isolation — subprocess-invoked
|
|
9
|
+
// tools (npm audit/outdated, OSV-Scanner, Semgrep's npx fallback) legitimately
|
|
10
|
+
// contact the network when those check groups are selected, and this
|
|
11
|
+
// scenario does not and cannot verify their runtime behavior. No network
|
|
12
|
+
// calls, probes, or instrumentation are performed by this scenario itself.
|
|
13
|
+
// ---------------------------------------------------------------------------
|
|
14
|
+
const NETWORK_API_PATTERNS = [
|
|
15
|
+
{ id: "fetch-call", regex: /\bfetch\s*\(/g },
|
|
16
|
+
{ id: "http-module-import", regex: /(?:require\(\s*["']https?["']\s*\)|from\s+["']https?["'])/g },
|
|
17
|
+
{ id: "net-tls-module-import", regex: /(?:require\(\s*["'](?:net|tls)["']\s*\)|from\s+["'](?:net|tls)["'])/g },
|
|
18
|
+
{ id: "axios-import", regex: /(?:require\(\s*["']axios["']\s*\)|from\s+["']axios["'])/g },
|
|
19
|
+
{ id: "got-import", regex: /(?:require\(\s*["']got["']\s*\)|from\s+["']got["'])/g },
|
|
20
|
+
{ id: "request-import", regex: /(?:require\(\s*["']request["']\s*\)|from\s+["']request["'])/g },
|
|
21
|
+
{ id: "undici-import", regex: /(?:require\(\s*["']undici["']\s*\)|from\s+["']undici["'])/g },
|
|
22
|
+
{ id: "node-fetch-import", regex: /(?:require\(\s*["']node-fetch["']\s*\)|from\s+["']node-fetch["'])/g },
|
|
23
|
+
{ id: "websocket-usage", regex: /new\s+WebSocket\s*\(/g },
|
|
24
|
+
];
|
|
25
|
+
// Paths where network-touching behavior is expected and intentional (they
|
|
26
|
+
// shell out to npm/OSV-Scanner/etc, not a local-first-source violation).
|
|
27
|
+
const KNOWN_NETWORK_INTENTIONAL_PREFIXES = [
|
|
28
|
+
"src/securityValidation/dependencies/",
|
|
29
|
+
"src/securityValidation/staticScans/",
|
|
30
|
+
];
|
|
31
|
+
function isKnownIntentionalPath(relativePath) {
|
|
32
|
+
return KNOWN_NETWORK_INTENTIONAL_PREFIXES.some((p) => relativePath.startsWith(p));
|
|
33
|
+
}
|
|
34
|
+
function scanFileForNetworkApis(relativePath, content) {
|
|
35
|
+
const matches = [];
|
|
36
|
+
const intentional = isKnownIntentionalPath(relativePath);
|
|
37
|
+
for (const { id, regex } of NETWORK_API_PATTERNS) {
|
|
38
|
+
const re = new RegExp(regex.source, regex.flags);
|
|
39
|
+
let match;
|
|
40
|
+
while ((match = re.exec(content)) !== null) {
|
|
41
|
+
if (match[0].length === 0) {
|
|
42
|
+
re.lastIndex += 1;
|
|
43
|
+
continue;
|
|
44
|
+
}
|
|
45
|
+
matches.push({ id, file: relativePath, line: lineNumberAt(content, match.index), intentional });
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
return matches;
|
|
49
|
+
}
|
|
50
|
+
export const NETWORK_ASSUMPTION_SCENARIO = {
|
|
51
|
+
id: "network-local-first-bounded-scan",
|
|
52
|
+
title: "Network assumption: bounded static scan finds no unexpected network API usage in local-first source",
|
|
53
|
+
description: "Bounded static scan of local-first source paths (manifests, src/, scripts/) for obvious network API usage (fetch/http/https/net/tls/axios/got/request/undici/node-fetch/WebSocket). Known network-using validation commands (npm audit, npm outdated, OSV-Scanner) are treated as expected, not violations. This does not prove runtime network isolation.",
|
|
54
|
+
checkId: "network",
|
|
55
|
+
applicableProfiles: [],
|
|
56
|
+
severityBaseline: "informational",
|
|
57
|
+
verdictImpact: "target-project-blocker",
|
|
58
|
+
expectedSafeBehavior: "No obvious unexpected network API usage is found in the bounded local-first source paths inspected.",
|
|
59
|
+
evidenceRequirements: ["observation"],
|
|
60
|
+
run: async (ctx) => {
|
|
61
|
+
const files = collectBoundedSourceFiles(ctx.target.targetRoot);
|
|
62
|
+
const isolationCaveat = makeEvidence({
|
|
63
|
+
kind: "observation",
|
|
64
|
+
source: "scope statement",
|
|
65
|
+
confidence: "high",
|
|
66
|
+
expectedBehavior: "This is a bounded static check of local-first source paths only. It does not prove runtime network isolation of subprocess-invoked tools (npm, CodeQL, Semgrep, OSV-Scanner) or any other process spawned during validation.",
|
|
67
|
+
observedBehavior: "Static-source-only claim; no runtime network instrumentation was performed.",
|
|
68
|
+
});
|
|
69
|
+
if (files.length === 0) {
|
|
70
|
+
return {
|
|
71
|
+
status: "passed",
|
|
72
|
+
confidence: "low",
|
|
73
|
+
evidence: [
|
|
74
|
+
isolationCaveat,
|
|
75
|
+
makeEvidence({
|
|
76
|
+
kind: "observation",
|
|
77
|
+
source: "bounded network scan file collection",
|
|
78
|
+
confidence: "low",
|
|
79
|
+
expectedBehavior: "At least one file would be scanned for a meaningful bounded-scan claim.",
|
|
80
|
+
observedBehavior: "0 files matched the bounded glob set. No findings because there was nothing to scan.",
|
|
81
|
+
}),
|
|
82
|
+
],
|
|
83
|
+
};
|
|
84
|
+
}
|
|
85
|
+
const allMatches = [];
|
|
86
|
+
for (const file of files) {
|
|
87
|
+
allMatches.push(...scanFileForNetworkApis(file.relativePath, file.content));
|
|
88
|
+
}
|
|
89
|
+
allMatches.sort((a, b) => a.file.localeCompare(b.file) || a.line - b.line);
|
|
90
|
+
const unexpected = allMatches.filter((m) => !m.intentional);
|
|
91
|
+
const intentional = allMatches.filter((m) => m.intentional);
|
|
92
|
+
const evidence = [
|
|
93
|
+
isolationCaveat,
|
|
94
|
+
makeEvidence({
|
|
95
|
+
kind: "observation",
|
|
96
|
+
source: "bounded network API scan",
|
|
97
|
+
confidence: "medium",
|
|
98
|
+
expectedBehavior: "Bounded scan of manifests/src/scripts for direct network API usage.",
|
|
99
|
+
observedBehavior: `${files.length} file(s) scanned; ${unexpected.length} unexpected match(es), ${intentional.length} known-intentional match(es).`,
|
|
100
|
+
}),
|
|
101
|
+
makeEvidence({
|
|
102
|
+
kind: "observation",
|
|
103
|
+
source: "known network-using validation commands",
|
|
104
|
+
confidence: "high",
|
|
105
|
+
expectedBehavior: "npm audit / npm outdated (deps check) may contact the npm registry; OSV-Scanner may contact the OSV API; Semgrep's npx fallback may contact the npm registry. These are expected when those check groups are selected via --checks and are not local-first source violations.",
|
|
106
|
+
observedBehavior: `${intentional.length} match(es) found under known network-using paths (${KNOWN_NETWORK_INTENTIONAL_PREFIXES.join(", ")}).`,
|
|
107
|
+
}),
|
|
108
|
+
...unexpected.slice(0, 20).map((m) => makeEvidence({
|
|
109
|
+
kind: "observation",
|
|
110
|
+
source: `pattern '${m.id}'`,
|
|
111
|
+
filePath: m.file,
|
|
112
|
+
line: m.line,
|
|
113
|
+
confidence: "medium",
|
|
114
|
+
expectedBehavior: "No unexpected direct network API usage in local-first source paths.",
|
|
115
|
+
observedBehavior: `Detected '${m.id}' usage in ${m.file}:${m.line}.`,
|
|
116
|
+
})),
|
|
117
|
+
];
|
|
118
|
+
if (unexpected.length > 0) {
|
|
119
|
+
return {
|
|
120
|
+
status: "failed",
|
|
121
|
+
confidence: "medium",
|
|
122
|
+
evidence,
|
|
123
|
+
severity: "major",
|
|
124
|
+
recommendation: `Review ${unexpected.length} unexpected network-API usage(s) in local-first source paths and confirm they are intentional, or move them under a clearly network-using module.`,
|
|
125
|
+
};
|
|
126
|
+
}
|
|
127
|
+
return { status: "passed", confidence: "medium", evidence };
|
|
128
|
+
},
|
|
129
|
+
};
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
import path from "node:path";
|
|
2
|
+
import { makeEvidence } from "../exploitEvidence.js";
|
|
3
|
+
import { getPayloadsForGroup } from "../payloadCorpus.js";
|
|
4
|
+
import { reportDirIsInsideExternalTarget } from "./targetSandboxScenario.js";
|
|
5
|
+
// ---------------------------------------------------------------------------
|
|
6
|
+
// v0.2.2 Batch 3 — output/generated-artifact boundary scenario.
|
|
7
|
+
//
|
|
8
|
+
// Two parts:
|
|
9
|
+
// 1. A real safety check: does the *default* (no --out) report output
|
|
10
|
+
// directory resolve inside an external target's working tree? That is
|
|
11
|
+
// the one genuinely dangerous default-behavior case (Batch 1/2 already
|
|
12
|
+
// made --out itself user-directed and unrestricted by design, so an
|
|
13
|
+
// explicit --out escaping is not itself a finding here).
|
|
14
|
+
// 2. Deterministic, non-destructive classification of the "output-boundary"
|
|
15
|
+
// payload corpus (paths with spaces, relative, absolute, home-dir) to
|
|
16
|
+
// produce evidence that path resolution behaves predictably — this does
|
|
17
|
+
// not write any files.
|
|
18
|
+
// ---------------------------------------------------------------------------
|
|
19
|
+
export const OUTPUT_BOUNDARY_SCENARIO = {
|
|
20
|
+
id: "output-boundary-report-dir",
|
|
21
|
+
title: "Output boundary: generated reports are not written inside an external target",
|
|
22
|
+
description: "Confirms the default report output directory does not resolve inside an external target's working tree, and classifies output-boundary payload path resolution without writing any files.",
|
|
23
|
+
checkId: "boundary",
|
|
24
|
+
applicableProfiles: [],
|
|
25
|
+
severityBaseline: "major",
|
|
26
|
+
verdictImpact: "release-blocker",
|
|
27
|
+
expectedSafeBehavior: "The default report output directory resolves outside any external target's working tree; payload path resolution is deterministic.",
|
|
28
|
+
evidenceRequirements: ["filesystem", "observation"],
|
|
29
|
+
run: async (ctx) => {
|
|
30
|
+
const evidence = [];
|
|
31
|
+
const insideTarget = reportDirIsInsideExternalTarget(ctx.config.reportDir, ctx.target.targetRoot, ctx.target.isSelf);
|
|
32
|
+
evidence.push(makeEvidence({
|
|
33
|
+
kind: "filesystem",
|
|
34
|
+
source: "default report output directory",
|
|
35
|
+
filePath: path.resolve(ctx.config.reportDir),
|
|
36
|
+
confidence: "high",
|
|
37
|
+
expectedBehavior: "Default report output directory must not resolve inside an external target project.",
|
|
38
|
+
observedBehavior: ctx.target.isSelf
|
|
39
|
+
? "Self-validation: report directory is expected to be under the tool root."
|
|
40
|
+
: insideTarget
|
|
41
|
+
? `Report directory resolves inside external target root '${ctx.target.targetRoot}'.`
|
|
42
|
+
: `Report directory resolves outside external target root '${ctx.target.targetRoot}'.`,
|
|
43
|
+
}));
|
|
44
|
+
const payloads = getPayloadsForGroup("output-boundary");
|
|
45
|
+
for (const p of payloads) {
|
|
46
|
+
const resolved = path.resolve(ctx.toolRoot, p.value);
|
|
47
|
+
evidence.push(makeEvidence({
|
|
48
|
+
kind: "observation",
|
|
49
|
+
source: `output-boundary payload '${p.id}'`,
|
|
50
|
+
confidence: "medium",
|
|
51
|
+
observedBehavior: `Resolved deterministically without any file write.`,
|
|
52
|
+
rawPreview: `${p.value} -> ${resolved}`,
|
|
53
|
+
}));
|
|
54
|
+
}
|
|
55
|
+
if (insideTarget) {
|
|
56
|
+
return {
|
|
57
|
+
status: "failed",
|
|
58
|
+
confidence: "high",
|
|
59
|
+
evidence,
|
|
60
|
+
recommendation: "Ensure the default report output directory is derived from the tool root, not the target root, for external-target validation.",
|
|
61
|
+
};
|
|
62
|
+
}
|
|
63
|
+
return {
|
|
64
|
+
status: "passed",
|
|
65
|
+
confidence: "high",
|
|
66
|
+
evidence,
|
|
67
|
+
};
|
|
68
|
+
},
|
|
69
|
+
};
|
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
import { makeEvidence } from "../exploitEvidence.js";
|
|
2
|
+
import { runSecurityCommand, resolveNpmCommand } from "../../commandRunner.js";
|
|
3
|
+
import { parseNpmPackDryRun } from "../../packageChecks/parseNpmPackDryRun.js";
|
|
4
|
+
import { detectForbiddenContents, isCriticalForbidden } from "../../packageChecks/forbiddenPackageContents.js";
|
|
5
|
+
// ---------------------------------------------------------------------------
|
|
6
|
+
// v0.2.2 Batch 3 — package boundary scenario.
|
|
7
|
+
//
|
|
8
|
+
// Reuses the existing npm-pack-dry-run parser and forbidden-content detector
|
|
9
|
+
// (src/securityValidation/packageChecks/) rather than re-implementing tarball
|
|
10
|
+
// parsing. This scenario adds structured, evidence-first attack-scenario
|
|
11
|
+
// framing on top of the same underlying data the "package" check group
|
|
12
|
+
// already produces — it does not publish or create real package artifacts.
|
|
13
|
+
//
|
|
14
|
+
// v0.2.2 Batch 5: escalates the result severity to "blocker" (via
|
|
15
|
+
// AttackScenarioRunOutcome.severity) when any matched file is
|
|
16
|
+
// isCriticalForbidden() (.env, .pem, .key, .p12, lab-output/, .my-dev-kit/) —
|
|
17
|
+
// reusing the exact same critical-file rule the underlying package check
|
|
18
|
+
// already applies, so a real secret-shaped leak in the tarball reliably
|
|
19
|
+
// triggers the "not-ready" verdict via blocker severity, not just "major".
|
|
20
|
+
// ---------------------------------------------------------------------------
|
|
21
|
+
export const PACKAGE_BOUNDARY_SCENARIO = {
|
|
22
|
+
id: "package-boundary-forbidden-content",
|
|
23
|
+
title: "Package boundary: npm tarball excludes forbidden/local-only content",
|
|
24
|
+
description: "Runs npm pack --dry-run against the target and verifies the resulting tarball file list does not include forbidden paths (.env, reports/security/, lab-output/, .my-dev-kit/, tarballs, node_modules, etc).",
|
|
25
|
+
checkId: "boundary",
|
|
26
|
+
applicableProfiles: [],
|
|
27
|
+
severityBaseline: "major",
|
|
28
|
+
verdictImpact: "target-project-blocker",
|
|
29
|
+
expectedSafeBehavior: "The npm pack --dry-run file list contains no files matching the forbidden content patterns.",
|
|
30
|
+
evidenceRequirements: ["package-content", "command"],
|
|
31
|
+
skipCondition: (ctx) => {
|
|
32
|
+
if (!ctx.target.hasPackageJson) {
|
|
33
|
+
return "Target has no package.json; npm pack --dry-run based package boundary check is not applicable.";
|
|
34
|
+
}
|
|
35
|
+
return undefined;
|
|
36
|
+
},
|
|
37
|
+
run: async (ctx) => {
|
|
38
|
+
const npm = resolveNpmCommand();
|
|
39
|
+
const cmd = await runSecurityCommand({
|
|
40
|
+
command: npm,
|
|
41
|
+
args: ["pack", "--dry-run"],
|
|
42
|
+
cwd: ctx.target.targetRoot,
|
|
43
|
+
timeoutMs: ctx.config.commandTimeoutMs,
|
|
44
|
+
});
|
|
45
|
+
const commandEvidence = makeEvidence({
|
|
46
|
+
kind: "command",
|
|
47
|
+
source: "npm pack --dry-run",
|
|
48
|
+
commandCwd: ctx.target.targetRoot,
|
|
49
|
+
commandSummary: `${npm} pack --dry-run`,
|
|
50
|
+
exitCode: cmd.exitCode,
|
|
51
|
+
confidence: "high",
|
|
52
|
+
});
|
|
53
|
+
const packOutput = [cmd.stdout, cmd.stderr].find((stream) => /tarball contents/i.test(stream)) ??
|
|
54
|
+
[cmd.stdout, cmd.stderr].filter(Boolean).join("\n");
|
|
55
|
+
const parsed = parseNpmPackDryRun(packOutput);
|
|
56
|
+
if (parsed.parseError && parsed.files.length === 0) {
|
|
57
|
+
return {
|
|
58
|
+
status: "blocked",
|
|
59
|
+
confidence: "low",
|
|
60
|
+
evidence: [
|
|
61
|
+
commandEvidence,
|
|
62
|
+
makeEvidence({
|
|
63
|
+
kind: "skipped-tool",
|
|
64
|
+
source: "npm pack --dry-run",
|
|
65
|
+
confidence: "low",
|
|
66
|
+
observedBehavior: parsed.parseError,
|
|
67
|
+
expectedBehavior: "npm pack --dry-run should list tarball contents for evaluation.",
|
|
68
|
+
}),
|
|
69
|
+
],
|
|
70
|
+
recommendation: "Ensure npm is available and the target package.json is valid, then re-run.",
|
|
71
|
+
};
|
|
72
|
+
}
|
|
73
|
+
const { matches, findings } = detectForbiddenContents({
|
|
74
|
+
files: parsed.files,
|
|
75
|
+
forbiddenPatterns: ctx.config.forbiddenPackagePatterns,
|
|
76
|
+
allowedExceptions: ctx.config.allowedPackageExceptions,
|
|
77
|
+
checkId: "attack-boundary-package",
|
|
78
|
+
});
|
|
79
|
+
if (matches.length === 0) {
|
|
80
|
+
return {
|
|
81
|
+
status: "passed",
|
|
82
|
+
confidence: "high",
|
|
83
|
+
evidence: [
|
|
84
|
+
commandEvidence,
|
|
85
|
+
makeEvidence({
|
|
86
|
+
kind: "package-content",
|
|
87
|
+
source: "npm pack --dry-run file list",
|
|
88
|
+
confidence: "high",
|
|
89
|
+
observedBehavior: `${parsed.files.length} file(s) inspected; none matched a forbidden pattern.`,
|
|
90
|
+
expectedBehavior: "No forbidden paths in the tarball file list.",
|
|
91
|
+
}),
|
|
92
|
+
],
|
|
93
|
+
};
|
|
94
|
+
}
|
|
95
|
+
const evidence = [
|
|
96
|
+
commandEvidence,
|
|
97
|
+
...matches.slice(0, 20).map((m, i) => {
|
|
98
|
+
const finding = findings[i];
|
|
99
|
+
return makeEvidence({
|
|
100
|
+
kind: "package-content",
|
|
101
|
+
source: "npm pack --dry-run file list",
|
|
102
|
+
filePath: m.file,
|
|
103
|
+
confidence: "high",
|
|
104
|
+
expectedBehavior: "Forbidden paths must not appear in the published tarball.",
|
|
105
|
+
observedBehavior: `Included '${m.file}' matching forbidden pattern '${m.pattern}' (${finding?.severity ?? "major"} severity).`,
|
|
106
|
+
});
|
|
107
|
+
}),
|
|
108
|
+
];
|
|
109
|
+
const hasCriticalMatch = matches.some((m) => isCriticalForbidden(m.file));
|
|
110
|
+
return {
|
|
111
|
+
status: "failed",
|
|
112
|
+
confidence: "high",
|
|
113
|
+
evidence,
|
|
114
|
+
severity: hasCriticalMatch ? "blocker" : "major",
|
|
115
|
+
recommendation: `Add ${matches.length} matched path(s) to .npmignore or the package.json "files" allowlist before publishing.`,
|
|
116
|
+
};
|
|
117
|
+
},
|
|
118
|
+
};
|
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
import { makeEvidence } from "../exploitEvidence.js";
|
|
2
|
+
import { getPayloadsForGroup } from "../payloadCorpus.js";
|
|
3
|
+
import { resolveWithinRoot } from "../../../core/pathSafety.js";
|
|
4
|
+
// ---------------------------------------------------------------------------
|
|
5
|
+
// v0.2.2 Batch 3 — path traversal scenario.
|
|
6
|
+
//
|
|
7
|
+
// Exercises the existing src/core/pathSafety.ts containment helper
|
|
8
|
+
// (resolveWithinRoot) — used for writes that must stay within a declared
|
|
9
|
+
// root — against the deterministic path-traversal payload corpus, plus a
|
|
10
|
+
// small set of legitimate paths that must continue to be accepted (spaces,
|
|
11
|
+
// safe relative/absolute-within-root paths). Pure path-math only; nothing is
|
|
12
|
+
// read or written to disk.
|
|
13
|
+
// ---------------------------------------------------------------------------
|
|
14
|
+
const LEGITIMATE_PATH_CASES = [
|
|
15
|
+
{ id: "relative-with-spaces", value: "safe sub dir/output.txt", shouldReject: false },
|
|
16
|
+
{ id: "relative-nested", value: "a/b/c/output.json", shouldReject: false },
|
|
17
|
+
];
|
|
18
|
+
export const PATH_TRAVERSAL_SCENARIO = {
|
|
19
|
+
id: "path-traversal-containment",
|
|
20
|
+
title: "Path traversal: containment helper rejects escape payloads and accepts legitimate paths",
|
|
21
|
+
description: "Verifies resolveWithinRoot() rejects the deterministic path-traversal payload corpus and continues to accept legitimate relative paths, including ones with spaces.",
|
|
22
|
+
checkId: "boundary",
|
|
23
|
+
applicableProfiles: [],
|
|
24
|
+
severityBaseline: "blocker",
|
|
25
|
+
verdictImpact: "tool-framework-blocker",
|
|
26
|
+
expectedSafeBehavior: "Every path-traversal payload throws when passed to resolveWithinRoot(); every legitimate path resolves without throwing.",
|
|
27
|
+
evidenceRequirements: ["filesystem", "observation"],
|
|
28
|
+
run: async (ctx) => {
|
|
29
|
+
const evidence = [];
|
|
30
|
+
const unrejectedTraversals = [];
|
|
31
|
+
const wronglyRejectedLegit = [];
|
|
32
|
+
// Payloads whose escape depends on a URL-decode step that never happens
|
|
33
|
+
// at the filesystem-path layer (resolveWithinRoot does no decoding, by
|
|
34
|
+
// design — Node's path module treats "%2f" as a literal filename
|
|
35
|
+
// character, not a separator). These correctly resolve *inside* root and
|
|
36
|
+
// are recorded as informational evidence only, not a rejection failure.
|
|
37
|
+
const payloadsNotApplicableToRawPathResolution = new Set(["path-traversal-encoded"]);
|
|
38
|
+
// On POSIX (Linux/macOS), "\" is a literal filename character, not a
|
|
39
|
+
// path separator — Node's posix path implementation never parses
|
|
40
|
+
// "..\\..\\Windows\\..." as parent-directory components, so it
|
|
41
|
+
// legitimately resolves inside root there. On win32, "\" IS a separator
|
|
42
|
+
// and the payload must still be rejected, so this exclusion only applies
|
|
43
|
+
// off-Windows (verified by the CI cross-platform matrix: this scenario
|
|
44
|
+
// was passing on windows-latest but failing on ubuntu-latest/macos-latest
|
|
45
|
+
// for exactly this platform-semantics reason before this fix).
|
|
46
|
+
if (process.platform !== "win32") {
|
|
47
|
+
payloadsNotApplicableToRawPathResolution.add("path-traversal-windows-parent");
|
|
48
|
+
}
|
|
49
|
+
const traversalPayloads = getPayloadsForGroup("path-traversal");
|
|
50
|
+
for (const p of traversalPayloads) {
|
|
51
|
+
let rejected = false;
|
|
52
|
+
let observed = "";
|
|
53
|
+
try {
|
|
54
|
+
const resolved = resolveWithinRoot(ctx.target.targetRoot, p.value);
|
|
55
|
+
observed = `Resolved to '${resolved}' without being rejected.`;
|
|
56
|
+
}
|
|
57
|
+
catch {
|
|
58
|
+
rejected = true;
|
|
59
|
+
observed = "Rejected (threw), as expected.";
|
|
60
|
+
}
|
|
61
|
+
if (!rejected && !payloadsNotApplicableToRawPathResolution.has(p.id)) {
|
|
62
|
+
unrejectedTraversals.push(p.id);
|
|
63
|
+
}
|
|
64
|
+
evidence.push(makeEvidence({
|
|
65
|
+
kind: "filesystem",
|
|
66
|
+
source: `resolveWithinRoot() with payload '${p.id}'`,
|
|
67
|
+
confidence: "high",
|
|
68
|
+
expectedBehavior: payloadsNotApplicableToRawPathResolution.has(p.id)
|
|
69
|
+
? "No path separators present without decoding; resolving inside root is safe (not an escape)."
|
|
70
|
+
: "Path-traversal payloads must be rejected (thrown), never silently resolved.",
|
|
71
|
+
observedBehavior: observed,
|
|
72
|
+
rawPreview: p.value,
|
|
73
|
+
}));
|
|
74
|
+
}
|
|
75
|
+
for (const c of LEGITIMATE_PATH_CASES) {
|
|
76
|
+
let accepted = false;
|
|
77
|
+
let observed = "";
|
|
78
|
+
try {
|
|
79
|
+
const resolved = resolveWithinRoot(ctx.target.targetRoot, c.value);
|
|
80
|
+
accepted = true;
|
|
81
|
+
observed = `Resolved to '${resolved}' as expected.`;
|
|
82
|
+
}
|
|
83
|
+
catch (err) {
|
|
84
|
+
observed = `Unexpectedly rejected: ${err instanceof Error ? err.message : String(err)}`;
|
|
85
|
+
}
|
|
86
|
+
if (!accepted) {
|
|
87
|
+
wronglyRejectedLegit.push(c.id);
|
|
88
|
+
}
|
|
89
|
+
evidence.push(makeEvidence({
|
|
90
|
+
kind: "filesystem",
|
|
91
|
+
source: `resolveWithinRoot() with legitimate path '${c.id}'`,
|
|
92
|
+
confidence: "high",
|
|
93
|
+
expectedBehavior: "Legitimate relative paths (including ones with spaces) must be accepted.",
|
|
94
|
+
observedBehavior: observed,
|
|
95
|
+
rawPreview: c.value,
|
|
96
|
+
}));
|
|
97
|
+
}
|
|
98
|
+
if (unrejectedTraversals.length > 0 || wronglyRejectedLegit.length > 0) {
|
|
99
|
+
const parts = [];
|
|
100
|
+
if (unrejectedTraversals.length > 0) {
|
|
101
|
+
parts.push(`${unrejectedTraversals.length} traversal payload(s) were not rejected: ${unrejectedTraversals.join(", ")}`);
|
|
102
|
+
}
|
|
103
|
+
if (wronglyRejectedLegit.length > 0) {
|
|
104
|
+
parts.push(`${wronglyRejectedLegit.length} legitimate path(s) were wrongly rejected: ${wronglyRejectedLegit.join(", ")}`);
|
|
105
|
+
}
|
|
106
|
+
return {
|
|
107
|
+
status: "failed",
|
|
108
|
+
confidence: "high",
|
|
109
|
+
evidence,
|
|
110
|
+
recommendation: parts.join("; "),
|
|
111
|
+
};
|
|
112
|
+
}
|
|
113
|
+
return {
|
|
114
|
+
status: "passed",
|
|
115
|
+
confidence: "high",
|
|
116
|
+
evidence,
|
|
117
|
+
};
|
|
118
|
+
},
|
|
119
|
+
};
|
|
@@ -0,0 +1,176 @@
|
|
|
1
|
+
import { makeEvidence, stripUnsafeControlChars } from "../exploitEvidence.js";
|
|
2
|
+
import { getPayloadsForGroup } from "../payloadCorpus.js";
|
|
3
|
+
import { renderJsonReport, renderTextReport, sanitizeForTextReport } from "../../report/renderSecurityReport.js";
|
|
4
|
+
import { SAFE_BASELINE_CONTENT, detectJsonStructuralInjection } from "../reportSchemaGuard.js";
|
|
5
|
+
// ---------------------------------------------------------------------------
|
|
6
|
+
// v0.2.2 Batch 4 — report poisoning scenario.
|
|
7
|
+
//
|
|
8
|
+
// Registered under checkId "boundary" — the AttackScenario contract supports
|
|
9
|
+
// only one primary check id per scenario, and report-rendering safety is a
|
|
10
|
+
// boundary-of-trust concern (untrusted scanned/attacker content must not
|
|
11
|
+
// escape into trusted report structure), same family as Batch 3's
|
|
12
|
+
// configInjectionScenario (which covers a narrower slice of this: malformed
|
|
13
|
+
// CLI config + basic JSON-breakout safety). This scenario is the dedicated,
|
|
14
|
+
// broader check: ANSI/terminal escapes, fake section headers, HTML/script-
|
|
15
|
+
// like content, markdown-link-like content, and JSON structural integrity —
|
|
16
|
+
// all exercised through the real renderJsonReport()/renderTextReport()
|
|
17
|
+
// functions with controlled, non-destructive mock findings (no payload is
|
|
18
|
+
// ever written into target source files).
|
|
19
|
+
// ---------------------------------------------------------------------------
|
|
20
|
+
const REAL_VERDICT_BLOCK = ["=".repeat(72), "21. RELEASE VERDICT", "=".repeat(72)].join("\n");
|
|
21
|
+
function makePoisonedReport(payloadValue) {
|
|
22
|
+
const now = new Date().toISOString();
|
|
23
|
+
return {
|
|
24
|
+
metadata: {
|
|
25
|
+
toolRoot: "/tool/root",
|
|
26
|
+
toolPackageName: "my-dev-kit-lab",
|
|
27
|
+
toolPackageVersion: "0.2.1",
|
|
28
|
+
targetRoot: "/tool/root",
|
|
29
|
+
targetDescription: "self (my-dev-kit-lab)",
|
|
30
|
+
packageName: "my-dev-kit-lab",
|
|
31
|
+
packageVersion: "0.2.1",
|
|
32
|
+
branch: "feature/security-validate-config-surface",
|
|
33
|
+
commit: "abc1234",
|
|
34
|
+
isSelf: true,
|
|
35
|
+
generatedAt: now,
|
|
36
|
+
totalDurationMs: 1,
|
|
37
|
+
},
|
|
38
|
+
sections: [],
|
|
39
|
+
allChecks: [
|
|
40
|
+
{
|
|
41
|
+
id: "report-poisoning-fixture",
|
|
42
|
+
name: payloadValue,
|
|
43
|
+
category: "artifact-safety",
|
|
44
|
+
status: "failed",
|
|
45
|
+
severity: "major",
|
|
46
|
+
startedAt: now,
|
|
47
|
+
finishedAt: now,
|
|
48
|
+
durationMs: 0,
|
|
49
|
+
findings: [
|
|
50
|
+
{
|
|
51
|
+
id: "report-poisoning-fixture-finding",
|
|
52
|
+
title: payloadValue,
|
|
53
|
+
severity: "major",
|
|
54
|
+
category: "artifact-safety",
|
|
55
|
+
description: payloadValue,
|
|
56
|
+
recommendation: payloadValue,
|
|
57
|
+
releaseImpact: "Should fix before release",
|
|
58
|
+
},
|
|
59
|
+
],
|
|
60
|
+
skippedReason: payloadValue,
|
|
61
|
+
},
|
|
62
|
+
],
|
|
63
|
+
allFindings: [],
|
|
64
|
+
verdict: "ready-except-optional-manual-checks",
|
|
65
|
+
recommendedNextStep: payloadValue,
|
|
66
|
+
attackResults: [
|
|
67
|
+
{
|
|
68
|
+
scenarioId: "report-poisoning-fixture-scenario",
|
|
69
|
+
scenarioTitle: payloadValue,
|
|
70
|
+
checkId: "boundary",
|
|
71
|
+
profileId: "node-cli-package",
|
|
72
|
+
status: "failed",
|
|
73
|
+
severity: "major",
|
|
74
|
+
confidence: "low",
|
|
75
|
+
evidence: [
|
|
76
|
+
makeEvidence({ kind: "observation", source: "fixture", confidence: "low", rawPreview: payloadValue }),
|
|
77
|
+
],
|
|
78
|
+
category: "artifact-safety",
|
|
79
|
+
recommendation: payloadValue,
|
|
80
|
+
skippedReason: payloadValue,
|
|
81
|
+
},
|
|
82
|
+
],
|
|
83
|
+
};
|
|
84
|
+
}
|
|
85
|
+
export const REPORT_POISONING_SCENARIO = {
|
|
86
|
+
id: "report-poisoning-safety",
|
|
87
|
+
title: "Report poisoning: renderer treats payload content as untrusted data, never trusted structure",
|
|
88
|
+
description: "Renders text and JSON reports with report-poisoning payloads (ANSI escapes, fake section headers, JSON breakout, HTML/script-like, markdown-link-like) embedded in check/finding/attack-result fields, and confirms: JSON stays parseable with no extra top-level keys, text rendering never crashes, the real RELEASE VERDICT block is structurally intact, and no raw ANSI control bytes reach the final text output.",
|
|
89
|
+
checkId: "boundary",
|
|
90
|
+
applicableProfiles: [],
|
|
91
|
+
severityBaseline: "major",
|
|
92
|
+
verdictImpact: "tool-framework-blocker",
|
|
93
|
+
expectedSafeBehavior: "Report-poisoning payloads are rendered as inert content; they never corrupt JSON structure, crash rendering, or forge a trusted verdict/section.",
|
|
94
|
+
evidenceRequirements: ["output", "observation"],
|
|
95
|
+
run: async () => {
|
|
96
|
+
const evidence = [];
|
|
97
|
+
const problems = [];
|
|
98
|
+
const payloads = getPayloadsForGroup("report-poisoning");
|
|
99
|
+
// Baseline-diff check (v0.2.2 Batch 6): compare each poisoned render's
|
|
100
|
+
// top-level JSON keys against an identically-shaped render carrying an
|
|
101
|
+
// inert placeholder instead of the payload. A payload can only widen the
|
|
102
|
+
// top-level shape by actually breaking out of its string context —
|
|
103
|
+
// legitimate additive schema fields appear in both and are never
|
|
104
|
+
// flagged, so this doesn't need to know the current field list at all.
|
|
105
|
+
const baselineJson = renderJsonReport(makePoisonedReport(SAFE_BASELINE_CONTENT));
|
|
106
|
+
for (const payload of payloads) {
|
|
107
|
+
let jsonOk = true;
|
|
108
|
+
let jsonExtraKeys = [];
|
|
109
|
+
let textOk = true;
|
|
110
|
+
let textError;
|
|
111
|
+
let verdictBlockIntact = true;
|
|
112
|
+
let rawAnsiLeaked = false;
|
|
113
|
+
const report = makePoisonedReport(payload.value);
|
|
114
|
+
try {
|
|
115
|
+
const json = renderJsonReport(report);
|
|
116
|
+
const injection = detectJsonStructuralInjection(baselineJson, json);
|
|
117
|
+
jsonExtraKeys = injection.injectedTopLevelKeys;
|
|
118
|
+
jsonOk = injection.parseable && jsonExtraKeys.length === 0;
|
|
119
|
+
}
|
|
120
|
+
catch {
|
|
121
|
+
jsonOk = false;
|
|
122
|
+
}
|
|
123
|
+
let text = "";
|
|
124
|
+
try {
|
|
125
|
+
text = renderTextReport(report);
|
|
126
|
+
}
|
|
127
|
+
catch (err) {
|
|
128
|
+
textOk = false;
|
|
129
|
+
textError = err instanceof Error ? err.message : String(err);
|
|
130
|
+
}
|
|
131
|
+
if (textOk) {
|
|
132
|
+
verdictBlockIntact = text.includes(REAL_VERDICT_BLOCK);
|
|
133
|
+
rawAnsiLeaked = /\x1b\[[0-9;]*[a-zA-Z]/.test(text);
|
|
134
|
+
}
|
|
135
|
+
const payloadProblem = !jsonOk || !textOk || !verdictBlockIntact || rawAnsiLeaked;
|
|
136
|
+
if (payloadProblem) {
|
|
137
|
+
problems.push(payload.id);
|
|
138
|
+
}
|
|
139
|
+
evidence.push(makeEvidence({
|
|
140
|
+
kind: "output",
|
|
141
|
+
source: `renderJsonReport()/renderTextReport() with payload '${payload.id}'`,
|
|
142
|
+
confidence: "high",
|
|
143
|
+
expectedBehavior: "JSON parseable with no extra top-level keys; text rendering does not crash; real verdict block intact; no raw ANSI bytes in text output.",
|
|
144
|
+
observedBehavior: `jsonOk=${jsonOk}${jsonExtraKeys.length > 0 ? ` (extraKeys=${jsonExtraKeys.join(",")})` : ""}, textOk=${textOk}${textError ? ` (${textError})` : ""}, verdictBlockIntact=${verdictBlockIntact}, rawAnsiLeaked=${rawAnsiLeaked}`,
|
|
145
|
+
rawPreview: payload.value,
|
|
146
|
+
}));
|
|
147
|
+
}
|
|
148
|
+
// Direct regression check on the sanitizer itself, independent of the
|
|
149
|
+
// full report pipeline above.
|
|
150
|
+
const ansiPayload = payloads.find((p) => p.id === "report-poisoning-ansi-escape");
|
|
151
|
+
if (ansiPayload) {
|
|
152
|
+
const sanitized = sanitizeForTextReport(ansiPayload.value);
|
|
153
|
+
const stillHasAnsi = /\x1b\[[0-9;]*[a-zA-Z]/.test(sanitized);
|
|
154
|
+
const stillHasAnsiViaStrip = /\x1b\[[0-9;]*[a-zA-Z]/.test(stripUnsafeControlChars(ansiPayload.value));
|
|
155
|
+
if (stillHasAnsi || stillHasAnsiViaStrip) {
|
|
156
|
+
problems.push("ansi-sanitizer-regression");
|
|
157
|
+
}
|
|
158
|
+
evidence.push(makeEvidence({
|
|
159
|
+
kind: "observation",
|
|
160
|
+
source: "sanitizeForTextReport() / stripUnsafeControlChars() direct check",
|
|
161
|
+
confidence: "high",
|
|
162
|
+
expectedBehavior: "ANSI escape sequences are removed by the sanitizer.",
|
|
163
|
+
observedBehavior: stillHasAnsi || stillHasAnsiViaStrip ? "ANSI bytes survived sanitization." : "ANSI bytes removed.",
|
|
164
|
+
}));
|
|
165
|
+
}
|
|
166
|
+
if (problems.length > 0) {
|
|
167
|
+
return {
|
|
168
|
+
status: "failed",
|
|
169
|
+
confidence: "high",
|
|
170
|
+
evidence,
|
|
171
|
+
recommendation: `Report rendering is not safe against payload(s): ${problems.join(", ")}. Fix before treating generated reports as safe to share.`,
|
|
172
|
+
};
|
|
173
|
+
}
|
|
174
|
+
return { status: "passed", confidence: "high", evidence };
|
|
175
|
+
},
|
|
176
|
+
};
|