@dailephd/my-dev-kit-lab 0.2.1 → 0.2.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (31) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +35 -24
  3. package/dist/scripts/security/validate.js +68 -29
  4. package/dist/src/securityValidation/attackScenarios/attackProfile.js +20 -0
  5. package/dist/src/securityValidation/attackScenarios/attackResult.js +37 -0
  6. package/dist/src/securityValidation/attackScenarios/attackRunner.js +153 -0
  7. package/dist/src/securityValidation/attackScenarios/attackScenario.js +21 -0
  8. package/dist/src/securityValidation/attackScenarios/boundedSourceScan.js +100 -0
  9. package/dist/src/securityValidation/attackScenarios/exploitEvidence.js +69 -0
  10. package/dist/src/securityValidation/attackScenarios/payloadCorpus.js +0 -0
  11. package/dist/src/securityValidation/attackScenarios/profiles/localToolProfile.js +10 -0
  12. package/dist/src/securityValidation/attackScenarios/profiles/nodeCliPackageProfile.js +20 -0
  13. package/dist/src/securityValidation/attackScenarios/profiles/npmPackageProfile.js +10 -0
  14. package/dist/src/securityValidation/attackScenarios/reportSchemaGuard.js +41 -0
  15. package/dist/src/securityValidation/attackScenarios/scenarios/configInjectionScenario.js +144 -0
  16. package/dist/src/securityValidation/attackScenarios/scenarios/networkAssumptionScenario.js +129 -0
  17. package/dist/src/securityValidation/attackScenarios/scenarios/outputBoundaryScenario.js +69 -0
  18. package/dist/src/securityValidation/attackScenarios/scenarios/packageBoundaryScenario.js +118 -0
  19. package/dist/src/securityValidation/attackScenarios/scenarios/pathTraversalScenario.js +119 -0
  20. package/dist/src/securityValidation/attackScenarios/scenarios/reportPoisoningScenario.js +176 -0
  21. package/dist/src/securityValidation/attackScenarios/scenarios/secretLeakageScenario.js +143 -0
  22. package/dist/src/securityValidation/attackScenarios/scenarios/subprocessInjectionScenario.js +117 -0
  23. package/dist/src/securityValidation/attackScenarios/scenarios/targetSandboxScenario.js +118 -0
  24. package/dist/src/securityValidation/attackScenarios/targetSnapshot.js +70 -0
  25. package/dist/src/securityValidation/packageChecks/forbiddenPackageContents.js +4 -1
  26. package/dist/src/securityValidation/report/renderSecurityReport.js +135 -13
  27. package/dist/src/securityValidation/types.js +7 -0
  28. package/dist/src/securityValidation/validate/cliOptions.js +182 -0
  29. package/dist/src/securityValidation/validate/runSecurityValidation.js +134 -35
  30. package/dist/src/securityValidation/validate/verdict.js +147 -0
  31. package/package.json +16 -16
@@ -0,0 +1,69 @@
1
+ // ---------------------------------------------------------------------------
2
+ // v0.2.2 Batch 2 — exploit evidence model.
3
+ //
4
+ // Evidence attached to an AttackResult. Kept small and JSON-serializable by
5
+ // construction (no functions, no circular references, no raw Buffers).
6
+ // Redaction helpers here must never leak full secret-like values into
7
+ // reports; only bounded, redacted previews are stored.
8
+ // ---------------------------------------------------------------------------
9
+ export const EXPLOIT_EVIDENCE_KINDS = [
10
+ "command",
11
+ "filesystem",
12
+ "output",
13
+ "target-modification",
14
+ "secret-leak",
15
+ "package-content",
16
+ "skipped-tool",
17
+ "skipped-scenario",
18
+ "observation",
19
+ ];
20
+ const MAX_PREVIEW_LENGTH = 200;
21
+ // Patterns for common secret-like tokens. Deliberately conservative — false
22
+ // positives (over-redacting) are safe; false negatives are not.
23
+ const SECRET_LIKE_PATTERNS = [
24
+ /sk-[a-zA-Z0-9]{16,}/g,
25
+ /gh[pousr]_[a-zA-Z0-9]{16,}/g,
26
+ /AKIA[0-9A-Z]{12,}/g,
27
+ /-----BEGIN [A-Z ]*PRIVATE KEY-----[\s\S]*?-----END [A-Z ]*PRIVATE KEY-----/g,
28
+ /[A-Za-z0-9_\-]{32,}/g,
29
+ ];
30
+ // ANSI CSI sequences (color codes, cursor movement, etc.) and other C0/DEL
31
+ // control bytes. Newline/carriage-return/tab are preserved — they're
32
+ // structural whitespace, not a terminal-rewriting risk.
33
+ const ANSI_CSI_PATTERN = /\x1b\[[0-9;]*[a-zA-Z]/g;
34
+ const OTHER_CONTROL_CHARS_PATTERN = /[\x00-\x08\x0b\x0c\x0e-\x1f\x7f]/g;
35
+ // Strips ANSI escape sequences and other unsafe control bytes. Applied
36
+ // before secret redaction so a payload cannot use control characters to
37
+ // hide from the secret-pattern matchers.
38
+ export function stripUnsafeControlChars(raw) {
39
+ return raw.replace(ANSI_CSI_PATTERN, "").replace(OTHER_CONTROL_CHARS_PATTERN, "");
40
+ }
41
+ // Redacts secret-looking substrings and terminal control sequences, and
42
+ // truncates to a bounded preview. Never returns the full original value when
43
+ // it is long, matches a secret-like pattern, or contains unsafe control
44
+ // bytes (e.g. ANSI escapes from a report-poisoning payload).
45
+ export function redactPreview(raw, maxLength = MAX_PREVIEW_LENGTH) {
46
+ let redacted = stripUnsafeControlChars(raw);
47
+ for (const pattern of SECRET_LIKE_PATTERNS) {
48
+ redacted = redacted.replace(pattern, (match) => `[REDACTED:${match.length}chars]`);
49
+ }
50
+ if (redacted.length > maxLength) {
51
+ return `${redacted.slice(0, maxLength)}... [truncated, ${raw.length} chars total]`;
52
+ }
53
+ return redacted;
54
+ }
55
+ let evidenceCounter = 0;
56
+ // Deterministic-enough id generator for evidence created within a single
57
+ // process run (report rendering does not depend on global uniqueness).
58
+ export function nextEvidenceId(prefix) {
59
+ evidenceCounter += 1;
60
+ return `${prefix}-${evidenceCounter}`;
61
+ }
62
+ export function makeEvidence(partial) {
63
+ const { rawPreview, ...rest } = partial;
64
+ return {
65
+ id: nextEvidenceId(partial.kind),
66
+ ...rest,
67
+ redactedPreview: rawPreview !== undefined ? redactPreview(rawPreview) : undefined,
68
+ };
69
+ }
@@ -0,0 +1,10 @@
1
+ export const LOCAL_TOOL_PROFILE = {
2
+ id: "local-tool",
3
+ label: "Local tool",
4
+ description: "Profile for a tool that is only ever run locally and is not published to a registry — package-content and npm-publish-shaped checks are less relevant, boundary/subprocess checks still matter.",
5
+ defaultCheckIds: ["deps", "static", "cli-adversarial", "fuzz"],
6
+ applicableCheckIds: ["deps", "static", "cli-adversarial", "fuzz", "boundary", "subprocess", "secrets", "network"],
7
+ scenarioGroups: ["boundary", "subprocess", "secrets", "network"],
8
+ mandatoryEvidenceCategories: ["command", "filesystem"],
9
+ skippedWording: "planned for a later v0.2.2 batch",
10
+ };
@@ -0,0 +1,20 @@
1
+ export const NODE_CLI_PACKAGE_PROFILE = {
2
+ id: "node-cli-package",
3
+ label: "Node CLI package",
4
+ description: "Default profile for a published Node.js CLI/package like my-dev-kit itself: local-first, deterministic, read-only with respect to user source.",
5
+ defaultCheckIds: ["deps", "package", "static", "cli-adversarial", "fuzz"],
6
+ applicableCheckIds: [
7
+ "deps",
8
+ "package",
9
+ "static",
10
+ "cli-adversarial",
11
+ "fuzz",
12
+ "boundary",
13
+ "subprocess",
14
+ "secrets",
15
+ "network",
16
+ ],
17
+ scenarioGroups: ["boundary", "subprocess", "secrets", "network"],
18
+ mandatoryEvidenceCategories: ["command", "filesystem", "output"],
19
+ skippedWording: "planned for a later v0.2.2 batch",
20
+ };
@@ -0,0 +1,10 @@
1
+ export const NPM_PACKAGE_PROFILE = {
2
+ id: "npm-package",
3
+ label: "npm package",
4
+ description: "Profile for a general published npm package (not necessarily a CLI) — emphasizes package-content, dependency, and secret-leakage checks over CLI-argument-shaped adversarial tests.",
5
+ defaultCheckIds: ["deps", "package", "static"],
6
+ applicableCheckIds: ["deps", "package", "static", "cli-adversarial", "fuzz", "boundary", "secrets", "network"],
7
+ scenarioGroups: ["boundary", "secrets", "network"],
8
+ mandatoryEvidenceCategories: ["package-content", "secret-leak"],
9
+ skippedWording: "planned for a later v0.2.2 batch",
10
+ };
@@ -0,0 +1,41 @@
1
+ // ---------------------------------------------------------------------------
2
+ // v0.2.2 Batch 6 — report-poisoning JSON schema guard.
3
+ //
4
+ // Batch 3/4 scenarios (configInjectionScenario, reportPoisoningScenario)
5
+ // each hardcoded their own "expected top-level JSON keys" allowlist to
6
+ // detect payload-created structural injection. That approach broke twice
7
+ // (Batch 4, Batch 5) purely because a *legitimate* additive top-level field
8
+ // was introduced elsewhere and nobody remembered to update both copies of
9
+ // the list.
10
+ //
11
+ // This module replaces the hardcoded-allowlist approach with a baseline
12
+ // comparison: render the same report shape twice — once with an inert
13
+ // placeholder string in the content fields a payload would occupy, once
14
+ // with the actual payload — and compare the top-level key *sets* of the
15
+ // parsed JSON. A payload can only produce an "extra" key if it actually broke
16
+ // out of its string context and created new JSON structure; legitimate
17
+ // schema evolution (a new field added to renderJsonReport's output) shows up
18
+ // identically in both renders and is never flagged. This keeps the
19
+ // injection check meaningful without hardcoding field names anywhere.
20
+ // ---------------------------------------------------------------------------
21
+ // Inert content used for the "clean" baseline render — deliberately boring,
22
+ // never matches any payload shape.
23
+ export const SAFE_BASELINE_CONTENT = "SAFE_BASELINE_CONTENT_NOT_A_PAYLOAD";
24
+ // Compares the top-level key sets of two already-rendered JSON strings
25
+ // (baseline vs. candidate). Returns parseable:false (and no injected keys)
26
+ // if either fails to parse — a parse failure is reported separately by
27
+ // callers as "jsonCorrupted", not conflated with structural injection.
28
+ export function detectJsonStructuralInjection(baselineJson, candidateJson) {
29
+ let baseline;
30
+ let candidate;
31
+ try {
32
+ baseline = JSON.parse(baselineJson);
33
+ candidate = JSON.parse(candidateJson);
34
+ }
35
+ catch {
36
+ return { parseable: false, injectedTopLevelKeys: [] };
37
+ }
38
+ const baselineKeys = new Set(Object.keys(baseline));
39
+ const injectedTopLevelKeys = Object.keys(candidate).filter((k) => !baselineKeys.has(k));
40
+ return { parseable: true, injectedTopLevelKeys };
41
+ }
@@ -0,0 +1,144 @@
1
+ import { makeEvidence } from "../exploitEvidence.js";
2
+ import { getPayloadsForGroup } from "../payloadCorpus.js";
3
+ import { normalizeSecurityValidateConfig } from "../../validate/cliOptions.js";
4
+ import { renderJsonReport, renderTextReport } from "../../report/renderSecurityReport.js";
5
+ import { SAFE_BASELINE_CONTENT, detectJsonStructuralInjection } from "../reportSchemaGuard.js";
6
+ // ---------------------------------------------------------------------------
7
+ // v0.2.2 Batch 3 — config injection scenario.
8
+ //
9
+ // Two parts, using the Batch 2 "malformed-config" and "report-poisoning"
10
+ // payload groups:
11
+ // 1. Confirms malformed values for --checks/--profile/--format/--fail-on are
12
+ // rejected by normalizeSecurityValidateConfig() (Batch 1) rather than
13
+ // silently accepted or partially applied.
14
+ // 2. Confirms JSON-breakout / ANSI-escape style strings embedded in report
15
+ // content (e.g. via a check name or skip reason) do not corrupt
16
+ // renderJsonReport()'s output (still valid, parseable JSON) or crash
17
+ // renderTextReport().
18
+ // ---------------------------------------------------------------------------
19
+ function makeFixtureReport(injected) {
20
+ const now = new Date().toISOString();
21
+ return {
22
+ metadata: {
23
+ toolRoot: "/tool/root",
24
+ toolPackageName: "my-dev-kit-lab",
25
+ toolPackageVersion: "0.2.1",
26
+ targetRoot: "/tool/root",
27
+ targetDescription: "self (my-dev-kit-lab)",
28
+ packageName: "my-dev-kit-lab",
29
+ packageVersion: "0.2.1",
30
+ branch: injected,
31
+ commit: "abc1234",
32
+ isSelf: true,
33
+ generatedAt: now,
34
+ totalDurationMs: 1,
35
+ },
36
+ sections: [],
37
+ allChecks: [
38
+ {
39
+ id: "config-injection-fixture",
40
+ name: injected,
41
+ category: "artifact-safety",
42
+ status: "skipped",
43
+ severity: "skipped",
44
+ startedAt: now,
45
+ finishedAt: now,
46
+ durationMs: 0,
47
+ findings: [],
48
+ skippedReason: injected,
49
+ },
50
+ ],
51
+ allFindings: [],
52
+ verdict: "ready-except-optional-manual-checks",
53
+ recommendedNextStep: injected,
54
+ };
55
+ }
56
+ export const CONFIG_INJECTION_SCENARIO = {
57
+ id: "config-injection-safety",
58
+ title: "Config injection: malformed CLI config is rejected; injected content cannot corrupt reports",
59
+ description: "Confirms malformed --checks/--profile/--format/--fail-on values fail cleanly via normalizeSecurityValidateConfig(), and that JSON-breakout/ANSI-escape strings embedded in report content do not corrupt JSON output or crash text rendering.",
60
+ checkId: "boundary",
61
+ applicableProfiles: [],
62
+ severityBaseline: "major",
63
+ verdictImpact: "tool-framework-blocker",
64
+ expectedSafeBehavior: "Malformed config values always throw before any validation runs; injected content in report fields never breaks JSON parseability or text rendering.",
65
+ evidenceRequirements: ["observation", "output"],
66
+ run: async (ctx) => {
67
+ const evidence = [];
68
+ const notRejected = [];
69
+ const malformedPayloads = getPayloadsForGroup("malformed-config");
70
+ const flagsToTest = ["checks", "profile", "format", "failOn"];
71
+ for (const payload of malformedPayloads) {
72
+ for (const flag of flagsToTest) {
73
+ let rejected = false;
74
+ try {
75
+ normalizeSecurityValidateConfig({ [flag]: payload.value }, ctx.toolRoot);
76
+ }
77
+ catch {
78
+ rejected = true;
79
+ }
80
+ if (!rejected) {
81
+ notRejected.push(`${flag}=${payload.id}`);
82
+ }
83
+ }
84
+ }
85
+ evidence.push(makeEvidence({
86
+ kind: "observation",
87
+ source: "normalizeSecurityValidateConfig()",
88
+ confidence: "high",
89
+ expectedBehavior: `All ${malformedPayloads.length * flagsToTest.length} malformed (payload, flag) combinations must throw.`,
90
+ observedBehavior: notRejected.length === 0
91
+ ? "All malformed combinations were rejected."
92
+ : `${notRejected.length} combination(s) were NOT rejected.`,
93
+ }));
94
+ const poisoningPayloads = getPayloadsForGroup("report-poisoning");
95
+ const baselineJson = renderJsonReport(makeFixtureReport(SAFE_BASELINE_CONTENT));
96
+ let jsonCorrupted = false;
97
+ let textCrashed = false;
98
+ for (const payload of poisoningPayloads) {
99
+ const report = makeFixtureReport(payload.value);
100
+ try {
101
+ const json = renderJsonReport(report);
102
+ // Baseline-diff check (v0.2.2 Batch 6): a payload can only widen the
103
+ // top-level JSON shape by actually breaking out of its string
104
+ // context — legitimate additive schema fields render identically in
105
+ // both the baseline and poisoned renders and are never flagged.
106
+ const injection = detectJsonStructuralInjection(baselineJson, json);
107
+ if (!injection.parseable || injection.injectedTopLevelKeys.length > 0) {
108
+ jsonCorrupted = true;
109
+ }
110
+ }
111
+ catch {
112
+ jsonCorrupted = true;
113
+ }
114
+ try {
115
+ renderTextReport(report);
116
+ }
117
+ catch {
118
+ textCrashed = true;
119
+ }
120
+ }
121
+ evidence.push(makeEvidence({
122
+ kind: "output",
123
+ source: "renderJsonReport() / renderTextReport() with report-poisoning payloads",
124
+ confidence: "high",
125
+ expectedBehavior: "JSON stays parseable with no extra top-level keys; text rendering never throws.",
126
+ observedBehavior: `jsonCorrupted=${jsonCorrupted}, textCrashed=${textCrashed}`,
127
+ }));
128
+ if (notRejected.length > 0 || jsonCorrupted || textCrashed) {
129
+ return {
130
+ status: "failed",
131
+ confidence: "high",
132
+ evidence,
133
+ recommendation: notRejected.length > 0
134
+ ? "Ensure normalizeSecurityValidateConfig() validates every enum flag against its known-value list."
135
+ : "Ensure report rendering treats all field content as opaque data (JSON.stringify already does this by construction).",
136
+ };
137
+ }
138
+ return {
139
+ status: "passed",
140
+ confidence: "high",
141
+ evidence,
142
+ };
143
+ },
144
+ };
@@ -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
+ };