Skip to content

Commit 26c0685

Browse files
dsymeCopilotCopilot
authored
🔒 Implement markdown security scanner for workflows (#15208)
* reject dodgy workflows * Update pkg/workflow/markdown_security_scanner.go Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> * Update pkg/workflow/markdown_security_scanner.go Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> * Update pkg/workflow/markdown_security_scanner.go Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> * Update pkg/workflow/compiler_orchestrator_engine.go Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> * Update pkg/workflow/markdown_security_scanner.go Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> * reject dodgy workflows * reject dodgy workflows * Initial plan (#15225) Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com> * add AddOptions * fix tests * fix code --------- Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> Co-authored-by: Copilot <198982749+Copilot@users.noreply.github.com>
1 parent 8aa7bd9 commit 26c0685

14 files changed

+1936
-159
lines changed

‎docs/src/content/docs/reference/markdown.md‎

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -98,6 +98,10 @@ Agentic markdown supports GitHub Actions expression substitutions and conditiona
9898
9999
This design enables rapid iteration on AI instructions while maintaining strict compilation requirements for security-sensitive frontmatter configuration. See [Editing Workflows](/gh-aw/guides/editing-workflows/) for complete guidance on when recompilation is needed versus when you can edit directly.
100100

101+
## Security Scanning
102+
103+
The markdown body of workflows (excluding frontmatter) is automatically scanned for malicious content when added via `gh aw add`, during trial mode, and at compile time for imported files. The scanner rejects workflows containing: Unicode abuse (zero-width characters, bidirectional overrides), hidden content (suspicious HTML comments, CSS-hidden elements), obfuscated links (data URIs, `javascript:` URLs, IP-based URLs, URL shorteners), dangerous HTML tags (`<script>`, `<iframe>`, `<object>`, `<form>`, event handlers), embedded executable content (SVG scripts, executable MIME data URIs), and social engineering patterns (prompt injection, base64-encoded commands, pipe-to-shell patterns). These checks cannot be overridden.
104+
101105
## Related Documentation
102106

103107
- [Editing Workflows](/gh-aw/guides/editing-workflows/) - When to recompile vs edit directly

‎pkg/cli/add_command.go‎

Lines changed: 136 additions & 87 deletions
Large diffs are not rendered by default.

‎pkg/cli/add_command_test.go‎

Lines changed: 8 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -109,7 +109,10 @@ func TestAddWorkflows(t *testing.T) {
109109

110110
for _, tt := range tests {
111111
t.Run(tt.name, func(t *testing.T) {
112-
_, err := AddWorkflows(tt.workflows, tt.number, false, "", "", false, "", false, false, false, "", false, "")
112+
opts := AddOptions{
113+
Number: tt.number,
114+
}
115+
_, err := AddWorkflows(tt.workflows, opts)
113116

114117
if tt.expectError {
115118
require.Error(t, err, "Expected error for test case: %s", tt.name)
@@ -171,22 +174,13 @@ func TestAddResolvedWorkflows(t *testing.T) {
171174
},
172175
}
173176

177+
opts := AddOptions{
178+
Number: tt.number,
179+
}
174180
_, err := AddResolvedWorkflows(
175181
[]string{"test/repo/test-workflow"},
176182
resolved,
177-
tt.number,
178-
false, // verbose
179-
false, // quiet
180-
"", // engineOverride
181-
"", // name
182-
false, // force
183-
"", // appendText
184-
false, // createPR
185-
false, // push
186-
false, // noGitattributes
187-
"", // workflowDir
188-
false, // noStopAfter
189-
"", // stopAfter
183+
opts,
190184
)
191185

192186
if tt.expectError {

‎pkg/cli/add_current_repo_test.go‎

Lines changed: 6 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -68,7 +68,8 @@ func TestAddWorkflowsFromCurrentRepository(t *testing.T) {
6868
// Clear cache before each test
6969
ClearCurrentRepoSlugCache()
7070

71-
_, err := AddWorkflows(tt.workflowSpecs, 1, false, "", "", false, "", false, false, false, "", false, "")
71+
opts := AddOptions{Number: 1}
72+
_, err := AddWorkflows(tt.workflowSpecs, opts)
7273

7374
if tt.expectError {
7475
if err == nil {
@@ -151,7 +152,8 @@ func TestAddWorkflowsFromCurrentRepositoryMultiple(t *testing.T) {
151152
// Clear cache before each test
152153
ClearCurrentRepoSlugCache()
153154

154-
_, err := AddWorkflows(tt.workflowSpecs, 1, false, "", "", false, "", false, false, false, "", false, "")
155+
opts := AddOptions{Number: 1}
156+
_, err := AddWorkflows(tt.workflowSpecs, opts)
155157

156158
if tt.expectError {
157159
if err == nil {
@@ -192,7 +194,8 @@ func TestAddWorkflowsFromCurrentRepositoryNotInGitRepo(t *testing.T) {
192194

193195
// When not in a git repo, the check should be skipped (can't determine current repo)
194196
// The function should proceed and fail for other reasons (e.g., workflow not found)
195-
_, err = AddWorkflows([]string{"some-owner/some-repo/workflow"}, 1, false, "", "", false, "", false, false, false, "", false, "")
197+
opts := AddOptions{Number: 1}
198+
_, err = AddWorkflows([]string{"some-owner/some-repo/workflow"}, opts)
196199

197200
// Should NOT get the "cannot add workflows from the current repository" error
198201
if err != nil && strings.Contains(err.Error(), "cannot add workflows from the current repository") {

‎pkg/cli/add_gitattributes_test.go‎

Lines changed: 6 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -84,7 +84,8 @@ This is a test workflow.`
8484
os.Remove(".gitattributes")
8585

8686
// Call addWorkflowsNormal with noGitattributes=false
87-
err := addWorkflowsNormal([]*WorkflowSpec{spec}, 1, false, false, "", "", false, "", false, false, false, "", false, "")
87+
opts := AddOptions{Number: 1}
88+
err := addWorkflowsNormal([]*WorkflowSpec{spec}, opts)
8889
if err != nil {
8990
// We expect this to fail because we don't have a full workflow setup,
9091
// but gitattributes should still be updated before the error
@@ -113,8 +114,9 @@ This is a test workflow.`
113114
// Remove any existing .gitattributes
114115
os.Remove(".gitattributes")
115116

117+
opts := AddOptions{Number: 1, NoGitattributes: true}
116118
// Call addWorkflowsNormal with noGitattributes=true
117-
err := addWorkflowsNormal([]*WorkflowSpec{spec}, 1, false, false, "", "", false, "", false, true, false, "", false, "")
119+
err := addWorkflowsNormal([]*WorkflowSpec{spec}, opts)
118120
if err != nil {
119121
// We expect this to fail because we don't have a full workflow setup
120122
t.Logf("Expected error during workflow addition: %v", err)
@@ -135,8 +137,9 @@ This is a test workflow.`
135137
t.Fatalf("Failed to create .gitattributes: %v", err)
136138
}
137139

140+
opts := AddOptions{Number: 1, NoGitattributes: true}
138141
// Call addWorkflowsNormal with noGitattributes=true
139-
err := addWorkflowsNormal([]*WorkflowSpec{spec}, 1, false, false, "", "", false, "", false, true, false, "", false, "")
142+
err := addWorkflowsNormal([]*WorkflowSpec{spec}, opts)
140143
if err != nil {
141144
// We expect this to fail because we don't have a full workflow setup
142145
t.Logf("Expected error during workflow addition: %v", err)

‎pkg/cli/add_interactive_git.go‎

Lines changed: 18 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -19,9 +19,25 @@ func (c *AddInteractiveConfig) applyChanges(ctx context.Context, workflowFiles,
1919

2020
// Add the workflow using existing implementation with --create-pull-request
2121
// Pass the resolved workflows to avoid re-fetching them
22-
// Pass quiet=true to suppress detailed output (already shown earlier in interactive mode)
22+
// Pass Quiet=true to suppress detailed output (already shown earlier in interactive mode)
2323
// This returns the result including PR number and HasWorkflowDispatch
24-
result, err := AddResolvedWorkflows(c.WorkflowSpecs, c.resolvedWorkflows, 1, c.Verbose, true, c.EngineOverride, "", false, "", true, false, c.NoGitattributes, c.WorkflowDir, c.NoStopAfter, c.StopAfter)
24+
opts := AddOptions{
25+
Number: 1,
26+
Verbose: c.Verbose,
27+
Quiet: true,
28+
EngineOverride: c.EngineOverride,
29+
Name: "",
30+
Force: false,
31+
AppendText: "",
32+
CreatePR: true,
33+
Push: false,
34+
NoGitattributes: c.NoGitattributes,
35+
WorkflowDir: c.WorkflowDir,
36+
NoStopAfter: c.NoStopAfter,
37+
StopAfter: c.StopAfter,
38+
DisableSecurityScanner: false,
39+
}
40+
result, err := AddResolvedWorkflows(c.WorkflowSpecs, c.resolvedWorkflows, opts)
2541
if err != nil {
2642
return fmt.Errorf("failed to add workflow: %w", err)
2743
}

‎pkg/cli/add_wildcard_test.go‎

Lines changed: 6 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -477,7 +477,8 @@ on: push
477477

478478
// Test 1: Non-wildcard duplicate should return error
479479
t.Run("non_wildcard_duplicate_returns_error", func(t *testing.T) {
480-
err := addWorkflowWithTracking(spec, 1, false, false, "", "", false, "", nil, false, "", false, "")
480+
opts := AddOptions{Number: 1}
481+
err := addWorkflowWithTracking(spec, nil, opts)
481482
if err == nil {
482483
t.Error("Expected error for non-wildcard duplicate, got nil")
483484
}
@@ -488,15 +489,17 @@ on: push
488489

489490
// Test 2: Wildcard duplicate should return nil (skip with warning)
490491
t.Run("wildcard_duplicate_returns_nil", func(t *testing.T) {
491-
err := addWorkflowWithTracking(spec, 1, false, false, "", "", false, "", nil, true, "", false, "")
492+
opts := AddOptions{Number: 1, FromWildcard: true}
493+
err := addWorkflowWithTracking(spec, nil, opts)
492494
if err != nil {
493495
t.Errorf("Expected nil for wildcard duplicate (should skip), got error: %v", err)
494496
}
495497
})
496498

497499
// Test 3: Wildcard duplicate with force flag should succeed
498500
t.Run("wildcard_duplicate_with_force_succeeds", func(t *testing.T) {
499-
err := addWorkflowWithTracking(spec, 1, false, false, "", "", true, "", nil, true, "", false, "")
501+
opts := AddOptions{Number: 1, Force: true, FromWildcard: true}
502+
err := addWorkflowWithTracking(spec, nil, opts)
500503
// This should succeed or return nil
501504
if err != nil && strings.Contains(err.Error(), "already exists") {
502505
t.Errorf("Expected success with force flag, got 'already exists' error: %v", err)

‎pkg/cli/add_workflow_pr.go‎

Lines changed: 19 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,7 @@ import (
1313
var addWorkflowPRLog = logger.New("cli:add_workflow_pr")
1414

1515
// addWorkflowsWithPR handles workflow addition with PR creation and returns the PR number and URL.
16-
func addWorkflowsWithPR(workflows []*WorkflowSpec, number int, verbose bool, quiet bool, engineOverride string, name string, force bool, appendText string, push bool, noGitattributes bool, fromWildcard bool, workflowDir string, noStopAfter bool, stopAfter string) (int, string, error) {
16+
func addWorkflowsWithPR(workflows []*WorkflowSpec, opts AddOptions) (int, string, error) {
1717
addWorkflowPRLog.Printf("Adding %d workflow(s) with PR creation", len(workflows))
1818

1919
// Get current branch for restoration later
@@ -31,7 +31,7 @@ func addWorkflowsWithPR(workflows []*WorkflowSpec, number int, verbose bool, qui
3131

3232
addWorkflowPRLog.Printf("Creating temporary branch: %s", branchName)
3333

34-
if err := createAndSwitchBranch(branchName, verbose); err != nil {
34+
if err := createAndSwitchBranch(branchName, opts.Verbose); err != nil {
3535
return 0, "", fmt.Errorf("failed to create branch %s: %w", branchName, err)
3636
}
3737

@@ -43,33 +43,36 @@ func addWorkflowsWithPR(workflows []*WorkflowSpec, number int, verbose bool, qui
4343

4444
// Ensure we switch back to original branch on exit
4545
defer func() {
46-
if switchErr := switchBranch(currentBranch, verbose); switchErr != nil && verbose {
46+
if switchErr := switchBranch(currentBranch, opts.Verbose); switchErr != nil && opts.Verbose {
4747
fmt.Fprintln(os.Stderr, console.FormatWarningMessage(fmt.Sprintf("Failed to switch back to branch %s: %v", currentBranch, switchErr)))
4848
}
4949
}()
5050

5151
// Add workflows using the normal function logic
5252
addWorkflowPRLog.Print("Adding workflows to repository")
53-
if err := addWorkflowsNormal(workflows, number, verbose, quiet, engineOverride, name, force, appendText, push, noGitattributes, fromWildcard, workflowDir, noStopAfter, stopAfter); err != nil {
53+
// Disable security scanner for PR creation to use workflow settings
54+
prOpts := opts
55+
prOpts.DisableSecurityScanner = false
56+
if err := addWorkflowsNormal(workflows, prOpts); err != nil {
5457
addWorkflowPRLog.Printf("Failed to add workflows: %v", err)
5558
// Rollback on error
56-
if rollbackErr := tracker.RollbackAllFiles(verbose); rollbackErr != nil && verbose {
59+
if rollbackErr := tracker.RollbackAllFiles(opts.Verbose); rollbackErr != nil && opts.Verbose {
5760
fmt.Fprintln(os.Stderr, console.FormatWarningMessage(fmt.Sprintf("Failed to rollback files: %v", rollbackErr)))
5861
}
5962
return 0, "", fmt.Errorf("failed to add workflows: %w", err)
6063
}
6164

6265
// Stage all files before creating PR
6366
addWorkflowPRLog.Print("Staging workflow files")
64-
if err := tracker.StageAllFiles(verbose); err != nil {
65-
if rollbackErr := tracker.RollbackAllFiles(verbose); rollbackErr != nil && verbose {
67+
if err := tracker.StageAllFiles(opts.Verbose); err != nil {
68+
if rollbackErr := tracker.RollbackAllFiles(opts.Verbose); rollbackErr != nil && opts.Verbose {
6669
fmt.Fprintln(os.Stderr, console.FormatWarningMessage(fmt.Sprintf("Failed to rollback files: %v", rollbackErr)))
6770
}
6871
return 0, "", fmt.Errorf("failed to stage workflow files: %w", err)
6972
}
7073

71-
// Update .gitattributes and stage it if modified
72-
if err := stageGitAttributesIfChanged(); err != nil && verbose {
74+
// Update .gitattributes and stage it if changed
75+
if err := stageGitAttributesIfChanged(); err != nil && opts.Verbose {
7376
fmt.Fprintln(os.Stderr, console.FormatWarningMessage(fmt.Sprintf("Failed to stage .gitattributes: %v", err)))
7477
}
7578

@@ -92,29 +95,29 @@ func addWorkflowsWithPR(workflows []*WorkflowSpec, number int, verbose bool, qui
9295
prBody = fmt.Sprintf("Add agentic workflows: %s", joinedNames)
9396
}
9497

95-
if err := commitChanges(commitMessage, verbose); err != nil {
96-
if rollbackErr := tracker.RollbackAllFiles(verbose); rollbackErr != nil && verbose {
98+
if err := commitChanges(commitMessage, opts.Verbose); err != nil {
99+
if rollbackErr := tracker.RollbackAllFiles(opts.Verbose); rollbackErr != nil && opts.Verbose {
97100
fmt.Fprintln(os.Stderr, console.FormatWarningMessage(fmt.Sprintf("Failed to rollback files: %v", rollbackErr)))
98101
}
99102
return 0, "", fmt.Errorf("failed to commit files: %w", err)
100103
}
101104

102105
// Push branch
103106
addWorkflowPRLog.Printf("Pushing branch %s to remote", branchName)
104-
if err := pushBranch(branchName, verbose); err != nil {
107+
if err := pushBranch(branchName, opts.Verbose); err != nil {
105108
addWorkflowPRLog.Printf("Failed to push branch: %v", err)
106-
if rollbackErr := tracker.RollbackAllFiles(verbose); rollbackErr != nil && verbose {
109+
if rollbackErr := tracker.RollbackAllFiles(opts.Verbose); rollbackErr != nil && opts.Verbose {
107110
fmt.Fprintln(os.Stderr, console.FormatWarningMessage(fmt.Sprintf("Failed to rollback files: %v", rollbackErr)))
108111
}
109112
return 0, "", fmt.Errorf("failed to push branch %s: %w", branchName, err)
110113
}
111114

112115
// Create PR
113116
addWorkflowPRLog.Printf("Creating pull request: %s", prTitle)
114-
prNumber, prURL, err := createPR(branchName, prTitle, prBody, verbose)
117+
prNumber, prURL, err := createPR(branchName, prTitle, prBody, opts.Verbose)
115118
if err != nil {
116119
addWorkflowPRLog.Printf("Failed to create PR: %v", err)
117-
if rollbackErr := tracker.RollbackAllFiles(verbose); rollbackErr != nil && verbose {
120+
if rollbackErr := tracker.RollbackAllFiles(opts.Verbose); rollbackErr != nil && opts.Verbose {
118121
fmt.Fprintln(os.Stderr, console.FormatWarningMessage(fmt.Sprintf("Failed to rollback files: %v", rollbackErr)))
119122
}
120123
return 0, "", fmt.Errorf("failed to create PR: %w", err)
@@ -125,7 +128,7 @@ func addWorkflowsWithPR(workflows []*WorkflowSpec, number int, verbose bool, qui
125128
// Success - no rollback needed
126129

127130
// Switch back to original branch
128-
if err := switchBranch(currentBranch, verbose); err != nil {
131+
if err := switchBranch(currentBranch, opts.Verbose); err != nil {
129132
return prNumber, prURL, fmt.Errorf("failed to switch back to branch %s: %w", currentBranch, err)
130133
}
131134

‎pkg/cli/local_workflow_trial_test.go‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -65,7 +65,7 @@ This is a test workflow.
6565
}
6666

6767
// Test the local installation function
68-
err = installLocalWorkflowInTrialMode(originalDir, tempDir, spec, "", false)
68+
err = installLocalWorkflowInTrialMode(originalDir, tempDir, spec, "", false, &TrialOptions{DisableSecurityScanner: false})
6969
if err != nil {
7070
t.Fatalf("Failed to install local workflow: %v", err)
7171
}

‎pkg/cli/trial_command.go‎

Lines changed: 30 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -49,19 +49,20 @@ type RepoConfig struct {
4949

5050
// TrialOptions contains all configuration options for running workflow trials
5151
type TrialOptions struct {
52-
Repos RepoConfig
53-
DeleteHostRepo bool
54-
ForceDelete bool
55-
Quiet bool
56-
DryRun bool
57-
TimeoutMinutes int
58-
TriggerContext string
59-
RepeatCount int
60-
AutoMergePRs bool
61-
EngineOverride string
62-
AppendText string
63-
PushSecrets bool
64-
Verbose bool
52+
Repos RepoConfig
53+
DeleteHostRepo bool
54+
ForceDelete bool
55+
Quiet bool
56+
DryRun bool
57+
TimeoutMinutes int
58+
TriggerContext string
59+
RepeatCount int
60+
AutoMergePRs bool
61+
EngineOverride string
62+
AppendText string
63+
PushSecrets bool
64+
Verbose bool
65+
DisableSecurityScanner bool
6566
}
6667

6768
// NewTrialCommand creates the trial command
@@ -132,6 +133,7 @@ Trial results are saved both locally (in trials/ directory) and in the host repo
132133
appendText, _ := cmd.Flags().GetString("append")
133134
pushSecrets, _ := cmd.Flags().GetBool("use-local-secrets")
134135
verbose, _ := cmd.Root().PersistentFlags().GetBool("verbose")
136+
disableSecurityScanner, _ := cmd.Flags().GetBool("disable-security-scanner")
135137

136138
if err := validateEngine(engineOverride); err != nil {
137139
return err
@@ -147,18 +149,19 @@ Trial results are saved both locally (in trials/ directory) and in the host repo
147149
CloneRepo: cloneRepoSpec,
148150
HostRepo: hostRepoSpec,
149151
},
150-
DeleteHostRepo: deleteHostRepo,
151-
ForceDelete: forceDeleteHostRepo,
152-
Quiet: yes,
153-
DryRun: dryRun,
154-
TimeoutMinutes: timeout,
155-
TriggerContext: triggerContext,
156-
RepeatCount: repeatCount,
157-
AutoMergePRs: autoMergePRs,
158-
EngineOverride: engineOverride,
159-
AppendText: appendText,
160-
PushSecrets: pushSecrets,
161-
Verbose: verbose,
152+
DeleteHostRepo: deleteHostRepo,
153+
ForceDelete: forceDeleteHostRepo,
154+
Quiet: yes,
155+
DryRun: dryRun,
156+
TimeoutMinutes: timeout,
157+
TriggerContext: triggerContext,
158+
RepeatCount: repeatCount,
159+
AutoMergePRs: autoMergePRs,
160+
EngineOverride: engineOverride,
161+
AppendText: appendText,
162+
PushSecrets: pushSecrets,
163+
Verbose: verbose,
164+
DisableSecurityScanner: disableSecurityScanner,
162165
}
163166

164167
if err := RunWorkflowTrials(cmd.Context(), workflowSpecs, opts); err != nil {
@@ -192,6 +195,7 @@ Trial results are saved both locally (in trials/ directory) and in the host repo
192195
addEngineFlag(cmd)
193196
cmd.Flags().String("append", "", "Append extra content to the end of agentic workflow on installation")
194197
cmd.Flags().Bool("use-local-secrets", false, "Use local environment API key secrets for trial execution (pushes and cleans up secrets in repository)")
198+
cmd.Flags().Bool("disable-security-scanner", false, "Disable security scanning of workflow markdown content")
195199
cmd.MarkFlagsMutuallyExclusive("host-repo", "repo")
196200
cmd.MarkFlagsMutuallyExclusive("logical-repo", "clone-repo")
197201

@@ -440,7 +444,7 @@ func RunWorkflowTrials(ctx context.Context, workflowSpecs []string, opts TrialOp
440444
fmt.Fprintln(os.Stderr, console.FormatInfoMessage(fmt.Sprintf("=== Running trial for workflow: %s ===", parsedSpec.WorkflowName)))
441445

442446
// Install workflow with trial mode compilation
443-
if err := installWorkflowInTrialMode(ctx, tempDir, parsedSpec, logicalRepoSlug, cloneRepoSlug, hostRepoSlug, secretTracker, opts.EngineOverride, opts.AppendText, opts.PushSecrets, directTrialMode, opts.Verbose); err != nil {
447+
if err := installWorkflowInTrialMode(ctx, tempDir, parsedSpec, logicalRepoSlug, cloneRepoSlug, hostRepoSlug, secretTracker, opts.EngineOverride, opts.AppendText, opts.PushSecrets, directTrialMode, opts.Verbose, &opts); err != nil {
444448
return fmt.Errorf("failed to install workflow '%s' in trial mode: %w", parsedSpec.WorkflowName, err)
445449
}
446450

0 commit comments

Comments
 (0)