awguard 1.4.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 +14 -0
- package/Dockerfile +8 -0
- package/README.md +75 -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 +28 -13
- package/docs/market-analysis.md +20 -0
- package/docs/marketplace-listing.md +40 -0
- package/docs/roadmap.md +75 -0
- 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 +5 -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 +63 -3
- 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 +159 -0
- package/src/migration.js +10 -0
- package/src/presets.js +2 -1
- package/src/remediation.js +19 -0
- package/src/reporters.js +10 -1
- package/src/scanner.js +119 -5
- package/src/score.js +3 -0
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
ADDED
|
@@ -0,0 +1,159 @@
|
|
|
1
|
+
import path from 'node:path';
|
|
2
|
+
import { classifyScanFile, severityRank } from './scanner.js';
|
|
3
|
+
|
|
4
|
+
const surfaceLabels = {
|
|
5
|
+
'github-workflow': 'GitHub Actions workflows',
|
|
6
|
+
'agent-context': 'Agent context files',
|
|
7
|
+
'mcp-config': 'MCP configs',
|
|
8
|
+
other: 'Other scanned files'
|
|
9
|
+
};
|
|
10
|
+
|
|
11
|
+
const surfaceOrder = ['github-workflow', 'agent-context', 'mcp-config', 'other'];
|
|
12
|
+
|
|
13
|
+
export function buildInventory(result) {
|
|
14
|
+
const fileRows = result.scannedFiles.map((file) => {
|
|
15
|
+
const relativeFile = path.relative(result.root, file) || file;
|
|
16
|
+
const surface = classifyScanFile(file, result.root);
|
|
17
|
+
const findings = result.findings.filter((finding) => finding.file === relativeFile);
|
|
18
|
+
|
|
19
|
+
return {
|
|
20
|
+
file: relativeFile,
|
|
21
|
+
surface,
|
|
22
|
+
label: surfaceLabels[surface] || surfaceLabels.other,
|
|
23
|
+
findings: findings.length,
|
|
24
|
+
highest: highestSeverity(findings),
|
|
25
|
+
rules: [...new Set(findings.map((finding) => finding.ruleId))]
|
|
26
|
+
};
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
const surfaces = surfaceOrder
|
|
30
|
+
.map((surface) => {
|
|
31
|
+
const files = fileRows.filter((file) => file.surface === surface);
|
|
32
|
+
const findings = result.findings.filter((finding) => files.some((file) => file.file === finding.file));
|
|
33
|
+
|
|
34
|
+
return {
|
|
35
|
+
surface,
|
|
36
|
+
label: surfaceLabels[surface],
|
|
37
|
+
files: files.length,
|
|
38
|
+
findings: findings.length,
|
|
39
|
+
highest: highestSeverity(findings),
|
|
40
|
+
rules: [...new Set(findings.map((finding) => finding.ruleId))]
|
|
41
|
+
};
|
|
42
|
+
})
|
|
43
|
+
.filter((surface) => surface.files > 0);
|
|
44
|
+
|
|
45
|
+
return {
|
|
46
|
+
summary: {
|
|
47
|
+
scannedFiles: result.scannedFiles.length,
|
|
48
|
+
surfaces: surfaces.length,
|
|
49
|
+
findings: result.findings.length,
|
|
50
|
+
highest: result.summary.highest
|
|
51
|
+
},
|
|
52
|
+
surfaces,
|
|
53
|
+
files: fileRows,
|
|
54
|
+
recommendations: recommendationsFor(surfaces, result.findings)
|
|
55
|
+
};
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
export function renderInventory(result) {
|
|
59
|
+
const inventory = buildInventory(result);
|
|
60
|
+
const lines = [
|
|
61
|
+
'# Agentic Surface Inventory',
|
|
62
|
+
'',
|
|
63
|
+
`Scanned files: **${inventory.summary.scannedFiles}**`,
|
|
64
|
+
`Agentic surfaces: **${inventory.summary.surfaces}**`,
|
|
65
|
+
`Findings: **${inventory.summary.findings}**`,
|
|
66
|
+
`Highest severity: **${inventory.summary.highest}**`,
|
|
67
|
+
'',
|
|
68
|
+
'## Surface Summary',
|
|
69
|
+
'',
|
|
70
|
+
'| Surface | Files | Findings | Highest | Rules |',
|
|
71
|
+
'| --- | ---: | ---: | --- | --- |'
|
|
72
|
+
];
|
|
73
|
+
|
|
74
|
+
if (inventory.surfaces.length === 0) {
|
|
75
|
+
lines.push('| None found | 0 | 0 | none | |');
|
|
76
|
+
} else {
|
|
77
|
+
for (const surface of inventory.surfaces) {
|
|
78
|
+
lines.push(
|
|
79
|
+
`| ${surface.label} | ${surface.files} | ${surface.findings} | ${surface.highest} | ${
|
|
80
|
+
surface.rules.length > 0 ? surface.rules.join(', ') : ''
|
|
81
|
+
} |`
|
|
82
|
+
);
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
lines.push('', '## Files', '', '| Surface | File | Findings | Highest | Rules |', '| --- | --- | ---: | --- | --- |');
|
|
87
|
+
|
|
88
|
+
if (inventory.files.length === 0) {
|
|
89
|
+
lines.push('| None found | | 0 | none | |');
|
|
90
|
+
} else {
|
|
91
|
+
for (const file of inventory.files) {
|
|
92
|
+
lines.push(
|
|
93
|
+
`| ${file.label} | \`${escapeMarkdown(file.file)}\` | ${file.findings} | ${file.highest} | ${
|
|
94
|
+
file.rules.length > 0 ? file.rules.join(', ') : ''
|
|
95
|
+
} |`
|
|
96
|
+
);
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
lines.push('', '## Recommended Next Steps', '');
|
|
101
|
+
for (const recommendation of inventory.recommendations) {
|
|
102
|
+
lines.push(`- ${recommendation}`);
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
return lines.join('\n');
|
|
106
|
+
}
|
|
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
|
+
|
|
119
|
+
function recommendationsFor(surfaces, findings) {
|
|
120
|
+
const surfaceNames = new Set(surfaces.map((surface) => surface.surface));
|
|
121
|
+
const rules = new Set(findings.map((finding) => finding.ruleId));
|
|
122
|
+
const recommendations = [];
|
|
123
|
+
|
|
124
|
+
if (rules.has('AWG014')) {
|
|
125
|
+
recommendations.push('Remove and rotate committed MCP credentials before widening agent access.');
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
if (rules.has('AWG013')) {
|
|
129
|
+
recommendations.push('Pin MCP server packages, container images, and startup commands before enabling repository-scoped tools.');
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
if (rules.has('AWG012')) {
|
|
133
|
+
recommendations.push('Review persistent agent context files before relying on workflow permission boundaries.');
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
if (!surfaceNames.has('agent-context')) {
|
|
137
|
+
recommendations.push('Add an explicit `AGENTS.md` or `.github/copilot-instructions.md` with conservative safety rules before introducing agents.');
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
if (!surfaceNames.has('mcp-config')) {
|
|
141
|
+
recommendations.push('Keep MCP configs absent until there is a reviewed tool allowlist and credential handling plan.');
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
if (recommendations.length === 0) {
|
|
145
|
+
recommendations.push('Keep this inventory in CI so new agent surfaces are visible during review.');
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
return recommendations;
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
function highestSeverity(findings) {
|
|
152
|
+
return findings.reduce((current, finding) => {
|
|
153
|
+
return severityRank[finding.severity] > severityRank[current] ? finding.severity : current;
|
|
154
|
+
}, 'none');
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
function escapeMarkdown(value) {
|
|
158
|
+
return String(value).replaceAll('|', '\\|');
|
|
159
|
+
}
|
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,6 +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, renderInventoryJson } from './inventory.js';
|
|
4
5
|
import { renderMigrationPlan } from './migration.js';
|
|
5
6
|
import { renderBadgeJson, renderScorecard } from './score.js';
|
|
6
7
|
import { ruleCatalog } from './scanner.js';
|
|
@@ -83,7 +84,7 @@ export function renderSarif(result) {
|
|
|
83
84
|
driver: {
|
|
84
85
|
name: 'Agentic Workflow Guard',
|
|
85
86
|
informationUri: 'https://github.com/Mughal-Baig/agentic-workflow-guard',
|
|
86
|
-
semanticVersion: '1.
|
|
87
|
+
semanticVersion: '1.6.0',
|
|
87
88
|
rules: Object.entries(ruleCatalog).map(([id, rule]) => ({
|
|
88
89
|
id,
|
|
89
90
|
name: id,
|
|
@@ -205,6 +206,14 @@ export function renderBadge(result) {
|
|
|
205
206
|
return renderBadgeJson(result);
|
|
206
207
|
}
|
|
207
208
|
|
|
209
|
+
export function renderSurfaceInventory(result) {
|
|
210
|
+
return renderInventory(result);
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
export function renderSurfaceInventoryJson(result) {
|
|
214
|
+
return renderInventoryJson(result);
|
|
215
|
+
}
|
|
216
|
+
|
|
208
217
|
export function renderGithubAnnotations(result) {
|
|
209
218
|
if (result.findings.length === 0) {
|
|
210
219
|
return 'Agentic Workflow Guard: no findings.';
|
package/src/scanner.js
CHANGED
|
@@ -175,6 +175,12 @@ export const ruleCatalog = {
|
|
|
175
175
|
severity: 'critical',
|
|
176
176
|
suggestion:
|
|
177
177
|
'Move MCP credentials into input prompts, environment variables, or a secret manager. Do not commit bearer tokens, API keys, passwords, or auth headers in MCP config files.'
|
|
178
|
+
},
|
|
179
|
+
AWG015: {
|
|
180
|
+
title: 'Agentic surface is not approved by policy',
|
|
181
|
+
severity: 'medium',
|
|
182
|
+
suggestion:
|
|
183
|
+
'Add the workflow, agent context file, MCP config, MCP server, package, or command to the policy allowlist only after review. Otherwise remove or harden it.'
|
|
178
184
|
}
|
|
179
185
|
};
|
|
180
186
|
|
|
@@ -306,15 +312,24 @@ export function scanMcpConfigText(text, file = '.mcp.json', root = process.cwd()
|
|
|
306
312
|
return context.findings;
|
|
307
313
|
}
|
|
308
314
|
|
|
315
|
+
export function classifyScanFile(file, root = process.cwd()) {
|
|
316
|
+
if (isMcpConfigFile(file, root)) return 'mcp-config';
|
|
317
|
+
if (isAgentInstructionFile(file, root)) return 'agent-context';
|
|
318
|
+
if (workflowExtensions.has(path.extname(file))) return 'github-workflow';
|
|
319
|
+
return 'other';
|
|
320
|
+
}
|
|
321
|
+
|
|
309
322
|
function scanFile(file, root, config) {
|
|
310
323
|
const text = fs.readFileSync(file, 'utf8');
|
|
324
|
+
let findings;
|
|
311
325
|
if (isAgentInstructionFile(file, root)) {
|
|
312
|
-
|
|
313
|
-
}
|
|
314
|
-
|
|
315
|
-
|
|
326
|
+
findings = scanAgentInstructionText(text, file, root, config);
|
|
327
|
+
} else if (isMcpConfigFile(file, root)) {
|
|
328
|
+
findings = scanMcpConfigText(text, file, root, config);
|
|
329
|
+
} else {
|
|
330
|
+
findings = scanWorkflowText(text, file, root, config);
|
|
316
331
|
}
|
|
317
|
-
return
|
|
332
|
+
return [...findings, ...detectFilePolicy(file, root, config)];
|
|
318
333
|
}
|
|
319
334
|
|
|
320
335
|
function discoverScanFiles(root) {
|
|
@@ -546,6 +561,7 @@ function detectMcpConfigRisks(context, config) {
|
|
|
546
561
|
for (const server of servers) {
|
|
547
562
|
detectMutableMcpServer(context, server);
|
|
548
563
|
detectMcpSecretMaterial(context, server);
|
|
564
|
+
detectMcpPolicy(context, server);
|
|
549
565
|
}
|
|
550
566
|
}
|
|
551
567
|
|
|
@@ -603,6 +619,71 @@ function detectMcpSecretMaterial(context, server) {
|
|
|
603
619
|
}
|
|
604
620
|
}
|
|
605
621
|
|
|
622
|
+
function detectMcpPolicy(context, server) {
|
|
623
|
+
const policy = context.config.policy || {};
|
|
624
|
+
const command = normalizeCommand(stringValue(server.config.command));
|
|
625
|
+
const args = arrayOfStrings(server.config.args);
|
|
626
|
+
const packageSpec = findMcpPackageSpec(command, args);
|
|
627
|
+
|
|
628
|
+
if (policy.approvedMcpServers?.length > 0 && !policy.approvedMcpServers.includes(server.name)) {
|
|
629
|
+
addFinding(context, 'AWG015', locateMcpLine(context, server, server.name), {
|
|
630
|
+
evidence: `MCP server "${server.name}"`,
|
|
631
|
+
message: `MCP server "${server.name}" is not listed in policy.approvedMcpServers.`
|
|
632
|
+
});
|
|
633
|
+
}
|
|
634
|
+
|
|
635
|
+
if (policy.approvedMcpCommands?.length > 0 && command && !policy.approvedMcpCommands.includes(command)) {
|
|
636
|
+
addFinding(context, 'AWG015', locateMcpLine(context, server, command), {
|
|
637
|
+
evidence: `MCP server "${server.name}" command: ${command}`,
|
|
638
|
+
message: `MCP server "${server.name}" uses a command not listed in policy.approvedMcpCommands.`
|
|
639
|
+
});
|
|
640
|
+
}
|
|
641
|
+
|
|
642
|
+
if (policy.approvedMcpPackages?.length > 0 && packageSpec && !policy.approvedMcpPackages.includes(packageSpec)) {
|
|
643
|
+
addFinding(context, 'AWG015', locateMcpLine(context, server, packageSpec), {
|
|
644
|
+
evidence: `MCP server "${server.name}" package: ${packageSpec}`,
|
|
645
|
+
message: `MCP server "${server.name}" uses a package not listed in policy.approvedMcpPackages.`
|
|
646
|
+
});
|
|
647
|
+
}
|
|
648
|
+
}
|
|
649
|
+
|
|
650
|
+
function detectFilePolicy(file, root, config) {
|
|
651
|
+
const policy = config.policy || {};
|
|
652
|
+
if (!policy.approvedFiles || policy.approvedFiles.length === 0) return [];
|
|
653
|
+
|
|
654
|
+
const relativeFile = path.relative(root, file).split(path.sep).join('/') || path.basename(file);
|
|
655
|
+
if (matchesAnyPolicyPattern(relativeFile, policy.approvedFiles)) return [];
|
|
656
|
+
|
|
657
|
+
const context = createPolicyContext(file, root, config);
|
|
658
|
+
addFinding(context, 'AWG015', 1, {
|
|
659
|
+
evidence: relativeFile,
|
|
660
|
+
message: `${relativeFile} is an agentic surface that is not listed in policy.approvedFiles.`
|
|
661
|
+
});
|
|
662
|
+
return context.findings;
|
|
663
|
+
}
|
|
664
|
+
|
|
665
|
+
function createPolicyContext(file, root, config) {
|
|
666
|
+
return {
|
|
667
|
+
file,
|
|
668
|
+
relativeFile: path.isAbsolute(file) ? path.relative(root, file) || path.basename(file) : file,
|
|
669
|
+
lines: [],
|
|
670
|
+
runBlocks: new Set(),
|
|
671
|
+
triggers: new Set(),
|
|
672
|
+
hasAgent: true,
|
|
673
|
+
hasPromptBoundary: true,
|
|
674
|
+
hasUntrustedTrigger: false,
|
|
675
|
+
hasPermissionBlock: false,
|
|
676
|
+
hasBroadPermission: false,
|
|
677
|
+
hasSecret: false,
|
|
678
|
+
config,
|
|
679
|
+
suppressions: new Map(),
|
|
680
|
+
invalidSuppressions: [],
|
|
681
|
+
suppressedFindings: [],
|
|
682
|
+
findings: [],
|
|
683
|
+
seen: new Set()
|
|
684
|
+
};
|
|
685
|
+
}
|
|
686
|
+
|
|
606
687
|
function addFinding(context, ruleId, line, overrides = {}) {
|
|
607
688
|
const docs = ruleCatalog[ruleId];
|
|
608
689
|
const ruleConfig = context.config.rules?.[ruleId];
|
|
@@ -831,6 +912,21 @@ function discoverAgentInstructionFiles(root) {
|
|
|
831
912
|
files.push(...walk(githubInstructionsDir).filter((file) => file.endsWith('.instructions.md')));
|
|
832
913
|
}
|
|
833
914
|
|
|
915
|
+
const githubAgentsDir = path.join(root, '.github', 'agents');
|
|
916
|
+
if (fs.existsSync(githubAgentsDir)) {
|
|
917
|
+
files.push(...walk(githubAgentsDir).filter((file) => file.endsWith('.md')));
|
|
918
|
+
}
|
|
919
|
+
|
|
920
|
+
const githubPromptsDir = path.join(root, '.github', 'prompts');
|
|
921
|
+
if (fs.existsSync(githubPromptsDir)) {
|
|
922
|
+
files.push(...walk(githubPromptsDir).filter((file) => file.endsWith('.prompt.md')));
|
|
923
|
+
}
|
|
924
|
+
|
|
925
|
+
const githubSkillsDir = path.join(root, '.github', 'skills');
|
|
926
|
+
if (fs.existsSync(githubSkillsDir)) {
|
|
927
|
+
files.push(...walk(githubSkillsDir).filter((file) => path.basename(file).toLowerCase() === 'skill.md'));
|
|
928
|
+
}
|
|
929
|
+
|
|
834
930
|
const cursorRulesDir = path.join(root, '.cursor', 'rules');
|
|
835
931
|
if (fs.existsSync(cursorRulesDir)) {
|
|
836
932
|
files.push(...walk(cursorRulesDir).filter((file) => ['.md', '.mdc', '.txt'].includes(path.extname(file))));
|
|
@@ -859,6 +955,12 @@ function isAgentInstructionFile(file, root) {
|
|
|
859
955
|
/\/\.github\/copilot-instructions\.md$/i.test(normalizedFile) ||
|
|
860
956
|
/^\.github\/instructions\/.+\.instructions\.md$/i.test(relativeFile) ||
|
|
861
957
|
/\/\.github\/instructions\/.+\.instructions\.md$/i.test(normalizedFile) ||
|
|
958
|
+
/^\.github\/agents\/.+\.md$/i.test(relativeFile) ||
|
|
959
|
+
/\/\.github\/agents\/.+\.md$/i.test(normalizedFile) ||
|
|
960
|
+
/^\.github\/prompts\/.+\.prompt\.md$/i.test(relativeFile) ||
|
|
961
|
+
/\/\.github\/prompts\/.+\.prompt\.md$/i.test(normalizedFile) ||
|
|
962
|
+
/^\.github\/skills\/.+\/skill\.md$/i.test(relativeFile) ||
|
|
963
|
+
/\/\.github\/skills\/.+\/skill\.md$/i.test(normalizedFile) ||
|
|
862
964
|
/^\.cursor\/rules\/.+\.(?:md|mdc|txt)$/i.test(relativeFile) ||
|
|
863
965
|
/\/\.cursor\/rules\/.+\.(?:md|mdc|txt)$/i.test(normalizedFile)
|
|
864
966
|
);
|
|
@@ -1179,6 +1281,18 @@ function isPlainObject(value) {
|
|
|
1179
1281
|
return Boolean(value) && typeof value === 'object' && !Array.isArray(value);
|
|
1180
1282
|
}
|
|
1181
1283
|
|
|
1284
|
+
function matchesAnyPolicyPattern(value, patterns) {
|
|
1285
|
+
return patterns.some((pattern) => wildcardToRegExp(pattern).test(value));
|
|
1286
|
+
}
|
|
1287
|
+
|
|
1288
|
+
function wildcardToRegExp(pattern) {
|
|
1289
|
+
const escaped = String(pattern)
|
|
1290
|
+
.split('*')
|
|
1291
|
+
.map((part) => escapeRegex(part))
|
|
1292
|
+
.join('.*');
|
|
1293
|
+
return new RegExp(`^${escaped}$`);
|
|
1294
|
+
}
|
|
1295
|
+
|
|
1182
1296
|
function windowAround(lines, index, radius) {
|
|
1183
1297
|
return lines.slice(Math.max(0, index - radius), Math.min(lines.length, index + radius + 1));
|
|
1184
1298
|
}
|
package/src/score.js
CHANGED
|
@@ -114,6 +114,9 @@ function actionFor(result, counts) {
|
|
|
114
114
|
}
|
|
115
115
|
|
|
116
116
|
if (counts.medium > 0) {
|
|
117
|
+
if (rules.has('AWG015')) {
|
|
118
|
+
return 'Review policy drift and approve only expected agentic surfaces.';
|
|
119
|
+
}
|
|
117
120
|
return 'Tighten explicit permissions, artifact boundaries, and suppression policy.';
|
|
118
121
|
}
|
|
119
122
|
|