99
1010class CommandInspector
1111{
12+ /**
13+ * Regex pattern for argument / option names whose default values should be
14+ * redacted from generated documentation to prevent accidental secret leakage.
15+ */
16+ private const SENSITIVE_KEYWORD_PATTERN = '/(password|passwd|secret|token|api[_\-]?key|auth[_\-]?key|private[_\-]?key|credential|apikey)/i ' ;
17+
18+ /**
19+ * Sentinel value written in place of a redacted default.
20+ */
21+ public const REDACTED_VALUE = '[ REDACTED ] ' ;
22+
23+ /**
24+ * Hardcoded fallback list of Symfony / Artisan global option names that should
25+ * always be stripped from per-command documentation. Used only when the command
26+ * has no bound Application (e.g. bare unit-test stubs).
27+ *
28+ * @var string[]
29+ */
30+ private const FALLBACK_INTERNAL_OPTIONS = [
31+ 'help ' , 'quiet ' , 'verbose ' , 'version ' ,
32+ 'ansi ' , 'no-ansi ' , 'no-interaction ' , 'env ' ,
33+ ];
34+
1235 /**
1336 * Inspect a single command and return structured metadata.
1437 *
@@ -27,7 +50,7 @@ public function inspect(SymfonyCommand $command): array
2750 'namespace ' => $ this ->extractNamespace ($ command ->getName ()),
2851 'group ' => null , // resolved later by GenerateDocsCommand
2952 'arguments ' => $ this ->inspectArguments ($ definition ->getArguments ()),
30- 'options ' => $ this ->inspectOptions ($ definition ->getOptions ()),
53+ 'options ' => $ this ->inspectOptions ($ definition ->getOptions (), $ command ),
3154 ];
3255 }
3356
@@ -47,7 +70,10 @@ private function inspectArguments(array $arguments): array
4770 'description ' => $ argument ->getDescription (),
4871 'required ' => $ argument ->isRequired (),
4972 'is_array ' => $ argument ->isArray (),
50- 'default ' => $ argument ->getDefault (),
73+ 'default ' => $ this ->redactSensitiveDefault (
74+ $ argument ->getName (),
75+ $ argument ->getDefault ()
76+ ),
5177 ];
5278 }
5379
@@ -57,13 +83,17 @@ private function inspectArguments(array $arguments): array
5783 /**
5884 * Inspect and normalise all options from a command definition.
5985 *
86+ * Global / internal options (--help, --quiet, etc.) are resolved dynamically
87+ * from the command's bound Application; a hardcoded fallback is used when no
88+ * application is available (e.g. in unit-test stubs).
89+ *
6090 * @param InputOption[] $options
6191 * @return array<int, array<string, mixed>>
6292 */
63- private function inspectOptions (array $ options ): array
93+ private function inspectOptions (array $ options, SymfonyCommand $ command ): array
6494 {
95+ $ internal = $ this ->resolveInternalOptionNames ($ command );
6596 $ results = [];
66- $ internal = ['help ' , 'quiet ' , 'verbose ' , 'version ' , 'ansi ' , 'no-ansi ' , 'no-interaction ' , 'env ' ];
6797
6898 foreach ($ options as $ option ) {
6999 if (in_array ($ option ->getName (), $ internal , true )) {
@@ -79,13 +109,54 @@ private function inspectOptions(array $options): array
79109 'accepts_value ' => ! $ option ->isValueOptional () && ! $ option ->isNegatable ()
80110 ? $ option ->isValueRequired ()
81111 : ! ($ option ->getDefault () === false && ! $ option ->acceptValue ()),
82- 'default ' => $ option ->getDefault (),
112+ 'default ' => $ this ->redactSensitiveDefault (
113+ $ option ->getName (),
114+ $ option ->getDefault ()
115+ ),
83116 ];
84117 }
85118
86119 return $ results ;
87120 }
88121
122+ /**
123+ * Resolve the names of global / internal options that should be stripped from
124+ * per-command documentation.
125+ *
126+ * When the command is bound to a Symfony Application the list is derived
127+ * directly from the application's input definition, which future-proofs the
128+ * package against new global flags added by Symfony or Laravel. When no
129+ * application is available the hardcoded fallback list is used.
130+ *
131+ * @return string[]
132+ */
133+ private function resolveInternalOptionNames (SymfonyCommand $ command ): array
134+ {
135+ if ($ app = $ command ->getApplication ()) {
136+ return array_keys ($ app ->getDefinition ()->getOptions ());
137+ }
138+
139+ return self ::FALLBACK_INTERNAL_OPTIONS ;
140+ }
141+
142+ /**
143+ * Redact the default value when the argument / option name matches a
144+ * pattern associated with sensitive credentials or secrets.
145+ */
146+ private function redactSensitiveDefault (string $ name , mixed $ default ): mixed
147+ {
148+ // Nothing to redact when there is no meaningful default.
149+ if ($ default === null || $ default === false || $ default === '' ) {
150+ return $ default ;
151+ }
152+
153+ if (preg_match (self ::SENSITIVE_KEYWORD_PATTERN , $ name )) {
154+ return self ::REDACTED_VALUE ;
155+ }
156+
157+ return $ default ;
158+ }
159+
89160 /**
90161 * Extract the namespace portion from a command name.
91162 * E.g. "make:model" → "make", "list" → null.
@@ -133,6 +204,10 @@ public function isApplicationCommand(SymfonyCommand $command, array $appCommandP
133204
134205 /**
135206 * Determine whether a command is from a vendor package.
207+ *
208+ * Uses absolute path-prefix matching against the project's real vendor
209+ * directory instead of a substring search, which would produce false
210+ * positives for projects whose root path happens to contain "/vendor/".
136211 */
137212 public function isVendorCommand (SymfonyCommand $ command ): bool
138213 {
@@ -142,6 +217,14 @@ public function isVendorCommand(SymfonyCommand $command): bool
142217 return false ;
143218 }
144219
145- return $ file && str_contains (str_replace ('\\' , '/ ' , $ file ), '/vendor/ ' );
220+ if (! $ file ) {
221+ return false ;
222+ }
223+
224+ // Normalise both paths to forward slashes for cross-platform safety.
225+ $ vendorPrefix = rtrim (str_replace ('\\' , '/ ' , base_path ('vendor ' )), '/ ' ).'/ ' ;
226+ $ normalizedFile = str_replace ('\\' , '/ ' , $ file );
227+
228+ return str_starts_with ($ normalizedFile , $ vendorPrefix );
146229 }
147230}
0 commit comments