mustflow 2.103.10 → 2.103.12
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/dist/cli/commands/script-pack.js +2 -0
- package/dist/cli/i18n/en.js +33 -0
- package/dist/cli/i18n/es.js +33 -0
- package/dist/cli/i18n/fr.js +33 -0
- package/dist/cli/i18n/hi.js +33 -0
- package/dist/cli/i18n/ko.js +33 -0
- package/dist/cli/i18n/zh.js +33 -0
- package/dist/cli/lib/script-pack-registry.js +57 -0
- package/dist/cli/script-packs/repo-deploy-surface.js +98 -0
- package/dist/cli/script-packs/repo-security-pattern-scan.js +150 -0
- package/dist/core/command-env.js +26 -8
- package/dist/core/public-json-contracts.js +18 -0
- package/dist/core/repo-deploy-surface.js +428 -0
- package/dist/core/script-pack-suggestions.js +29 -2
- package/dist/core/security-pattern-scan.js +518 -0
- package/package.json +1 -1
- package/schemas/README.md +7 -0
- package/schemas/repo-deploy-surface-report.schema.json +190 -0
- package/schemas/security-pattern-scan-report.schema.json +196 -0
- package/templates/default/i18n.toml +1 -1
- package/templates/default/locales/en/.mustflow/skills/api-contract-change/SKILL.md +18 -9
- package/templates/default/manifest.toml +1 -1
|
@@ -10,7 +10,7 @@ const CODE_NAVIGATION_SCRIPT_REFS = new Set([
|
|
|
10
10
|
'repo/related-files',
|
|
11
11
|
]);
|
|
12
12
|
const CONFIG_CHAIN_SURFACES = new Set(['config', 'package', 'source', 'test']);
|
|
13
|
-
const CONFIG_FILE_PATTERN = /(?:^|\/)(?:\.gitignore|\.env\.(?:example|sample|template|defaults)|\.dev\.vars\.example|tsconfig(?:\..*)?\.json|eslint\.config\.[cm]?[jt]s|\.eslintrc(?:\.json)?|\.prettierrc(?:\.json)?|prettier\.config\.[cm]?[jt]s|vite\.config\.[cm]?[jt]s|vitest\.config\.[cm]?[jt]s|tailwind\.config\.[cm]?[jt]s|jest\.config\.[cm]?[jt]s|playwright\.config\.[cm]?[jt]s|astro\.config\.mjs|svelte\.config\.js)$/u;
|
|
13
|
+
const CONFIG_FILE_PATTERN = /(?:^|\/)(?:\.gitignore|\.env\.(?:example|sample|template|defaults)|\.dev\.vars\.example|tsconfig(?:\..*)?\.json|eslint\.config\.[cm]?[jt]s|\.eslintrc(?:\.json)?|\.prettierrc(?:\.json)?|prettier\.config\.[cm]?[jt]s|vite\.config\.[cm]?[jt]s|vitest\.config\.[cm]?[jt]s|tailwind\.config\.[cm]?[jt]s|jest\.config\.[cm]?[jt]s|playwright\.config\.[cm]?[jt]s|astro\.config\.mjs|svelte\.config\.js|wrangler\.(?:toml|jsonc?)|vercel\.json|netlify\.toml|Dockerfile|docker-compose\.ya?ml|compose\.ya?ml)$/u;
|
|
14
14
|
export function isScriptPackSuggestionPhase(value) {
|
|
15
15
|
return ['before_change', 'during_change', 'after_change', 'review'].includes(value);
|
|
16
16
|
}
|
|
@@ -133,7 +133,7 @@ function surfacesForScript(script) {
|
|
|
133
133
|
addIf('skill', /skill|workflow/u);
|
|
134
134
|
addIf('generated', /generated|protected|vendor|cache|boundary/u);
|
|
135
135
|
addIf('config', /config|command/u);
|
|
136
|
-
addIf('package', /package|release/u);
|
|
136
|
+
addIf('package', /deploy|package|publish|release/u);
|
|
137
137
|
addIf('test', /test|suite|fixture|coverage|selection|timing|performance/u);
|
|
138
138
|
addIf('source', /code|source|symbol/u);
|
|
139
139
|
if (script.ref === 'repo/manifest-lock-drift') {
|
|
@@ -265,6 +265,9 @@ function createRunHint(script, analyzedPaths) {
|
|
|
265
265
|
if (script.ref === 'repo/approval-gate') {
|
|
266
266
|
return 'mf script-pack run repo/approval-gate check --action <action_type> --json';
|
|
267
267
|
}
|
|
268
|
+
if (script.ref === 'repo/deploy-surface') {
|
|
269
|
+
return 'mf script-pack run repo/deploy-surface inspect --json';
|
|
270
|
+
}
|
|
268
271
|
if (script.ref === 'repo/config-chain') {
|
|
269
272
|
const configPaths = analyzedPaths
|
|
270
273
|
.filter((entry) => entry.surfaces.some((surface) => CONFIG_CHAIN_SURFACES.has(surface)))
|
|
@@ -283,6 +286,12 @@ function createRunHint(script, analyzedPaths) {
|
|
|
283
286
|
.map((entry) => entry.path);
|
|
284
287
|
return createConcretePathHint('mf script-pack run repo/secret-risk-scan scan', secretRiskPaths, script.usage);
|
|
285
288
|
}
|
|
289
|
+
if (script.ref === 'repo/security-pattern-scan') {
|
|
290
|
+
const securityPatternPaths = analyzedPaths
|
|
291
|
+
.filter((entry) => entry.surfaces.some((surface) => surface === 'config' || surface === 'source' || surface === 'package' || surface === 'test'))
|
|
292
|
+
.map((entry) => entry.path);
|
|
293
|
+
return createConcretePathHint('mf script-pack run repo/security-pattern-scan scan', securityPatternPaths, script.usage);
|
|
294
|
+
}
|
|
286
295
|
if (script.ref === 'repo/related-files') {
|
|
287
296
|
const relatedPaths = analyzedPaths
|
|
288
297
|
.filter((entry) => entry.surfaces.some((surface) => surface === 'source' || surface === 'test'))
|
|
@@ -367,6 +376,24 @@ export function createScriptPackSuggestionReport(mustflowRoot, options) {
|
|
|
367
376
|
score += 2;
|
|
368
377
|
reasons.push('Prioritizes approval-gate checks for approval-sensitive workflow and release surfaces.');
|
|
369
378
|
}
|
|
379
|
+
if (script.ref === 'repo/deploy-surface' &&
|
|
380
|
+
(requestedSurfaces.has('package') || requestedSurfaces.has('config') || requestedSurfaces.has('docs'))) {
|
|
381
|
+
score += 2;
|
|
382
|
+
reasons.push('Prioritizes deploy-surface inspection for push, tag, release, docs, and package publication follow-up.');
|
|
383
|
+
}
|
|
384
|
+
if (script.ref === 'repo/security-pattern-scan' &&
|
|
385
|
+
(hasSourcePath ||
|
|
386
|
+
requestedSurfaces.has('config') ||
|
|
387
|
+
options.skills.some((skill) => [
|
|
388
|
+
'api-access-control-review',
|
|
389
|
+
'file-upload-security-review',
|
|
390
|
+
'security-flow-review',
|
|
391
|
+
'security-privacy-review',
|
|
392
|
+
'security-regression-tests',
|
|
393
|
+
].includes(skill)))) {
|
|
394
|
+
score += 2;
|
|
395
|
+
reasons.push('Prioritizes security-pattern scans for source, config, and security-review surfaces.');
|
|
396
|
+
}
|
|
370
397
|
if (score === 0) {
|
|
371
398
|
return null;
|
|
372
399
|
}
|
|
@@ -0,0 +1,518 @@
|
|
|
1
|
+
import { createHash } from 'node:crypto';
|
|
2
|
+
import { existsSync, lstatSync, readdirSync } from 'node:fs';
|
|
3
|
+
import path from 'node:path';
|
|
4
|
+
import { DEFAULT_IGNORED_DIRECTORIES, isIgnoredDirectoryPath } from './ignored-directories.js';
|
|
5
|
+
import { ensureInside, ensureInsideWithoutSymlinks, readUtf8FileInsideWithoutSymlinks } from './safe-filesystem.js';
|
|
6
|
+
export const SECURITY_PATTERN_SCAN_PACK_ID = 'repo';
|
|
7
|
+
export const SECURITY_PATTERN_SCAN_SCRIPT_ID = 'security-pattern-scan';
|
|
8
|
+
export const SECURITY_PATTERN_SCAN_SCRIPT_REF = `${SECURITY_PATTERN_SCAN_PACK_ID}/${SECURITY_PATTERN_SCAN_SCRIPT_ID}`;
|
|
9
|
+
const DEFAULT_MAX_FILE_BYTES = 256 * 1024;
|
|
10
|
+
const DEFAULT_MAX_FILES = 1000;
|
|
11
|
+
const DEFAULT_MAX_FINDINGS = 300;
|
|
12
|
+
const MAX_ISSUES = 50;
|
|
13
|
+
const SCAN_EXTENSIONS = [
|
|
14
|
+
'.c',
|
|
15
|
+
'.cc',
|
|
16
|
+
'.cpp',
|
|
17
|
+
'.cs',
|
|
18
|
+
'.cts',
|
|
19
|
+
'.go',
|
|
20
|
+
'.java',
|
|
21
|
+
'.js',
|
|
22
|
+
'.jsx',
|
|
23
|
+
'.kt',
|
|
24
|
+
'.md',
|
|
25
|
+
'.mdx',
|
|
26
|
+
'.mjs',
|
|
27
|
+
'.mts',
|
|
28
|
+
'.php',
|
|
29
|
+
'.py',
|
|
30
|
+
'.rb',
|
|
31
|
+
'.rs',
|
|
32
|
+
'.sh',
|
|
33
|
+
'.ts',
|
|
34
|
+
'.tsx',
|
|
35
|
+
'.yaml',
|
|
36
|
+
'.yml',
|
|
37
|
+
];
|
|
38
|
+
const IGNORED_DIRECTORIES = DEFAULT_IGNORED_DIRECTORIES;
|
|
39
|
+
const ERROR_CODES = new Set([
|
|
40
|
+
'security_pattern_path_outside_root',
|
|
41
|
+
'security_pattern_unreadable_path',
|
|
42
|
+
]);
|
|
43
|
+
function normalizeRelativePath(value) {
|
|
44
|
+
return value.replace(/\\/gu, '/').replace(/^\.\/+/u, '') || '.';
|
|
45
|
+
}
|
|
46
|
+
function sha256Tagged(value) {
|
|
47
|
+
return `sha256:${createHash('sha256').update(value).digest('hex')}`;
|
|
48
|
+
}
|
|
49
|
+
function fingerprint(value) {
|
|
50
|
+
return `sha256:${createHash('sha256').update(value).digest('hex').slice(0, 16)}`;
|
|
51
|
+
}
|
|
52
|
+
function pushIssue(issues, issue) {
|
|
53
|
+
if (issues.length < MAX_ISSUES) {
|
|
54
|
+
issues.push(issue);
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
function makeFinding(code, severity, pathValue, message, details = {}) {
|
|
58
|
+
return {
|
|
59
|
+
code,
|
|
60
|
+
severity,
|
|
61
|
+
path: pathValue,
|
|
62
|
+
message,
|
|
63
|
+
line: details.line,
|
|
64
|
+
detector: details.detector,
|
|
65
|
+
category: details.category,
|
|
66
|
+
review_focus: details.reviewFocus,
|
|
67
|
+
fingerprint: details.fingerprint,
|
|
68
|
+
json_pointer: null,
|
|
69
|
+
metric: null,
|
|
70
|
+
actual: null,
|
|
71
|
+
expected: null,
|
|
72
|
+
};
|
|
73
|
+
}
|
|
74
|
+
function positiveInteger(value, fallback) {
|
|
75
|
+
return typeof value === 'number' && Number.isInteger(value) && value > 0 ? value : fallback;
|
|
76
|
+
}
|
|
77
|
+
function isIgnoredDirectory(relativePath) {
|
|
78
|
+
return isIgnoredDirectoryPath(normalizeRelativePath(relativePath), IGNORED_DIRECTORIES);
|
|
79
|
+
}
|
|
80
|
+
function surfaceForPath(relativePath) {
|
|
81
|
+
const normalized = normalizeRelativePath(relativePath);
|
|
82
|
+
const extension = path.extname(normalized).toLowerCase();
|
|
83
|
+
if (!SCAN_EXTENSIONS.includes(extension)) {
|
|
84
|
+
return null;
|
|
85
|
+
}
|
|
86
|
+
if (normalized.startsWith('.github/workflows/') || ['.yml', '.yaml'].includes(extension)) {
|
|
87
|
+
return 'ci';
|
|
88
|
+
}
|
|
89
|
+
if (['.md', '.mdx'].includes(extension)) {
|
|
90
|
+
return 'docs';
|
|
91
|
+
}
|
|
92
|
+
if (['.json', '.toml', '.yaml', '.yml'].includes(extension) || normalized.startsWith('.mustflow/config/')) {
|
|
93
|
+
return 'config';
|
|
94
|
+
}
|
|
95
|
+
return 'code';
|
|
96
|
+
}
|
|
97
|
+
function normalizeTargetPath(projectRoot, targetPath) {
|
|
98
|
+
const absolutePath = path.resolve(process.cwd(), targetPath);
|
|
99
|
+
ensureInside(projectRoot, absolutePath);
|
|
100
|
+
return {
|
|
101
|
+
absolutePath,
|
|
102
|
+
relativePath: normalizeRelativePath(path.relative(projectRoot, absolutePath)),
|
|
103
|
+
};
|
|
104
|
+
}
|
|
105
|
+
function targetKind(absolutePath) {
|
|
106
|
+
if (!existsSync(absolutePath)) {
|
|
107
|
+
return { exists: false, kind: 'missing' };
|
|
108
|
+
}
|
|
109
|
+
const stats = lstatSync(absolutePath);
|
|
110
|
+
if (stats.isFile()) {
|
|
111
|
+
return { exists: true, kind: 'file' };
|
|
112
|
+
}
|
|
113
|
+
if (stats.isDirectory()) {
|
|
114
|
+
return { exists: true, kind: 'directory' };
|
|
115
|
+
}
|
|
116
|
+
return { exists: true, kind: 'other' };
|
|
117
|
+
}
|
|
118
|
+
function addCandidate(candidates, findings, issues, policy, candidate) {
|
|
119
|
+
if (candidates.has(candidate.relativePath)) {
|
|
120
|
+
return;
|
|
121
|
+
}
|
|
122
|
+
if (candidates.size >= policy.max_files) {
|
|
123
|
+
if (!findings.some((finding) => finding.code === 'security_pattern_max_files_exceeded')) {
|
|
124
|
+
const message = `Security-pattern scan matched more than ${policy.max_files} files; remaining files were skipped.`;
|
|
125
|
+
pushIssue(issues, message);
|
|
126
|
+
findings.push(makeFinding('security_pattern_max_files_exceeded', 'medium', candidate.relativePath, message));
|
|
127
|
+
}
|
|
128
|
+
return;
|
|
129
|
+
}
|
|
130
|
+
candidates.set(candidate.relativePath, candidate);
|
|
131
|
+
}
|
|
132
|
+
function collectFilesFromDirectory(projectRoot, absoluteDirectory, candidates, findings, issues, policy) {
|
|
133
|
+
const relativeDirectory = normalizeRelativePath(path.relative(projectRoot, absoluteDirectory));
|
|
134
|
+
if (isIgnoredDirectory(relativeDirectory)) {
|
|
135
|
+
return;
|
|
136
|
+
}
|
|
137
|
+
let entries;
|
|
138
|
+
try {
|
|
139
|
+
ensureInsideWithoutSymlinks(projectRoot, absoluteDirectory);
|
|
140
|
+
entries = readdirSync(absoluteDirectory, { withFileTypes: true });
|
|
141
|
+
}
|
|
142
|
+
catch (error) {
|
|
143
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
144
|
+
pushIssue(issues, `${relativeDirectory}: ${message}`);
|
|
145
|
+
findings.push(makeFinding('security_pattern_unreadable_path', 'high', relativeDirectory, message));
|
|
146
|
+
return;
|
|
147
|
+
}
|
|
148
|
+
for (const entry of entries) {
|
|
149
|
+
const absoluteEntry = path.join(absoluteDirectory, entry.name);
|
|
150
|
+
const relativeEntry = normalizeRelativePath(path.relative(projectRoot, absoluteEntry));
|
|
151
|
+
if (entry.isDirectory()) {
|
|
152
|
+
collectFilesFromDirectory(projectRoot, absoluteEntry, candidates, findings, issues, policy);
|
|
153
|
+
continue;
|
|
154
|
+
}
|
|
155
|
+
if (!entry.isFile()) {
|
|
156
|
+
continue;
|
|
157
|
+
}
|
|
158
|
+
const surface = surfaceForPath(relativeEntry);
|
|
159
|
+
if (surface) {
|
|
160
|
+
addCandidate(candidates, findings, issues, policy, { absolutePath: absoluteEntry, relativePath: relativeEntry, surface });
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
function lineNumberAtIndex(text, index) {
|
|
165
|
+
let line = 1;
|
|
166
|
+
let offset = 0;
|
|
167
|
+
while (offset < index) {
|
|
168
|
+
if (text.charCodeAt(offset) === 10) {
|
|
169
|
+
line += 1;
|
|
170
|
+
}
|
|
171
|
+
offset += 1;
|
|
172
|
+
}
|
|
173
|
+
return line;
|
|
174
|
+
}
|
|
175
|
+
function addBoundedFinding(findings, issues, policy, finding) {
|
|
176
|
+
if (findings.length >= policy.max_findings) {
|
|
177
|
+
if (!findings.some((entry) => entry.code === 'security_pattern_max_findings_exceeded')) {
|
|
178
|
+
const message = `Security-pattern scan found more than ${policy.max_findings} findings; remaining findings were skipped.`;
|
|
179
|
+
pushIssue(issues, message);
|
|
180
|
+
findings.push(makeFinding('security_pattern_max_findings_exceeded', 'medium', finding.path, message));
|
|
181
|
+
}
|
|
182
|
+
return;
|
|
183
|
+
}
|
|
184
|
+
findings.push(finding);
|
|
185
|
+
}
|
|
186
|
+
const ALWAYS = () => true;
|
|
187
|
+
const CODE_ONLY = (candidate) => candidate.surface === 'code';
|
|
188
|
+
const CODE_OR_CI = (candidate) => candidate.surface === 'code' || candidate.surface === 'ci';
|
|
189
|
+
const RULES = [
|
|
190
|
+
{
|
|
191
|
+
code: 'security_pattern_fs_call_non_literal_path',
|
|
192
|
+
detector: 'fs_call_non_literal_path',
|
|
193
|
+
category: 'filesystem',
|
|
194
|
+
severity: 'medium',
|
|
195
|
+
message: 'Filesystem call receives a non-literal first argument.',
|
|
196
|
+
reviewFocus: 'Prove path normalization and allowed-root containment before trusting this file operation.',
|
|
197
|
+
pattern: /\b(?:fs\.)?(?:readFile|readFileSync|writeFile|writeFileSync|appendFile|createReadStream|createWriteStream|unlink|rm|rename|copyFile|mkdir|readdir|stat|lstat)\s*\(\s*(?!["'`])/gu,
|
|
198
|
+
appliesTo: CODE_ONLY,
|
|
199
|
+
},
|
|
200
|
+
{
|
|
201
|
+
code: 'security_pattern_path_join_user_input',
|
|
202
|
+
detector: 'path_join_user_input',
|
|
203
|
+
category: 'filesystem',
|
|
204
|
+
severity: 'high',
|
|
205
|
+
message: 'Path composition appears to use request-controlled input.',
|
|
206
|
+
reviewFocus: 'Trace the composed path to the file sink and prove real-path containment after decoding and symlink checks.',
|
|
207
|
+
pattern: /\bpath\.(?:join|resolve)\s*\([^;\n]*(?:req\.|request\.|ctx\.|params|body|query)/gu,
|
|
208
|
+
appliesTo: CODE_ONLY,
|
|
209
|
+
},
|
|
210
|
+
{
|
|
211
|
+
code: 'security_pattern_dynamic_regex',
|
|
212
|
+
detector: 'dynamic_regex',
|
|
213
|
+
category: 'injection',
|
|
214
|
+
severity: 'medium',
|
|
215
|
+
message: 'RegExp constructor receives a dynamic pattern.',
|
|
216
|
+
reviewFocus: 'Check whether attacker-controlled text can choose the pattern; prefer literals, escaping, length limits, or a safe regex engine.',
|
|
217
|
+
pattern: /\b(?:new\s+RegExp|RegExp)\s*\(\s*(?!["'`][^"'`\r\n]*["'`]\s*[,)]).+/gu,
|
|
218
|
+
appliesTo: CODE_ONLY,
|
|
219
|
+
},
|
|
220
|
+
{
|
|
221
|
+
code: 'security_pattern_shell_true',
|
|
222
|
+
detector: 'shell_true',
|
|
223
|
+
category: 'command',
|
|
224
|
+
severity: 'high',
|
|
225
|
+
message: 'Process spawn options enable shell execution.',
|
|
226
|
+
reviewFocus: 'Use argv arrays and a static executable map; prove user-controlled strings cannot reach shell syntax.',
|
|
227
|
+
pattern: /\bshell\s*:\s*true\b/gu,
|
|
228
|
+
appliesTo: CODE_OR_CI,
|
|
229
|
+
},
|
|
230
|
+
{
|
|
231
|
+
code: 'security_pattern_eval_execution',
|
|
232
|
+
detector: 'eval_execution',
|
|
233
|
+
category: 'injection',
|
|
234
|
+
severity: 'critical',
|
|
235
|
+
message: 'Dynamic code execution primitive found.',
|
|
236
|
+
reviewFocus: 'Replace eval/new Function with a bounded parser, static operation map, or sandboxed interpreter with explicit policy.',
|
|
237
|
+
pattern: /\b(?:eval\s*\(|new\s+Function\s*\(|Function\s*\()/gu,
|
|
238
|
+
appliesTo: CODE_ONLY,
|
|
239
|
+
},
|
|
240
|
+
{
|
|
241
|
+
code: 'security_pattern_sql_template_interpolation',
|
|
242
|
+
detector: 'sql_template_interpolation',
|
|
243
|
+
category: 'injection',
|
|
244
|
+
severity: 'high',
|
|
245
|
+
message: 'SQL-looking template string contains interpolation.',
|
|
246
|
+
reviewFocus: 'Use parameterized values and allowlisted identifiers; check ORDER BY, table, column, and raw ORM fragments separately.',
|
|
247
|
+
pattern: /`[^`\r\n]*(?:SELECT|UPDATE|DELETE|INSERT|WHERE|ORDER BY)[^`\r\n]*\$\{[^`\r\n]*`/giu,
|
|
248
|
+
appliesTo: CODE_ONLY,
|
|
249
|
+
},
|
|
250
|
+
{
|
|
251
|
+
code: 'security_pattern_mass_assignment',
|
|
252
|
+
detector: 'mass_assignment',
|
|
253
|
+
category: 'access_control',
|
|
254
|
+
severity: 'high',
|
|
255
|
+
message: 'Request body appears to be bound directly into an entity or persistence call.',
|
|
256
|
+
reviewFocus: 'Replace raw body binding with a write DTO allowlist and server-derived privileged fields.',
|
|
257
|
+
pattern: /\b(?:Object\.assign\s*\([^,\n]+,\s*(?:req|request|ctx)?\.?body\b|(?:create|update|updateMany|insert|save)\s*\(\s*(?:req|request|ctx)?\.?body\b)/gu,
|
|
258
|
+
appliesTo: CODE_ONLY,
|
|
259
|
+
},
|
|
260
|
+
{
|
|
261
|
+
code: 'security_pattern_client_controlled_authority',
|
|
262
|
+
detector: 'client_controlled_authority',
|
|
263
|
+
category: 'access_control',
|
|
264
|
+
severity: 'high',
|
|
265
|
+
message: 'Authority-bearing field appears to come from request-controlled input.',
|
|
266
|
+
reviewFocus: 'Derive user, tenant, role, price, plan, owner, and entitlement from trusted server state, not request fields.',
|
|
267
|
+
pattern: /\b(?:req|request|ctx)\.(?:body|query|headers|params)\.?(?:\[['"])?(?:userId|accountId|tenantId|orgId|workspaceId|ownerId|role|isAdmin|permissions|scope|plan|price|status|entitlement)(?:['"]\])?/giu,
|
|
268
|
+
appliesTo: CODE_ONLY,
|
|
269
|
+
},
|
|
270
|
+
{
|
|
271
|
+
code: 'security_pattern_insecure_cookie_options',
|
|
272
|
+
detector: 'insecure_cookie_options',
|
|
273
|
+
category: 'token_session',
|
|
274
|
+
severity: 'medium',
|
|
275
|
+
message: 'Cookie-setting call does not show secure session-cookie flags on the same line.',
|
|
276
|
+
reviewFocus: 'For authority-bearing cookies, verify HttpOnly, Secure, SameSite, path, lifetime, rotation, logout, and CSRF posture.',
|
|
277
|
+
pattern: /\b(?:res|response|ctx)\.cookie\s*\([^\r\n]*/gu,
|
|
278
|
+
appliesTo: CODE_ONLY,
|
|
279
|
+
linePredicate: (line) => !/httpOnly|secure|sameSite/iu.test(line),
|
|
280
|
+
},
|
|
281
|
+
{
|
|
282
|
+
code: 'security_pattern_cors_origin_reflection_with_credentials',
|
|
283
|
+
detector: 'cors_origin_reflection_with_credentials',
|
|
284
|
+
category: 'browser',
|
|
285
|
+
severity: 'high',
|
|
286
|
+
message: 'CORS origin reflection appears in a file that also enables credentials.',
|
|
287
|
+
reviewFocus: 'Replace reflected origins with an allowlist and emit Vary: Origin when credentials are allowed.',
|
|
288
|
+
pattern: /Access-Control-Allow-Origin[^;\n]*(?:req|request)\.headers\.origin|(?:req|request)\.headers\.origin[^;\n]*Access-Control-Allow-Origin/giu,
|
|
289
|
+
appliesTo: (_candidate, text) => /Access-Control-Allow-Credentials[^;\n]*true/iu.test(text),
|
|
290
|
+
},
|
|
291
|
+
{
|
|
292
|
+
code: 'security_pattern_postmessage_wildcard_target',
|
|
293
|
+
detector: 'postmessage_wildcard_target',
|
|
294
|
+
category: 'browser',
|
|
295
|
+
severity: 'high',
|
|
296
|
+
message: 'postMessage uses a wildcard target origin.',
|
|
297
|
+
reviewFocus: 'Send messages only to a fixed trusted origin and avoid sending tokens or secrets through cross-window messages.',
|
|
298
|
+
pattern: /\.postMessage\s*\([^;\n]*,\s*["']\*["']/gu,
|
|
299
|
+
appliesTo: CODE_ONLY,
|
|
300
|
+
},
|
|
301
|
+
{
|
|
302
|
+
code: 'security_pattern_postmessage_missing_origin_check',
|
|
303
|
+
detector: 'postmessage_missing_origin_check',
|
|
304
|
+
category: 'browser',
|
|
305
|
+
severity: 'medium',
|
|
306
|
+
message: 'Message event listener has no visible event.origin check in the file.',
|
|
307
|
+
reviewFocus: 'Validate event.origin, event.source, message type, and payload schema before acting on cross-window messages.',
|
|
308
|
+
pattern: /addEventListener\s*\(\s*["']message["']/gu,
|
|
309
|
+
appliesTo: (_candidate, text) => !/\bevent\.origin\b|\borigin\s*!==|\borigin\s*===/u.test(text),
|
|
310
|
+
},
|
|
311
|
+
{
|
|
312
|
+
code: 'security_pattern_local_storage_token',
|
|
313
|
+
detector: 'local_storage_token',
|
|
314
|
+
category: 'token_session',
|
|
315
|
+
severity: 'high',
|
|
316
|
+
message: 'Browser localStorage appears to store a token or session-like value.',
|
|
317
|
+
reviewFocus: 'Avoid durable browser-readable authority where possible; review XSS blast radius, token lifetime, rotation, and revocation.',
|
|
318
|
+
pattern: /\blocalStorage\.setItem\s*\([^;\n]*(?:token|session|jwt|auth|refresh|api[_-]?key)/giu,
|
|
319
|
+
appliesTo: CODE_ONLY,
|
|
320
|
+
},
|
|
321
|
+
{
|
|
322
|
+
code: 'security_pattern_server_fetch_user_url',
|
|
323
|
+
detector: 'server_fetch_user_url',
|
|
324
|
+
category: 'injection',
|
|
325
|
+
severity: 'high',
|
|
326
|
+
message: 'Server-side HTTP call appears to use a request-controlled URL.',
|
|
327
|
+
reviewFocus: 'Treat this as SSRF until scheme, host, redirects, DNS resolution, private networks, timeout, and size limits are proven.',
|
|
328
|
+
pattern: /\b(?:fetch|axios\.(?:get|post|put|patch)|request|got)\s*\([^;\n]*(?:req|request|ctx)\.(?:query|body|params)[^;\n]*(?:url|uri|href)/giu,
|
|
329
|
+
appliesTo: CODE_ONLY,
|
|
330
|
+
},
|
|
331
|
+
{
|
|
332
|
+
code: 'security_pattern_tls_verification_disabled',
|
|
333
|
+
detector: 'tls_verification_disabled',
|
|
334
|
+
category: 'crypto_transport',
|
|
335
|
+
severity: 'critical',
|
|
336
|
+
message: 'TLS certificate verification appears to be disabled.',
|
|
337
|
+
reviewFocus: 'Remove certificate-verification bypasses and use test-only injection or local trust roots when needed.',
|
|
338
|
+
pattern: /\b(?:rejectUnauthorized\s*:\s*false|InsecureSkipVerify\s*:\s*true|verify\s*=\s*False|check_hostname\s*=\s*False)/gu,
|
|
339
|
+
appliesTo: CODE_OR_CI,
|
|
340
|
+
},
|
|
341
|
+
{
|
|
342
|
+
code: 'security_pattern_unsafe_yaml_load',
|
|
343
|
+
detector: 'unsafe_yaml_load',
|
|
344
|
+
category: 'parser',
|
|
345
|
+
severity: 'high',
|
|
346
|
+
message: 'YAML parsing appears to use an unsafe loader.',
|
|
347
|
+
reviewFocus: 'Use safe_load or a schema-backed parser and validate the resulting data shape before use.',
|
|
348
|
+
pattern: /\byaml\.load\s*\([^;\n]*(?:Loader\s*=\s*yaml\.(?:Loader|FullLoader|UnsafeLoader)|FullLoader|UnsafeLoader)/gu,
|
|
349
|
+
appliesTo: CODE_ONLY,
|
|
350
|
+
},
|
|
351
|
+
{
|
|
352
|
+
code: 'security_pattern_native_deserialization',
|
|
353
|
+
detector: 'native_deserialization',
|
|
354
|
+
category: 'parser',
|
|
355
|
+
severity: 'critical',
|
|
356
|
+
message: 'Native object deserialization primitive found.',
|
|
357
|
+
reviewFocus: 'Do not deserialize untrusted bytes into executable or language-native objects; use JSON plus schema validation where possible.',
|
|
358
|
+
pattern: /\b(?:pickle\.loads|pickle\.load|marshal\.loads|ObjectInputStream|BinaryFormatter|yaml\.unsafe_load|bincode::deserialize)/gu,
|
|
359
|
+
appliesTo: CODE_ONLY,
|
|
360
|
+
},
|
|
361
|
+
{
|
|
362
|
+
code: 'security_pattern_raw_sensitive_request_logging',
|
|
363
|
+
detector: 'raw_sensitive_request_logging',
|
|
364
|
+
category: 'logging',
|
|
365
|
+
severity: 'high',
|
|
366
|
+
message: 'Logger call appears to include raw request headers, body, cookies, or query.',
|
|
367
|
+
reviewFocus: 'Redact Authorization, Cookie, token, OTP, password, secret, and reset-link fields before logging request metadata.',
|
|
368
|
+
pattern: /\b(?:logger|log|console)\.(?:debug|info|warn|error|log)\s*\([^;\n]*(?:req|request)\.(?:headers|body|cookies|query)/gu,
|
|
369
|
+
appliesTo: CODE_ONLY,
|
|
370
|
+
},
|
|
371
|
+
];
|
|
372
|
+
function scanCandidate(projectRoot, candidate, policy, findings, issues) {
|
|
373
|
+
let text;
|
|
374
|
+
try {
|
|
375
|
+
text = readUtf8FileInsideWithoutSymlinks(projectRoot, candidate.absolutePath, { maxBytes: policy.max_file_bytes });
|
|
376
|
+
}
|
|
377
|
+
catch (error) {
|
|
378
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
379
|
+
const code = message.includes('exceeds maximum size')
|
|
380
|
+
? 'security_pattern_file_too_large'
|
|
381
|
+
: 'security_pattern_unreadable_path';
|
|
382
|
+
const severity = code === 'security_pattern_file_too_large' ? 'medium' : 'high';
|
|
383
|
+
pushIssue(issues, `${candidate.relativePath}: ${message}`);
|
|
384
|
+
findings.push(makeFinding(code, severity, candidate.relativePath, message));
|
|
385
|
+
return;
|
|
386
|
+
}
|
|
387
|
+
for (const rule of RULES) {
|
|
388
|
+
if (!rule.appliesTo(candidate, text)) {
|
|
389
|
+
continue;
|
|
390
|
+
}
|
|
391
|
+
for (const match of text.matchAll(rule.pattern)) {
|
|
392
|
+
const line = lineNumberAtIndex(text, match.index ?? 0);
|
|
393
|
+
const matchedLine = text.split(/\r\n|\n|\r/u)[line - 1] ?? '';
|
|
394
|
+
if (rule.linePredicate && !rule.linePredicate(matchedLine, text)) {
|
|
395
|
+
continue;
|
|
396
|
+
}
|
|
397
|
+
addBoundedFinding(findings, issues, policy, makeFinding(rule.code, rule.severity, candidate.relativePath, rule.message, {
|
|
398
|
+
line,
|
|
399
|
+
detector: rule.detector,
|
|
400
|
+
category: rule.category,
|
|
401
|
+
reviewFocus: rule.reviewFocus,
|
|
402
|
+
fingerprint: fingerprint(`${rule.detector}:${match[0]}`),
|
|
403
|
+
}));
|
|
404
|
+
}
|
|
405
|
+
}
|
|
406
|
+
}
|
|
407
|
+
function securityPatternStatus(findings) {
|
|
408
|
+
if (findings.some((finding) => ERROR_CODES.has(finding.code))) {
|
|
409
|
+
return 'error';
|
|
410
|
+
}
|
|
411
|
+
if (findings.some((finding) => ['medium', 'high', 'critical'].includes(finding.severity))) {
|
|
412
|
+
return 'failed';
|
|
413
|
+
}
|
|
414
|
+
return 'passed';
|
|
415
|
+
}
|
|
416
|
+
function summarizeSecurityPatterns(targets, fileCount, findings) {
|
|
417
|
+
const categories = new Set(findings.map((finding) => finding.category).filter((category) => Boolean(category)));
|
|
418
|
+
return {
|
|
419
|
+
target_count: targets.length,
|
|
420
|
+
file_count: fileCount,
|
|
421
|
+
finding_count: findings.length,
|
|
422
|
+
high_or_critical_count: findings.filter((finding) => ['high', 'critical'].includes(finding.severity)).length,
|
|
423
|
+
category_count: categories.size,
|
|
424
|
+
};
|
|
425
|
+
}
|
|
426
|
+
function createInputHash(policy, targets, findings, issues) {
|
|
427
|
+
return sha256Tagged(JSON.stringify({
|
|
428
|
+
policy,
|
|
429
|
+
targets,
|
|
430
|
+
findings: findings.map((finding) => ({
|
|
431
|
+
code: finding.code,
|
|
432
|
+
path: finding.path,
|
|
433
|
+
line: finding.line,
|
|
434
|
+
detector: finding.detector,
|
|
435
|
+
category: finding.category,
|
|
436
|
+
fingerprint: finding.fingerprint,
|
|
437
|
+
})),
|
|
438
|
+
issues,
|
|
439
|
+
}));
|
|
440
|
+
}
|
|
441
|
+
export function inspectSecurityPatternScan(projectRoot, options = {}) {
|
|
442
|
+
const root = path.resolve(projectRoot);
|
|
443
|
+
const policy = {
|
|
444
|
+
max_file_bytes: positiveInteger(options.maxFileBytes, DEFAULT_MAX_FILE_BYTES),
|
|
445
|
+
max_files: positiveInteger(options.maxFiles, DEFAULT_MAX_FILES),
|
|
446
|
+
max_findings: positiveInteger(options.maxFindings, DEFAULT_MAX_FINDINGS),
|
|
447
|
+
extensions: [...SCAN_EXTENSIONS],
|
|
448
|
+
ignored_directories: [...IGNORED_DIRECTORIES],
|
|
449
|
+
evidence_mode: 'metadata_only',
|
|
450
|
+
};
|
|
451
|
+
const targetInputs = options.paths && options.paths.length > 0 ? options.paths : ['.'];
|
|
452
|
+
const targets = [];
|
|
453
|
+
const candidates = new Map();
|
|
454
|
+
const findings = [];
|
|
455
|
+
const issues = [];
|
|
456
|
+
for (const targetPath of targetInputs) {
|
|
457
|
+
let absolutePath;
|
|
458
|
+
let relativePath;
|
|
459
|
+
try {
|
|
460
|
+
const normalized = normalizeTargetPath(root, targetPath);
|
|
461
|
+
absolutePath = normalized.absolutePath;
|
|
462
|
+
relativePath = normalized.relativePath;
|
|
463
|
+
ensureInsideWithoutSymlinks(root, absolutePath, { allowMissingLeaf: true });
|
|
464
|
+
}
|
|
465
|
+
catch (error) {
|
|
466
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
467
|
+
pushIssue(issues, message);
|
|
468
|
+
targets.push({ input: targetPath, path: targetPath, exists: null, kind: 'unknown' });
|
|
469
|
+
findings.push(makeFinding('security_pattern_path_outside_root', 'high', targetPath, message));
|
|
470
|
+
continue;
|
|
471
|
+
}
|
|
472
|
+
let existence;
|
|
473
|
+
try {
|
|
474
|
+
existence = targetKind(absolutePath);
|
|
475
|
+
}
|
|
476
|
+
catch (error) {
|
|
477
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
478
|
+
pushIssue(issues, `${relativePath}: ${message}`);
|
|
479
|
+
targets.push({ input: targetPath, path: relativePath, exists: null, kind: 'unknown' });
|
|
480
|
+
findings.push(makeFinding('security_pattern_unreadable_path', 'high', relativePath, message));
|
|
481
|
+
continue;
|
|
482
|
+
}
|
|
483
|
+
targets.push({ input: targetPath, path: relativePath, exists: existence.exists, kind: existence.kind });
|
|
484
|
+
if (existence.kind === 'file') {
|
|
485
|
+
const surface = surfaceForPath(relativePath);
|
|
486
|
+
if (surface) {
|
|
487
|
+
addCandidate(candidates, findings, issues, policy, { absolutePath, relativePath, surface });
|
|
488
|
+
}
|
|
489
|
+
}
|
|
490
|
+
else if (existence.kind === 'directory') {
|
|
491
|
+
collectFilesFromDirectory(root, absolutePath, candidates, findings, issues, policy);
|
|
492
|
+
}
|
|
493
|
+
}
|
|
494
|
+
for (const candidate of candidates.values()) {
|
|
495
|
+
scanCandidate(root, candidate, policy, findings, issues);
|
|
496
|
+
}
|
|
497
|
+
const status = securityPatternStatus(findings);
|
|
498
|
+
const truncated = findings.some((finding) => ['security_pattern_max_files_exceeded', 'security_pattern_max_findings_exceeded'].includes(finding.code));
|
|
499
|
+
const summary = summarizeSecurityPatterns(targets, candidates.size, findings);
|
|
500
|
+
return {
|
|
501
|
+
schema_version: '1',
|
|
502
|
+
command: 'script-pack',
|
|
503
|
+
pack_id: SECURITY_PATTERN_SCAN_PACK_ID,
|
|
504
|
+
script_id: SECURITY_PATTERN_SCAN_SCRIPT_ID,
|
|
505
|
+
script_ref: SECURITY_PATTERN_SCAN_SCRIPT_REF,
|
|
506
|
+
action: 'scan',
|
|
507
|
+
status,
|
|
508
|
+
ok: status === 'passed',
|
|
509
|
+
mustflow_root: root,
|
|
510
|
+
policy,
|
|
511
|
+
input_hash: createInputHash(policy, targets, findings, issues),
|
|
512
|
+
targets,
|
|
513
|
+
summary,
|
|
514
|
+
truncated,
|
|
515
|
+
findings,
|
|
516
|
+
issues,
|
|
517
|
+
};
|
|
518
|
+
}
|
package/package.json
CHANGED
package/schemas/README.md
CHANGED
|
@@ -124,6 +124,10 @@ Current schemas:
|
|
|
124
124
|
`mf script-pack run repo/secret-risk-scan scan [path...] --json`, containing plausible
|
|
125
125
|
hardcoded-secret findings with detector names, paths, line numbers, and redacted fingerprints
|
|
126
126
|
without printing secret values
|
|
127
|
+
- `security-pattern-scan-report.schema.json`: output of
|
|
128
|
+
`mf script-pack run repo/security-pattern-scan scan [path...] --json`, containing high-signal
|
|
129
|
+
security code-pattern leads with detector names, categories, paths, line numbers, review focus,
|
|
130
|
+
and redacted fingerprints without printing matched source lines or secret values
|
|
127
131
|
- `text-budget-report.schema.json`: output of
|
|
128
132
|
`mf script-pack run core/text-budget check <path...> --json`, containing
|
|
129
133
|
exact text-budget metrics, input content hashes, policy metadata, findings, and JSON Pointer field
|
|
@@ -152,6 +156,9 @@ Current schemas:
|
|
|
152
156
|
- `repo-approval-gate-report.schema.json`: output of
|
|
153
157
|
`mf script-pack run repo/approval-gate check --action <type> --json`, containing approval policy
|
|
154
158
|
decisions, required-action findings, and unreadable policy issues
|
|
159
|
+
- `repo-deploy-surface-report.schema.json`: output of
|
|
160
|
+
`mf script-pack run repo/deploy-surface inspect --json`, containing detected local deploy and
|
|
161
|
+
release surfaces, trigger evidence, required verification, and manual gates
|
|
155
162
|
- `related-files-report.schema.json`: output of
|
|
156
163
|
`mf script-pack run repo/related-files map <path...> --json`, containing conservative related-file
|
|
157
164
|
candidates from direct imports, importers, same-basename siblings, parent configuration files, and
|