awguard 1.5.0 → 1.6.0
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/CHANGELOG.md +8 -0
- package/Dockerfile +8 -0
- package/README.md +57 -1
- package/action.yml +2 -2
- package/docs/assets/terminal-demo.svg +19 -0
- package/docs/comparison.md +23 -0
- package/docs/launch-plan.md +23 -15
- package/docs/market-analysis.md +3 -1
- package/docs/marketplace-listing.md +40 -0
- package/docs/roadmap.md +10 -6
- package/docs/site/index.html +251 -0
- package/examples/.gitlab-ci.yml +6 -0
- package/examples/.vscode/tasks.json +17 -0
- package/examples/README.md +4 -0
- package/examples/awguard.config.example.json +6 -0
- package/examples/lab/README.md +27 -0
- package/examples/lab/fixed/.github/workflows/ai-triage.yml +20 -0
- package/examples/lab/fixed/.mcp.json +12 -0
- package/examples/lab/fixed/AGENTS.md +5 -0
- package/examples/lab/unsafe/.github/workflows/ai-triage.yml +16 -0
- package/examples/lab/unsafe/.mcp.json +11 -0
- package/examples/lab/unsafe/AGENTS.md +4 -0
- package/examples/pre-commit-config.yaml +8 -0
- package/package.json +2 -1
- package/src/cli.js +36 -2
- package/src/compare.js +110 -0
- package/src/config.js +29 -2
- package/src/graph.js +6 -1
- package/src/init.js +81 -0
- package/src/inventory.js +11 -0
- package/src/migration.js +10 -0
- package/src/presets.js +2 -1
- package/src/remediation.js +19 -0
- package/src/reporters.js +6 -2
- package/src/scanner.js +91 -5
- package/src/score.js +3 -0
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
# Vulnerable Lab
|
|
2
|
+
|
|
3
|
+
This lab gives maintainers a tiny before/after set for demos, screenshots, and testing.
|
|
4
|
+
|
|
5
|
+
## Unsafe
|
|
6
|
+
|
|
7
|
+
```bash
|
|
8
|
+
npx awguard@latest examples/lab/unsafe --format inventory
|
|
9
|
+
npx awguard@latest examples/lab/unsafe --format graph
|
|
10
|
+
npx awguard@latest examples/lab/unsafe --fix-dry-run
|
|
11
|
+
```
|
|
12
|
+
|
|
13
|
+
The unsafe version includes:
|
|
14
|
+
|
|
15
|
+
- an AI triage workflow that reads issue comments;
|
|
16
|
+
- broad token permissions;
|
|
17
|
+
- an unsafe persistent agent instruction;
|
|
18
|
+
- a mutable MCP server with a committed token-shaped value.
|
|
19
|
+
|
|
20
|
+
## Fixed
|
|
21
|
+
|
|
22
|
+
```bash
|
|
23
|
+
npx awguard@latest examples/lab/fixed --format inventory
|
|
24
|
+
npx awguard@latest examples/lab/fixed --fail-on high
|
|
25
|
+
```
|
|
26
|
+
|
|
27
|
+
The fixed version uses read-only workflow permissions, conservative agent instructions, and pinned MCP package startup with prompted credentials.
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
name: Safer AI triage
|
|
2
|
+
|
|
3
|
+
on:
|
|
4
|
+
issue_comment:
|
|
5
|
+
|
|
6
|
+
permissions:
|
|
7
|
+
contents: read
|
|
8
|
+
|
|
9
|
+
jobs:
|
|
10
|
+
triage:
|
|
11
|
+
runs-on: ubuntu-latest
|
|
12
|
+
steps:
|
|
13
|
+
- name: Capture comment as untrusted data
|
|
14
|
+
env:
|
|
15
|
+
USER_TEXT: ${{ github.event.comment.body }} # awguard-disable-line AWG001 -- Reviewed: captured as data and used only in read-only suggestion mode.
|
|
16
|
+
run: |
|
|
17
|
+
printf '%s\n' "$USER_TEXT" > untrusted-input.txt
|
|
18
|
+
- name: Ask agent for suggestion only
|
|
19
|
+
run: |
|
|
20
|
+
codex --approval-mode suggest --prompt-file untrusted-input.txt
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
{
|
|
2
|
+
"inputs": [{ "type": "promptString", "id": "github-token", "password": true }],
|
|
3
|
+
"mcpServers": {
|
|
4
|
+
"github": {
|
|
5
|
+
"command": "npx",
|
|
6
|
+
"args": ["-y", "@modelcontextprotocol/server-github@1.2.3"],
|
|
7
|
+
"env": {
|
|
8
|
+
"GITHUB_TOKEN": "${input:github-token}"
|
|
9
|
+
}
|
|
10
|
+
}
|
|
11
|
+
}
|
|
12
|
+
}
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
name: Unsafe AI triage
|
|
2
|
+
|
|
3
|
+
on:
|
|
4
|
+
issue_comment:
|
|
5
|
+
|
|
6
|
+
permissions: write-all
|
|
7
|
+
|
|
8
|
+
jobs:
|
|
9
|
+
triage:
|
|
10
|
+
runs-on: ubuntu-latest
|
|
11
|
+
steps:
|
|
12
|
+
- uses: actions/checkout@v4
|
|
13
|
+
- env:
|
|
14
|
+
OPENAI_API_KEY: ${{ secrets.OPENAI_API_KEY }}
|
|
15
|
+
run: |
|
|
16
|
+
codex --dangerously-skip-permissions --prompt "${{ github.event.comment.body }}"
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "awguard",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.6.0",
|
|
4
4
|
"description": "Scan GitHub Actions workflows, agent instructions, and MCP configs for AI-agent injection and unsafe tool boundaries.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"homepage": "https://github.com/Mughal-Baig/agentic-workflow-guard#readme",
|
|
@@ -37,6 +37,7 @@
|
|
|
37
37
|
},
|
|
38
38
|
"files": [
|
|
39
39
|
"action.yml",
|
|
40
|
+
"Dockerfile",
|
|
40
41
|
"CHANGELOG.md",
|
|
41
42
|
"bin",
|
|
42
43
|
"src",
|
package/src/cli.js
CHANGED
|
@@ -2,7 +2,9 @@ import process from 'node:process';
|
|
|
2
2
|
import fs from 'node:fs';
|
|
3
3
|
import path from 'node:path';
|
|
4
4
|
import { applyBaseline, createBaseline, loadBaseline, writeBaseline } from './baseline.js';
|
|
5
|
+
import { loadReport, renderComparison } from './compare.js';
|
|
5
6
|
import { loadConfig } from './config.js';
|
|
7
|
+
import { renderInitGuide } from './init.js';
|
|
6
8
|
import { renderFixDryRun } from './remediation.js';
|
|
7
9
|
import { scanWorkflows, severityRank } from './scanner.js';
|
|
8
10
|
import {
|
|
@@ -16,15 +18,19 @@ import {
|
|
|
16
18
|
renderSarif,
|
|
17
19
|
renderScore,
|
|
18
20
|
renderSurfaceInventory,
|
|
21
|
+
renderSurfaceInventoryJson,
|
|
19
22
|
renderText
|
|
20
23
|
} from './reporters.js';
|
|
21
24
|
|
|
22
25
|
const HELP = `Agentic Workflow Guard
|
|
23
26
|
|
|
24
27
|
Usage:
|
|
25
|
-
awguard [path] [--config file] [--preset name] [--format text|json|markdown|github|sarif|graph|html|migration|score|badge|inventory] [--output file] [--baseline file] [--write-baseline file] [--fix-dry-run] [--fail-on none|low|medium|high|critical]
|
|
28
|
+
awguard [path] [--config file] [--preset name] [--format text|json|markdown|github|sarif|graph|html|migration|score|badge|inventory|inventory-json] [--output file] [--baseline file] [--write-baseline file] [--fix-dry-run] [--fail-on none|low|medium|high|critical]
|
|
29
|
+
awguard init
|
|
30
|
+
awguard --compare previous.json current.json
|
|
26
31
|
|
|
27
32
|
Examples:
|
|
33
|
+
awguard init
|
|
28
34
|
awguard .
|
|
29
35
|
awguard .mcp.json
|
|
30
36
|
awguard . --config awguard.config.json
|
|
@@ -33,16 +39,23 @@ Examples:
|
|
|
33
39
|
awguard . --format html --output awguard-report.html
|
|
34
40
|
awguard . --format migration --output awguard-migration.md
|
|
35
41
|
awguard . --format inventory
|
|
42
|
+
awguard . --format inventory-json --output awguard-inventory.json
|
|
36
43
|
awguard . --format score
|
|
37
44
|
awguard . --format badge --output awguard-badge.json
|
|
38
45
|
awguard . --fix-dry-run
|
|
39
46
|
awguard . --format sarif --output awguard.sarif --fail-on none
|
|
40
47
|
awguard . --write-baseline awguard.baseline.json
|
|
41
48
|
awguard . --baseline awguard.baseline.json --fail-on high
|
|
49
|
+
awguard --compare old-awguard.json new-awguard.json
|
|
42
50
|
awguard . --format github --fail-on medium
|
|
43
51
|
`;
|
|
44
52
|
|
|
45
53
|
export async function runCli(args, env = process.env) {
|
|
54
|
+
if (args[0] === 'init') {
|
|
55
|
+
console.log(renderInitGuide());
|
|
56
|
+
return;
|
|
57
|
+
}
|
|
58
|
+
|
|
46
59
|
const options = parseArgs(args, env);
|
|
47
60
|
|
|
48
61
|
if (options.help) {
|
|
@@ -50,6 +63,17 @@ export async function runCli(args, env = process.env) {
|
|
|
50
63
|
return;
|
|
51
64
|
}
|
|
52
65
|
|
|
66
|
+
if (options.compare.length > 0) {
|
|
67
|
+
const output = renderComparison(loadReport(options.compare[0]), loadReport(options.compare[1]));
|
|
68
|
+
if (options.output) {
|
|
69
|
+
const outputFile = writeOutput(options.output, output);
|
|
70
|
+
console.error(`Wrote ${outputFile}`);
|
|
71
|
+
} else {
|
|
72
|
+
console.log(output);
|
|
73
|
+
}
|
|
74
|
+
return;
|
|
75
|
+
}
|
|
76
|
+
|
|
53
77
|
const { config } = loadConfig({ configPath: options.config, root: options.path, presets: options.presets });
|
|
54
78
|
let result = scanWorkflows({ root: options.path, config });
|
|
55
79
|
|
|
@@ -87,6 +111,7 @@ export function parseArgs(args, env = {}) {
|
|
|
87
111
|
baseline: readInput(env, 'baseline') || '',
|
|
88
112
|
writeBaseline: readInput(env, 'write_baseline') || readInput(env, 'write-baseline') || '',
|
|
89
113
|
config: readInput(env, 'config') || '',
|
|
114
|
+
compare: [],
|
|
90
115
|
presets: splitList(readInput(env, 'preset') || readInput(env, 'presets') || ''),
|
|
91
116
|
fixDryRun: readBoolInput(env, 'fix_dry_run') || readBoolInput(env, 'fix-dry-run'),
|
|
92
117
|
help: false
|
|
@@ -121,6 +146,10 @@ export function parseArgs(args, env = {}) {
|
|
|
121
146
|
options.config = args[++index];
|
|
122
147
|
} else if (arg.startsWith('--config=')) {
|
|
123
148
|
options.config = arg.slice('--config='.length);
|
|
149
|
+
} else if (arg === '--compare') {
|
|
150
|
+
options.compare = [args[++index], args[++index]].filter(Boolean);
|
|
151
|
+
} else if (arg.startsWith('--compare=')) {
|
|
152
|
+
options.compare = arg.slice('--compare='.length).split(',').map((item) => item.trim()).filter(Boolean);
|
|
124
153
|
} else if (arg === '--preset') {
|
|
125
154
|
options.presets.push(...splitList(args[++index]));
|
|
126
155
|
} else if (arg.startsWith('--preset=')) {
|
|
@@ -145,9 +174,13 @@ export function parseArgs(args, env = {}) {
|
|
|
145
174
|
'migration',
|
|
146
175
|
'score',
|
|
147
176
|
'badge',
|
|
148
|
-
'inventory'
|
|
177
|
+
'inventory',
|
|
178
|
+
'inventory-json'
|
|
149
179
|
]);
|
|
150
180
|
validateEnum('fail-on', options.failOn, ['none', 'low', 'medium', 'high', 'critical']);
|
|
181
|
+
if (options.compare.length !== 0 && options.compare.length !== 2) {
|
|
182
|
+
throw new Error('--compare requires two awguard --format json report files');
|
|
183
|
+
}
|
|
151
184
|
|
|
152
185
|
return options;
|
|
153
186
|
}
|
|
@@ -167,6 +200,7 @@ function render(result, format) {
|
|
|
167
200
|
if (format === 'score') return renderScore(result);
|
|
168
201
|
if (format === 'badge') return renderBadge(result);
|
|
169
202
|
if (format === 'inventory') return renderSurfaceInventory(result);
|
|
203
|
+
if (format === 'inventory-json') return renderSurfaceInventoryJson(result);
|
|
170
204
|
if (format === 'github') return renderGithubAnnotations(result);
|
|
171
205
|
return renderText(result);
|
|
172
206
|
}
|
package/src/compare.js
ADDED
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
import fs from 'node:fs';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
|
|
4
|
+
export function loadReport(file) {
|
|
5
|
+
const absoluteFile = path.resolve(file);
|
|
6
|
+
if (!fs.existsSync(absoluteFile)) {
|
|
7
|
+
throw new Error(`report file does not exist: ${absoluteFile}`);
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
const report = JSON.parse(fs.readFileSync(absoluteFile, 'utf8'));
|
|
11
|
+
if (!Array.isArray(report.findings) || !Array.isArray(report.scannedFiles)) {
|
|
12
|
+
throw new Error(`report file must be awguard --format json output: ${absoluteFile}`);
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
return report;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export function buildComparison(previous, current) {
|
|
19
|
+
const previousFindings = mapByFingerprint(previous.findings);
|
|
20
|
+
const currentFindings = mapByFingerprint(current.findings);
|
|
21
|
+
const previousFiles = new Set(previous.scannedFiles || []);
|
|
22
|
+
const currentFiles = new Set(current.scannedFiles || []);
|
|
23
|
+
|
|
24
|
+
const introducedFindings = [...currentFindings.entries()]
|
|
25
|
+
.filter(([fingerprint]) => !previousFindings.has(fingerprint))
|
|
26
|
+
.map(([, finding]) => finding);
|
|
27
|
+
const resolvedFindings = [...previousFindings.entries()]
|
|
28
|
+
.filter(([fingerprint]) => !currentFindings.has(fingerprint))
|
|
29
|
+
.map(([, finding]) => finding);
|
|
30
|
+
const unchangedFindings = [...currentFindings.keys()].filter((fingerprint) => previousFindings.has(fingerprint));
|
|
31
|
+
|
|
32
|
+
return {
|
|
33
|
+
summary: {
|
|
34
|
+
previousFindings: previous.findings.length,
|
|
35
|
+
currentFindings: current.findings.length,
|
|
36
|
+
introducedFindings: introducedFindings.length,
|
|
37
|
+
resolvedFindings: resolvedFindings.length,
|
|
38
|
+
unchangedFindings: unchangedFindings.length,
|
|
39
|
+
addedFiles: [...currentFiles].filter((file) => !previousFiles.has(file)).length,
|
|
40
|
+
removedFiles: [...previousFiles].filter((file) => !currentFiles.has(file)).length
|
|
41
|
+
},
|
|
42
|
+
introducedFindings,
|
|
43
|
+
resolvedFindings,
|
|
44
|
+
addedFiles: [...currentFiles].filter((file) => !previousFiles.has(file)).sort(),
|
|
45
|
+
removedFiles: [...previousFiles].filter((file) => !currentFiles.has(file)).sort()
|
|
46
|
+
};
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
export function renderComparison(previous, current) {
|
|
50
|
+
const comparison = buildComparison(previous, current);
|
|
51
|
+
const lines = [
|
|
52
|
+
'# Agentic Workflow Guard Comparison',
|
|
53
|
+
'',
|
|
54
|
+
`Previous findings: **${comparison.summary.previousFindings}**`,
|
|
55
|
+
`Current findings: **${comparison.summary.currentFindings}**`,
|
|
56
|
+
`Introduced findings: **${comparison.summary.introducedFindings}**`,
|
|
57
|
+
`Resolved findings: **${comparison.summary.resolvedFindings}**`,
|
|
58
|
+
`Unchanged findings: **${comparison.summary.unchangedFindings}**`,
|
|
59
|
+
`Added scanned files: **${comparison.summary.addedFiles}**`,
|
|
60
|
+
`Removed scanned files: **${comparison.summary.removedFiles}**`,
|
|
61
|
+
''
|
|
62
|
+
];
|
|
63
|
+
|
|
64
|
+
lines.push('## Introduced Findings', '');
|
|
65
|
+
appendFindings(lines, comparison.introducedFindings);
|
|
66
|
+
lines.push('', '## Resolved Findings', '');
|
|
67
|
+
appendFindings(lines, comparison.resolvedFindings);
|
|
68
|
+
lines.push('', '## Added Files', '');
|
|
69
|
+
appendFiles(lines, comparison.addedFiles);
|
|
70
|
+
lines.push('', '## Removed Files', '');
|
|
71
|
+
appendFiles(lines, comparison.removedFiles);
|
|
72
|
+
|
|
73
|
+
return lines.join('\n');
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
function appendFindings(lines, findings) {
|
|
77
|
+
if (findings.length === 0) {
|
|
78
|
+
lines.push('None.');
|
|
79
|
+
return;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
lines.push('| Severity | Rule | Location | Finding |');
|
|
83
|
+
lines.push('| --- | --- | --- | --- |');
|
|
84
|
+
for (const finding of findings) {
|
|
85
|
+
lines.push(
|
|
86
|
+
`| ${escapeMarkdown(finding.severity)} | ${escapeMarkdown(finding.ruleId)} | ${escapeMarkdown(
|
|
87
|
+
`${finding.file}:${finding.line}`
|
|
88
|
+
)} | ${escapeMarkdown(finding.title)} |`
|
|
89
|
+
);
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
function appendFiles(lines, files) {
|
|
94
|
+
if (files.length === 0) {
|
|
95
|
+
lines.push('None.');
|
|
96
|
+
return;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
for (const file of files) {
|
|
100
|
+
lines.push(`- \`${file.replaceAll('`', '\\`')}\``);
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
function mapByFingerprint(findings) {
|
|
105
|
+
return new Map(findings.map((finding) => [finding.fingerprint || `${finding.ruleId}:${finding.file}:${finding.line}`, finding]));
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
function escapeMarkdown(value) {
|
|
109
|
+
return String(value).replaceAll('|', '\\|');
|
|
110
|
+
}
|
package/src/config.js
CHANGED
|
@@ -33,7 +33,8 @@ export function normalizeConfig(rawConfig = {}, source = 'config') {
|
|
|
33
33
|
|
|
34
34
|
return {
|
|
35
35
|
rules: normalizeRules(mergedConfig.rules || {}, source),
|
|
36
|
-
suppressions: normalizeSuppressions(mergedConfig.suppressions || {}, source)
|
|
36
|
+
suppressions: normalizeSuppressions(mergedConfig.suppressions || {}, source),
|
|
37
|
+
policy: normalizePolicy(mergedConfig.policy || {}, source)
|
|
37
38
|
};
|
|
38
39
|
}
|
|
39
40
|
|
|
@@ -51,7 +52,8 @@ function mergePresetConfigs(rawConfig, source) {
|
|
|
51
52
|
|
|
52
53
|
return mergeConfigObjects(merged, {
|
|
53
54
|
rules: rawConfig.rules || {},
|
|
54
|
-
suppressions: rawConfig.suppressions || {}
|
|
55
|
+
suppressions: rawConfig.suppressions || {},
|
|
56
|
+
policy: rawConfig.policy || {}
|
|
55
57
|
});
|
|
56
58
|
}
|
|
57
59
|
|
|
@@ -64,6 +66,10 @@ function mergeConfigObjects(base, override) {
|
|
|
64
66
|
suppressions: {
|
|
65
67
|
...(base.suppressions || {}),
|
|
66
68
|
...(override.suppressions || {})
|
|
69
|
+
},
|
|
70
|
+
policy: {
|
|
71
|
+
...(base.policy || {}),
|
|
72
|
+
...(override.policy || {})
|
|
67
73
|
}
|
|
68
74
|
};
|
|
69
75
|
}
|
|
@@ -149,6 +155,27 @@ function normalizeSuppressions(suppressions, source) {
|
|
|
149
155
|
};
|
|
150
156
|
}
|
|
151
157
|
|
|
158
|
+
function normalizePolicy(policy, source) {
|
|
159
|
+
if (!isObject(policy)) {
|
|
160
|
+
throw new Error(`${source} policy must be an object`);
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
return {
|
|
164
|
+
approvedFiles: normalizeStringArray(policy.approvedFiles || [], `${source} policy.approvedFiles`),
|
|
165
|
+
approvedMcpServers: normalizeStringArray(policy.approvedMcpServers || [], `${source} policy.approvedMcpServers`),
|
|
166
|
+
approvedMcpPackages: normalizeStringArray(policy.approvedMcpPackages || [], `${source} policy.approvedMcpPackages`),
|
|
167
|
+
approvedMcpCommands: normalizeStringArray(policy.approvedMcpCommands || [], `${source} policy.approvedMcpCommands`)
|
|
168
|
+
};
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
function normalizeStringArray(value, source) {
|
|
172
|
+
if (!Array.isArray(value)) {
|
|
173
|
+
throw new Error(`${source} must be an array`);
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
return value.map((item) => String(item));
|
|
177
|
+
}
|
|
178
|
+
|
|
152
179
|
function ensureKnownRule(ruleId, source) {
|
|
153
180
|
if (!ruleCatalog[ruleId]) {
|
|
154
181
|
throw new Error(`${source} references unknown rule id: ${ruleId}`);
|
package/src/graph.js
CHANGED
|
@@ -14,7 +14,8 @@ const impactByRule = {
|
|
|
14
14
|
AWG011: 'Suppression policy can hide real risk',
|
|
15
15
|
AWG012: 'Persistent agent instructions can weaken CI guardrails',
|
|
16
16
|
AWG013: 'Mutable MCP tool server can change agent capabilities',
|
|
17
|
-
AWG014: 'Committed MCP credential can expose external tools or data'
|
|
17
|
+
AWG014: 'Committed MCP credential can expose external tools or data',
|
|
18
|
+
AWG015: 'Unapproved agentic surface can drift outside policy'
|
|
18
19
|
};
|
|
19
20
|
|
|
20
21
|
export function buildAttackGraphs(result) {
|
|
@@ -218,6 +219,7 @@ function inferSource(finding) {
|
|
|
218
219
|
if (finding.ruleId === 'AWG012') return 'persistent agent instruction file';
|
|
219
220
|
if (finding.ruleId === 'AWG013') return 'project-scoped MCP server config';
|
|
220
221
|
if (finding.ruleId === 'AWG014') return 'committed MCP credential material';
|
|
222
|
+
if (finding.ruleId === 'AWG015') return 'repository policy';
|
|
221
223
|
return 'workflow configuration';
|
|
222
224
|
}
|
|
223
225
|
|
|
@@ -228,6 +230,7 @@ function inferBoundary(finding) {
|
|
|
228
230
|
if (finding.ruleId === 'AWG007') return 'command execution sink';
|
|
229
231
|
if (finding.ruleId === 'AWG012') return 'agent instruction context';
|
|
230
232
|
if (finding.ruleId === 'AWG013' || finding.ruleId === 'AWG014') return 'MCP tool boundary';
|
|
233
|
+
if (finding.ruleId === 'AWG015') return 'policy allowlist boundary';
|
|
231
234
|
return 'workflow execution';
|
|
232
235
|
}
|
|
233
236
|
|
|
@@ -238,6 +241,7 @@ function inferCapability(finding) {
|
|
|
238
241
|
if (finding.ruleId === 'AWG012') return 'persistent prompt steering';
|
|
239
242
|
if (finding.ruleId === 'AWG013') return 'MCP server startup';
|
|
240
243
|
if (finding.ruleId === 'AWG014') return 'credentialed MCP tool access';
|
|
244
|
+
if (finding.ruleId === 'AWG015') return 'agentic surface drift';
|
|
241
245
|
return 'CI runner and agent tools';
|
|
242
246
|
}
|
|
243
247
|
|
|
@@ -248,6 +252,7 @@ function inferAuthority(finding) {
|
|
|
248
252
|
if (finding.ruleId === 'AWG012') return 'agent policy context';
|
|
249
253
|
if (finding.ruleId === 'AWG013') return 'developer machine or CI tool process';
|
|
250
254
|
if (finding.ruleId === 'AWG014') return 'MCP server secrets';
|
|
255
|
+
if (finding.ruleId === 'AWG015') return 'repository policy approval';
|
|
251
256
|
return 'workflow permissions';
|
|
252
257
|
}
|
|
253
258
|
|
package/src/init.js
ADDED
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
export function renderInitGuide({ actionRef = 'v0' } = {}) {
|
|
2
|
+
return [
|
|
3
|
+
'# Agentic Workflow Guard Setup',
|
|
4
|
+
'',
|
|
5
|
+
'Create `.github/workflows/awguard.yml`:',
|
|
6
|
+
'',
|
|
7
|
+
'```yaml',
|
|
8
|
+
`name: Agentic Workflow Guard`,
|
|
9
|
+
'',
|
|
10
|
+
'on:',
|
|
11
|
+
' pull_request:',
|
|
12
|
+
' workflow_dispatch:',
|
|
13
|
+
' schedule:',
|
|
14
|
+
" - cron: '17 4 * * 1'",
|
|
15
|
+
'',
|
|
16
|
+
'permissions:',
|
|
17
|
+
' contents: read',
|
|
18
|
+
' security-events: write',
|
|
19
|
+
'',
|
|
20
|
+
'jobs:',
|
|
21
|
+
' scan:',
|
|
22
|
+
' runs-on: ubuntu-latest',
|
|
23
|
+
' steps:',
|
|
24
|
+
' - uses: actions/checkout@v4',
|
|
25
|
+
` - uses: Mughal-Baig/agentic-workflow-guard@${actionRef}`,
|
|
26
|
+
' with:',
|
|
27
|
+
' preset: strict',
|
|
28
|
+
' format: sarif',
|
|
29
|
+
' output: awguard.sarif',
|
|
30
|
+
' fail-on: high',
|
|
31
|
+
' - uses: github/codeql-action/upload-sarif@v4',
|
|
32
|
+
' if: always()',
|
|
33
|
+
' with:',
|
|
34
|
+
' sarif_file: awguard.sarif',
|
|
35
|
+
' category: agentic-workflow-guard',
|
|
36
|
+
'```',
|
|
37
|
+
'',
|
|
38
|
+
'Create `awguard.config.json`:',
|
|
39
|
+
'',
|
|
40
|
+
'```json',
|
|
41
|
+
JSON.stringify(
|
|
42
|
+
{
|
|
43
|
+
extends: ['strict'],
|
|
44
|
+
policy: {
|
|
45
|
+
approvedFiles: ['AGENTS.md', '.github/workflows/*'],
|
|
46
|
+
approvedMcpServers: [],
|
|
47
|
+
approvedMcpPackages: [],
|
|
48
|
+
approvedMcpCommands: ['npx', 'node', 'uvx', 'docker']
|
|
49
|
+
},
|
|
50
|
+
suppressions: {
|
|
51
|
+
minimumReasonLength: 20
|
|
52
|
+
}
|
|
53
|
+
},
|
|
54
|
+
null,
|
|
55
|
+
2
|
|
56
|
+
),
|
|
57
|
+
'```',
|
|
58
|
+
'',
|
|
59
|
+
'Adopt without breaking existing CI:',
|
|
60
|
+
'',
|
|
61
|
+
'```bash',
|
|
62
|
+
'npx awguard@latest . --write-baseline awguard.baseline.json --fail-on none',
|
|
63
|
+
'npx awguard@latest . --baseline awguard.baseline.json --fail-on high',
|
|
64
|
+
'```',
|
|
65
|
+
'',
|
|
66
|
+
'Generate useful reports:',
|
|
67
|
+
'',
|
|
68
|
+
'```bash',
|
|
69
|
+
'npx awguard@latest . --format inventory',
|
|
70
|
+
'npx awguard@latest . --format inventory-json --output awguard-inventory.json',
|
|
71
|
+
'npx awguard@latest . --format score',
|
|
72
|
+
'npx awguard@latest . --format badge --output docs/awguard-badge.json',
|
|
73
|
+
'```',
|
|
74
|
+
'',
|
|
75
|
+
'README badge:',
|
|
76
|
+
'',
|
|
77
|
+
'```markdown',
|
|
78
|
+
'[](docs/awguard-badge.json)',
|
|
79
|
+
'```'
|
|
80
|
+
].join('\n');
|
|
81
|
+
}
|
package/src/inventory.js
CHANGED
|
@@ -105,6 +105,17 @@ export function renderInventory(result) {
|
|
|
105
105
|
return lines.join('\n');
|
|
106
106
|
}
|
|
107
107
|
|
|
108
|
+
export function renderInventoryJson(result) {
|
|
109
|
+
return JSON.stringify(
|
|
110
|
+
{
|
|
111
|
+
root: result.root,
|
|
112
|
+
...buildInventory(result)
|
|
113
|
+
},
|
|
114
|
+
null,
|
|
115
|
+
2
|
|
116
|
+
);
|
|
117
|
+
}
|
|
118
|
+
|
|
108
119
|
function recommendationsFor(surfaces, findings) {
|
|
109
120
|
const surfaceNames = new Set(surfaces.map((surface) => surface.surface));
|
|
110
121
|
const rules = new Set(findings.map((finding) => finding.ruleId));
|
package/src/migration.js
CHANGED
|
@@ -63,6 +63,11 @@ const ruleActions = {
|
|
|
63
63
|
'Remove committed MCP tokens, API keys, passwords, and auth headers.',
|
|
64
64
|
'Use prompted inputs, environment variables, or managed secrets for MCP credentials.',
|
|
65
65
|
'Rotate credentials that were present in repository history.'
|
|
66
|
+
],
|
|
67
|
+
AWG015: [
|
|
68
|
+
'Review the unapproved agentic surface and decide whether it belongs in the repository.',
|
|
69
|
+
'Add approved files, MCP servers, packages, and commands to policy allowlists.',
|
|
70
|
+
'Fail CI on policy drift so new agent surfaces are visible in review.'
|
|
66
71
|
]
|
|
67
72
|
};
|
|
68
73
|
|
|
@@ -180,6 +185,7 @@ function riskShapeFor(findings) {
|
|
|
180
185
|
if (rules.has('AWG012')) pieces.push('persistent agent instructions weaken review or permission boundaries');
|
|
181
186
|
if (rules.has('AWG013')) pieces.push('project MCP config can change agent tool capabilities through mutable startup');
|
|
182
187
|
if (rules.has('AWG014')) pieces.push('project MCP config contains committed credentials');
|
|
188
|
+
if (rules.has('AWG015')) pieces.push('agentic surface is outside the repository policy');
|
|
183
189
|
|
|
184
190
|
return pieces.length > 0 ? pieces.join('; ') : 'workflow hardening issue';
|
|
185
191
|
}
|
|
@@ -230,6 +236,10 @@ function allowedOperationsFor(findings) {
|
|
|
230
236
|
operations.add('MCP credentials supplied by prompt input, environment variable, or secret manager only');
|
|
231
237
|
}
|
|
232
238
|
|
|
239
|
+
if (rules.has('AWG015')) {
|
|
240
|
+
operations.add('policy approval only after reviewing the workflow, agent context, MCP server, package, and command');
|
|
241
|
+
}
|
|
242
|
+
|
|
233
243
|
operations.add('noop or missing-data report when validation fails');
|
|
234
244
|
return [...operations];
|
|
235
245
|
}
|
package/src/presets.js
CHANGED
package/src/remediation.js
CHANGED
|
@@ -68,6 +68,11 @@ const fixCatalog = {
|
|
|
68
68
|
'Move MCP credentials into prompt inputs, environment variables, or a managed secret store.',
|
|
69
69
|
'Use placeholders such as ${input:token} or ${TOKEN} instead of committed literal values.',
|
|
70
70
|
'Rotate any token, API key, password, or auth header that was committed.'
|
|
71
|
+
],
|
|
72
|
+
AWG015: [
|
|
73
|
+
'Review the new agentic surface before approving it in policy.',
|
|
74
|
+
'Add reviewed files to policy.approvedFiles and reviewed MCP tools to the MCP policy allowlists.',
|
|
75
|
+
'Remove or quarantine unapproved workflows, agent instructions, prompts, skills, and MCP configs.'
|
|
71
76
|
]
|
|
72
77
|
};
|
|
73
78
|
|
|
@@ -181,5 +186,19 @@ run: |
|
|
|
181
186
|
};
|
|
182
187
|
}
|
|
183
188
|
|
|
189
|
+
if (finding.ruleId === 'AWG015') {
|
|
190
|
+
return {
|
|
191
|
+
language: 'json',
|
|
192
|
+
text: `{
|
|
193
|
+
"policy": {
|
|
194
|
+
"approvedFiles": ["AGENTS.md", ".github/workflows/*", ".github/agents/*"],
|
|
195
|
+
"approvedMcpServers": ["github"],
|
|
196
|
+
"approvedMcpPackages": ["@modelcontextprotocol/server-github@1.2.3"],
|
|
197
|
+
"approvedMcpCommands": ["npx", "node"]
|
|
198
|
+
}
|
|
199
|
+
}`
|
|
200
|
+
};
|
|
201
|
+
}
|
|
202
|
+
|
|
184
203
|
return null;
|
|
185
204
|
}
|
package/src/reporters.js
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import path from 'node:path';
|
|
2
2
|
import { findingFingerprint } from './fingerprints.js';
|
|
3
3
|
import { renderGraphMarkdown, renderHtmlReport } from './graph.js';
|
|
4
|
-
import { renderInventory } from './inventory.js';
|
|
4
|
+
import { renderInventory, renderInventoryJson } from './inventory.js';
|
|
5
5
|
import { renderMigrationPlan } from './migration.js';
|
|
6
6
|
import { renderBadgeJson, renderScorecard } from './score.js';
|
|
7
7
|
import { ruleCatalog } from './scanner.js';
|
|
@@ -84,7 +84,7 @@ export function renderSarif(result) {
|
|
|
84
84
|
driver: {
|
|
85
85
|
name: 'Agentic Workflow Guard',
|
|
86
86
|
informationUri: 'https://github.com/Mughal-Baig/agentic-workflow-guard',
|
|
87
|
-
semanticVersion: '1.
|
|
87
|
+
semanticVersion: '1.6.0',
|
|
88
88
|
rules: Object.entries(ruleCatalog).map(([id, rule]) => ({
|
|
89
89
|
id,
|
|
90
90
|
name: id,
|
|
@@ -210,6 +210,10 @@ export function renderSurfaceInventory(result) {
|
|
|
210
210
|
return renderInventory(result);
|
|
211
211
|
}
|
|
212
212
|
|
|
213
|
+
export function renderSurfaceInventoryJson(result) {
|
|
214
|
+
return renderInventoryJson(result);
|
|
215
|
+
}
|
|
216
|
+
|
|
213
217
|
export function renderGithubAnnotations(result) {
|
|
214
218
|
if (result.findings.length === 0) {
|
|
215
219
|
return 'Agentic Workflow Guard: no findings.';
|