mustflow 2.103.3 → 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.
Files changed (50) hide show
  1. package/dist/cli/commands/run.js +11 -0
  2. package/dist/cli/commands/script-pack.js +2 -0
  3. package/dist/cli/i18n/en.js +35 -0
  4. package/dist/cli/i18n/es.js +35 -0
  5. package/dist/cli/i18n/fr.js +35 -0
  6. package/dist/cli/i18n/hi.js +35 -0
  7. package/dist/cli/i18n/ko.js +35 -0
  8. package/dist/cli/i18n/zh.js +35 -0
  9. package/dist/cli/lib/external-skill-import.js +78 -14
  10. package/dist/cli/lib/local-index/sql.js +9 -1
  11. package/dist/cli/lib/run-plan.js +37 -0
  12. package/dist/cli/lib/script-pack-registry.js +57 -0
  13. package/dist/cli/script-packs/repo-deploy-surface.js +98 -0
  14. package/dist/cli/script-packs/repo-security-pattern-scan.js +150 -0
  15. package/dist/core/change-impact.js +16 -0
  16. package/dist/core/code-outline.js +3 -13
  17. package/dist/core/command-env.js +26 -8
  18. package/dist/core/config-chain.js +3 -13
  19. package/dist/core/dependency-graph.js +3 -13
  20. package/dist/core/docs-link-integrity.js +23 -4
  21. package/dist/core/env-contract.js +3 -13
  22. package/dist/core/export-diff.js +3 -3
  23. package/dist/core/ignored-directories.js +40 -0
  24. package/dist/core/public-json-contracts.js +18 -0
  25. package/dist/core/reference-drift.js +4 -2
  26. package/dist/core/related-files.js +3 -13
  27. package/dist/core/repo-deploy-surface.js +428 -0
  28. package/dist/core/repo-merge-conflict-scan.js +3 -9
  29. package/dist/core/route-outline.js +3 -13
  30. package/dist/core/script-pack-suggestions.js +52 -14
  31. package/dist/core/secret-risk-scan.js +3 -13
  32. package/dist/core/security-pattern-scan.js +518 -0
  33. package/dist/core/skill-route-resolution.js +21 -1
  34. package/package.json +2 -2
  35. package/schemas/README.md +7 -0
  36. package/schemas/link-integrity-report.schema.json +1 -0
  37. package/schemas/reference-drift-report.schema.json +1 -0
  38. package/schemas/repo-deploy-surface-report.schema.json +190 -0
  39. package/schemas/security-pattern-scan-report.schema.json +196 -0
  40. package/templates/default/i18n.toml +20 -8
  41. package/templates/default/locales/en/.mustflow/skills/ai-generated-code-hardening/SKILL.md +30 -7
  42. package/templates/default/locales/en/.mustflow/skills/api-contract-change/SKILL.md +18 -9
  43. package/templates/default/locales/en/.mustflow/skills/api-request-performance-review/SKILL.md +12 -6
  44. package/templates/default/locales/en/.mustflow/skills/completion-evidence-gate/SKILL.md +20 -9
  45. package/templates/default/locales/en/.mustflow/skills/hot-path-performance-review/SKILL.md +20 -15
  46. package/templates/default/locales/en/.mustflow/skills/next-action-menu/SKILL.md +22 -7
  47. package/templates/default/locales/en/.mustflow/skills/quadratic-scan-review/SKILL.md +21 -19
  48. package/templates/default/locales/en/.mustflow/skills/react-code-change/SKILL.md +54 -8
  49. package/templates/default/locales/en/.mustflow/skills/vertical-slice-tdd/SKILL.md +22 -8
  50. 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
  }
@@ -80,26 +80,37 @@ export function classifyScriptPackPathSurface(relativePath) {
80
80
  return surfaces.length > 0 ? uniqueSortedSurfaces(surfaces) : ['unknown'];
81
81
  }
82
82
  function readChangedPaths(mustflowRoot, issues) {
83
- const result = spawnSync('git', ['status', '--short'], {
83
+ const result = spawnSync('git', ['status', '--porcelain=v1', '-z', '--untracked-files=all'], {
84
84
  cwd: mustflowRoot,
85
85
  encoding: 'utf8',
86
86
  stdio: ['ignore', 'pipe', 'pipe'],
87
87
  windowsHide: true,
88
+ maxBuffer: 16 * 1024 * 1024,
88
89
  });
89
- if (result.status !== 0) {
90
- const detail = result.stderr.trim() || result.stdout.trim() || `git status exited with ${result.status}`;
90
+ if (result.error || result.status !== 0) {
91
+ const detail = result.error?.message ?? (result.stderr.trim() || result.stdout.trim() || `git status exited with ${result.status}`);
91
92
  issues.push(`Could not read changed paths: ${detail}`);
92
93
  return [];
93
94
  }
94
- return result.stdout
95
- .split(/\r?\n/u)
96
- .map((line) => line.trimEnd())
97
- .filter((line) => line.length > 0)
98
- .map((line) => {
99
- const renamed = /\s->\s(?<target>.+)$/u.exec(line);
100
- return renamed?.groups?.target ?? line.slice(3).trim();
101
- })
102
- .filter((entry) => entry.length > 0);
95
+ const paths = [];
96
+ const records = result.stdout.split('\0');
97
+ let index = 0;
98
+ while (index < records.length) {
99
+ const record = records[index] ?? '';
100
+ index += 1;
101
+ if (record.length === 0) {
102
+ continue;
103
+ }
104
+ const status = record.slice(0, 2);
105
+ const relativePath = record.slice(3);
106
+ if (relativePath.length > 0) {
107
+ paths.push(relativePath);
108
+ }
109
+ if (status.includes('R') || status.includes('C')) {
110
+ index += 1;
111
+ }
112
+ }
113
+ return paths;
103
114
  }
104
115
  function surfacesForScript(script) {
105
116
  const surfaces = new Set();
@@ -122,7 +133,7 @@ function surfacesForScript(script) {
122
133
  addIf('skill', /skill|workflow/u);
123
134
  addIf('generated', /generated|protected|vendor|cache|boundary/u);
124
135
  addIf('config', /config|command/u);
125
- addIf('package', /package|release/u);
136
+ addIf('package', /deploy|package|publish|release/u);
126
137
  addIf('test', /test|suite|fixture|coverage|selection|timing|performance/u);
127
138
  addIf('source', /code|source|symbol/u);
128
139
  if (script.ref === 'repo/manifest-lock-drift') {
@@ -254,6 +265,9 @@ function createRunHint(script, analyzedPaths) {
254
265
  if (script.ref === 'repo/approval-gate') {
255
266
  return 'mf script-pack run repo/approval-gate check --action <action_type> --json';
256
267
  }
268
+ if (script.ref === 'repo/deploy-surface') {
269
+ return 'mf script-pack run repo/deploy-surface inspect --json';
270
+ }
257
271
  if (script.ref === 'repo/config-chain') {
258
272
  const configPaths = analyzedPaths
259
273
  .filter((entry) => entry.surfaces.some((surface) => CONFIG_CHAIN_SURFACES.has(surface)))
@@ -272,6 +286,12 @@ function createRunHint(script, analyzedPaths) {
272
286
  .map((entry) => entry.path);
273
287
  return createConcretePathHint('mf script-pack run repo/secret-risk-scan scan', secretRiskPaths, script.usage);
274
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
+ }
275
295
  if (script.ref === 'repo/related-files') {
276
296
  const relatedPaths = analyzedPaths
277
297
  .filter((entry) => entry.surfaces.some((surface) => surface === 'source' || surface === 'test'))
@@ -356,6 +376,24 @@ export function createScriptPackSuggestionReport(mustflowRoot, options) {
356
376
  score += 2;
357
377
  reasons.push('Prioritizes approval-gate checks for approval-sensitive workflow and release surfaces.');
358
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
+ }
359
397
  if (score === 0) {
360
398
  return null;
361
399
  }
@@ -1,6 +1,7 @@
1
1
  import { createHash } from 'node:crypto';
2
2
  import { existsSync, lstatSync, readdirSync } from 'node:fs';
3
3
  import path from 'node:path';
4
+ import { DEFAULT_IGNORED_DIRECTORIES, isIgnoredDirectoryPath } from './ignored-directories.js';
4
5
  import { ensureInside, ensureInsideWithoutSymlinks, readFileInsideWithoutSymlinks } from './safe-filesystem.js';
5
6
  export const SECRET_RISK_SCAN_PACK_ID = 'repo';
6
7
  export const SECRET_RISK_SCAN_SCRIPT_ID = 'secret-risk-scan';
@@ -35,17 +36,7 @@ const ENV_EXAMPLE_NAMES = [
35
36
  '.env.local.example',
36
37
  '.dev.vars.example',
37
38
  ];
38
- const IGNORED_DIRECTORIES = [
39
- '.git',
40
- '.mustflow/cache',
41
- '.mustflow/state',
42
- 'node_modules',
43
- 'dist',
44
- 'build',
45
- 'coverage',
46
- '.next',
47
- '.turbo',
48
- ];
39
+ const IGNORED_DIRECTORIES = DEFAULT_IGNORED_DIRECTORIES;
49
40
  const ERROR_CODES = new Set([
50
41
  'secret_risk_path_outside_root',
51
42
  'secret_risk_unreadable_path',
@@ -68,8 +59,7 @@ function makeFinding(code, severity, pathValue, message, details = {}) {
68
59
  return { code, severity, path: pathValue, message, ...details };
69
60
  }
70
61
  function isIgnoredDirectory(relativePath) {
71
- const normalized = normalizeRelativePath(relativePath);
72
- return IGNORED_DIRECTORIES.some((directory) => normalized === directory || normalized.startsWith(`${directory}/`));
62
+ return isIgnoredDirectoryPath(normalizeRelativePath(relativePath), IGNORED_DIRECTORIES);
73
63
  }
74
64
  function isSecretFile(relativePath) {
75
65
  return SECRET_FILE_NAMES.includes(path.basename(relativePath).toLowerCase());
@@ -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
+ }