@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,143 @@
|
|
|
1
|
+
import { makeEvidence } from "../exploitEvidence.js";
|
|
2
|
+
import { collectBoundedSourceFiles, lineNumberAt } from "../boundedSourceScan.js";
|
|
3
|
+
const SEVERITY_FOR_KIND = {
|
|
4
|
+
"private-key": "blocker",
|
|
5
|
+
"github-token": "blocker",
|
|
6
|
+
"openai-token": "blocker",
|
|
7
|
+
"aws-key": "blocker",
|
|
8
|
+
"generic-secret-assignment": "major",
|
|
9
|
+
};
|
|
10
|
+
const SECRET_PATTERNS = [
|
|
11
|
+
{ kind: "private-key", regex: /-----BEGIN [A-Z ]*PRIVATE KEY-----[\s\S]*?-----END [A-Z ]*PRIVATE KEY-----/g },
|
|
12
|
+
{ kind: "github-token", regex: /gh[pousr]_[A-Za-z0-9]{16,}/g },
|
|
13
|
+
{ kind: "openai-token", regex: /sk-[A-Za-z0-9]{16,}/g },
|
|
14
|
+
{ kind: "aws-key", regex: /AKIA[0-9A-Z]{12,}/g },
|
|
15
|
+
{
|
|
16
|
+
kind: "generic-secret-assignment",
|
|
17
|
+
regex: /(?:password|passwd|secret|api[_-]?key|token)\s*[:=]\s*["']?([A-Za-z0-9_\-]{8,})["']?/gi,
|
|
18
|
+
},
|
|
19
|
+
];
|
|
20
|
+
const PLACEHOLDER_MARKERS = [
|
|
21
|
+
/\bexample\b/i,
|
|
22
|
+
/change[-_]?me/i,
|
|
23
|
+
/\bdummy\b/i,
|
|
24
|
+
/\btest\b/i,
|
|
25
|
+
/\bfake\b/i,
|
|
26
|
+
/your[-_]?api[-_]?key/i,
|
|
27
|
+
/<[a-z_]+>/i,
|
|
28
|
+
/\bxxxx+\b/i,
|
|
29
|
+
/\bplaceholder\b/i,
|
|
30
|
+
/\bsample\b/i,
|
|
31
|
+
/\bredacted\b/i,
|
|
32
|
+
];
|
|
33
|
+
function isPlaceholder(text) {
|
|
34
|
+
return PLACEHOLDER_MARKERS.some((p) => p.test(text));
|
|
35
|
+
}
|
|
36
|
+
const SEVERITY_RANK = {
|
|
37
|
+
blocker: 4,
|
|
38
|
+
major: 3,
|
|
39
|
+
minor: 2,
|
|
40
|
+
informational: 1,
|
|
41
|
+
skipped: 0,
|
|
42
|
+
};
|
|
43
|
+
function scanFileForSecrets(relativePath, content) {
|
|
44
|
+
const findings = [];
|
|
45
|
+
for (const { kind, regex } of SECRET_PATTERNS) {
|
|
46
|
+
// Each pattern object's `regex` is reused across files; reset lastIndex
|
|
47
|
+
// via a fresh exec loop to stay correct for global regexes.
|
|
48
|
+
const re = new RegExp(regex.source, regex.flags);
|
|
49
|
+
let match;
|
|
50
|
+
while ((match = re.exec(content)) !== null) {
|
|
51
|
+
const matchedText = match[0];
|
|
52
|
+
// For the generic assignment pattern, judge placeholder-ness on the
|
|
53
|
+
// captured value; for shaped tokens, judge on the whole match.
|
|
54
|
+
const judgedText = kind === "generic-secret-assignment" ? (match[1] ?? matchedText) : matchedText;
|
|
55
|
+
if (isPlaceholder(judgedText) || isPlaceholder(matchedText)) {
|
|
56
|
+
// Downgrade: not a high-confidence finding, skip entirely rather
|
|
57
|
+
// than add noise — placeholders are expected in source/config.
|
|
58
|
+
continue;
|
|
59
|
+
}
|
|
60
|
+
// Guard against zero-length matches looping forever.
|
|
61
|
+
if (matchedText.length === 0) {
|
|
62
|
+
re.lastIndex += 1;
|
|
63
|
+
continue;
|
|
64
|
+
}
|
|
65
|
+
findings.push({
|
|
66
|
+
kind,
|
|
67
|
+
file: relativePath,
|
|
68
|
+
line: lineNumberAt(content, match.index),
|
|
69
|
+
matchedText,
|
|
70
|
+
matchLength: matchedText.length,
|
|
71
|
+
severity: SEVERITY_FOR_KIND[kind],
|
|
72
|
+
});
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
return findings;
|
|
76
|
+
}
|
|
77
|
+
export const SECRET_LEAKAGE_SCENARIO = {
|
|
78
|
+
id: "secret-leakage-bounded-scan",
|
|
79
|
+
title: "Secret leakage: bounded static scan finds no high-confidence secret-shaped literals",
|
|
80
|
+
description: "Scans a bounded, deterministic set of project files (manifests, tsconfig, src/, scripts/, .env*) for high-confidence secret-shaped literal values (private keys, GitHub/OpenAI/AWS-style tokens, generic password/token/secret assignments). Placeholder values and bare env-var-name references are excluded from findings.",
|
|
81
|
+
checkId: "secrets",
|
|
82
|
+
applicableProfiles: [],
|
|
83
|
+
severityBaseline: "informational",
|
|
84
|
+
verdictImpact: "target-project-blocker",
|
|
85
|
+
expectedSafeBehavior: "No high-confidence secret-shaped literal values are present in the bounded glob set inspected.",
|
|
86
|
+
evidenceRequirements: ["secret-leak", "observation"],
|
|
87
|
+
run: async (ctx) => {
|
|
88
|
+
const files = collectBoundedSourceFiles(ctx.target.targetRoot);
|
|
89
|
+
if (files.length === 0) {
|
|
90
|
+
return {
|
|
91
|
+
status: "passed",
|
|
92
|
+
confidence: "low",
|
|
93
|
+
evidence: [
|
|
94
|
+
makeEvidence({
|
|
95
|
+
kind: "observation",
|
|
96
|
+
source: "bounded secret scan file collection",
|
|
97
|
+
confidence: "low",
|
|
98
|
+
expectedBehavior: "At least one file would be scanned for a meaningful bounded-scan claim.",
|
|
99
|
+
observedBehavior: "0 files matched the bounded glob set (package.json/tsconfig/src/scripts/.env*). No findings because there was nothing to scan — this does not prove the target has no secrets elsewhere.",
|
|
100
|
+
}),
|
|
101
|
+
],
|
|
102
|
+
};
|
|
103
|
+
}
|
|
104
|
+
const allFindings = [];
|
|
105
|
+
for (const file of files) {
|
|
106
|
+
allFindings.push(...scanFileForSecrets(file.relativePath, file.content));
|
|
107
|
+
}
|
|
108
|
+
allFindings.sort((a, b) => a.file.localeCompare(b.file) || a.line - b.line);
|
|
109
|
+
const evidence = [
|
|
110
|
+
makeEvidence({
|
|
111
|
+
kind: "observation",
|
|
112
|
+
source: "bounded secret scan",
|
|
113
|
+
confidence: "medium",
|
|
114
|
+
expectedBehavior: "Bounded scan of manifests/tsconfig/src/scripts/.env* files.",
|
|
115
|
+
observedBehavior: `${files.length} file(s) scanned; ${allFindings.length} high-confidence secret-shaped finding(s).`,
|
|
116
|
+
}),
|
|
117
|
+
...allFindings.slice(0, 20).map((f) => makeEvidence({
|
|
118
|
+
kind: "secret-leak",
|
|
119
|
+
source: `pattern '${f.kind}'`,
|
|
120
|
+
filePath: f.file,
|
|
121
|
+
line: f.line,
|
|
122
|
+
confidence: "high",
|
|
123
|
+
expectedBehavior: "No literal high-confidence secret values in scanned source paths.",
|
|
124
|
+
// Manually pre-redacted: never pass the raw matched text through —
|
|
125
|
+
// some generic-assignment matches are shorter than the 32-char
|
|
126
|
+
// generic redaction threshold in redactPreview(), so we redact
|
|
127
|
+
// explicitly here rather than relying on it for this scenario.
|
|
128
|
+
rawPreview: `${f.kind} match in ${f.file}:${f.line} [REDACTED:${f.matchLength}chars]`,
|
|
129
|
+
})),
|
|
130
|
+
];
|
|
131
|
+
if (allFindings.length === 0) {
|
|
132
|
+
return { status: "passed", confidence: "medium", evidence };
|
|
133
|
+
}
|
|
134
|
+
const worstSeverity = allFindings.reduce((worst, f) => (SEVERITY_RANK[f.severity] > SEVERITY_RANK[worst] ? f.severity : worst), "informational");
|
|
135
|
+
return {
|
|
136
|
+
status: "failed",
|
|
137
|
+
confidence: "high",
|
|
138
|
+
evidence,
|
|
139
|
+
severity: worstSeverity,
|
|
140
|
+
recommendation: `Remove or rotate ${allFindings.length} high-confidence secret-shaped value(s) found in the scanned bounded file set; move secrets to environment variables or a secret manager, never literal source/config values.`,
|
|
141
|
+
};
|
|
142
|
+
},
|
|
143
|
+
};
|
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
import fs from "node:fs";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
import { fileURLToPath } from "node:url";
|
|
4
|
+
import { makeEvidence } from "../exploitEvidence.js";
|
|
5
|
+
import { getPayloadsForGroup } from "../payloadCorpus.js";
|
|
6
|
+
import { runSecurityCommand } from "../../commandRunner.js";
|
|
7
|
+
// ---------------------------------------------------------------------------
|
|
8
|
+
// v0.2.2 Batch 3 — subprocess injection scenario.
|
|
9
|
+
//
|
|
10
|
+
// Non-destructive by construction: never executes a payload as a shell
|
|
11
|
+
// command. Instead it runs `node -e "..."` with each subprocess-injection
|
|
12
|
+
// payload passed as a single argv element (via spawn's argument array, the
|
|
13
|
+
// same mechanism runSecurityCommand/runMeasuredCommand use), and confirms
|
|
14
|
+
// the child process receives it back as exactly one literal argument — proof
|
|
15
|
+
// that no shell re-interpreted it (splitting it into multiple commands via
|
|
16
|
+
// `;`, `&&`, backticks, `$()`, or `|`).
|
|
17
|
+
//
|
|
18
|
+
// Also does a bounded static check that the command-execution helpers this
|
|
19
|
+
// scenario exercises are built with `shell: false` (source inspection of a
|
|
20
|
+
// known-small file, not a general code scanner).
|
|
21
|
+
// ---------------------------------------------------------------------------
|
|
22
|
+
const ECHO_ARGV_SCRIPT = "process.stdout.write(JSON.stringify(process.argv.slice(1)))";
|
|
23
|
+
function commandRunnerSourcePath() {
|
|
24
|
+
const here = path.dirname(fileURLToPath(import.meta.url));
|
|
25
|
+
return path.resolve(here, "..", "..", "commandRunner.ts");
|
|
26
|
+
}
|
|
27
|
+
export const SUBPROCESS_INJECTION_SCENARIO = {
|
|
28
|
+
id: "subprocess-injection-safety",
|
|
29
|
+
title: "Subprocess injection: shell metacharacter payloads are passed as literal arguments",
|
|
30
|
+
description: "Runs a benign argv-echoing Node script with each subprocess-injection payload as a single spawn argument and confirms it round-trips as exactly one literal string, proving no shell re-interpretation. Also checks that commandRunner.ts constructs subprocess calls with shell:false.",
|
|
31
|
+
checkId: "subprocess",
|
|
32
|
+
applicableProfiles: [],
|
|
33
|
+
severityBaseline: "blocker",
|
|
34
|
+
verdictImpact: "tool-framework-blocker",
|
|
35
|
+
expectedSafeBehavior: "Every subprocess-injection payload round-trips as a single literal argv element; commandRunner.ts uses shell:false.",
|
|
36
|
+
evidenceRequirements: ["command", "observation"],
|
|
37
|
+
run: async (ctx) => {
|
|
38
|
+
const evidence = [];
|
|
39
|
+
const notLiteral = [];
|
|
40
|
+
const payloads = getPayloadsForGroup("subprocess-injection");
|
|
41
|
+
for (const p of payloads) {
|
|
42
|
+
const cmd = await runSecurityCommand({
|
|
43
|
+
command: process.execPath,
|
|
44
|
+
args: ["-e", ECHO_ARGV_SCRIPT, p.value],
|
|
45
|
+
cwd: ctx.toolRoot,
|
|
46
|
+
timeoutMs: 10_000,
|
|
47
|
+
});
|
|
48
|
+
let roundTrippedCorrectly = false;
|
|
49
|
+
try {
|
|
50
|
+
const parsed = JSON.parse(cmd.stdout.trim());
|
|
51
|
+
roundTrippedCorrectly = parsed.length === 1 && parsed[0] === p.value;
|
|
52
|
+
}
|
|
53
|
+
catch {
|
|
54
|
+
roundTrippedCorrectly = false;
|
|
55
|
+
}
|
|
56
|
+
if (!roundTrippedCorrectly) {
|
|
57
|
+
notLiteral.push(p.id);
|
|
58
|
+
}
|
|
59
|
+
evidence.push(makeEvidence({
|
|
60
|
+
kind: "command",
|
|
61
|
+
source: `runSecurityCommand() argv round-trip for payload '${p.id}'`,
|
|
62
|
+
commandCwd: ctx.toolRoot,
|
|
63
|
+
commandSummary: "node -e <argv-echo-script> <payload>",
|
|
64
|
+
exitCode: cmd.exitCode,
|
|
65
|
+
confidence: "high",
|
|
66
|
+
expectedBehavior: "Payload must round-trip as exactly one literal argv element.",
|
|
67
|
+
observedBehavior: roundTrippedCorrectly
|
|
68
|
+
? "Round-tripped as a single literal argument, as expected."
|
|
69
|
+
: "Did NOT round-trip as a single literal argument — possible shell interpretation.",
|
|
70
|
+
rawPreview: p.value,
|
|
71
|
+
}));
|
|
72
|
+
}
|
|
73
|
+
let shellFalseConfirmed = false;
|
|
74
|
+
let sourceReadable = true;
|
|
75
|
+
try {
|
|
76
|
+
const source = fs.readFileSync(commandRunnerSourcePath(), "utf8");
|
|
77
|
+
shellFalseConfirmed = /shell:\s*false/.test(source);
|
|
78
|
+
}
|
|
79
|
+
catch {
|
|
80
|
+
sourceReadable = false;
|
|
81
|
+
}
|
|
82
|
+
evidence.push(makeEvidence({
|
|
83
|
+
kind: "observation",
|
|
84
|
+
source: "static check: src/securityValidation/commandRunner.ts",
|
|
85
|
+
confidence: sourceReadable ? "high" : "low",
|
|
86
|
+
expectedBehavior: "Subprocess calls should be constructed with shell:false.",
|
|
87
|
+
observedBehavior: sourceReadable
|
|
88
|
+
? shellFalseConfirmed
|
|
89
|
+
? "shell:false found in commandRunner.ts."
|
|
90
|
+
: "shell:false NOT found in commandRunner.ts."
|
|
91
|
+
: "Could not read commandRunner.ts source for static confirmation.",
|
|
92
|
+
}));
|
|
93
|
+
if (notLiteral.length > 0 || (sourceReadable && !shellFalseConfirmed)) {
|
|
94
|
+
return {
|
|
95
|
+
status: "failed",
|
|
96
|
+
confidence: "high",
|
|
97
|
+
evidence,
|
|
98
|
+
recommendation: notLiteral.length > 0
|
|
99
|
+
? `Investigate argument construction — payload(s) did not round-trip literally: ${notLiteral.join(", ")}.`
|
|
100
|
+
: "commandRunner.ts must construct child_process.spawn calls with shell:false.",
|
|
101
|
+
};
|
|
102
|
+
}
|
|
103
|
+
if (!sourceReadable) {
|
|
104
|
+
return {
|
|
105
|
+
status: "blocked",
|
|
106
|
+
confidence: "low",
|
|
107
|
+
evidence,
|
|
108
|
+
recommendation: "Could not statically confirm shell:false; argv round-trip evidence is still valid.",
|
|
109
|
+
};
|
|
110
|
+
}
|
|
111
|
+
return {
|
|
112
|
+
status: "passed",
|
|
113
|
+
confidence: "high",
|
|
114
|
+
evidence,
|
|
115
|
+
};
|
|
116
|
+
},
|
|
117
|
+
};
|
|
@@ -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") ||
|