awguard 1.5.0 → 1.7.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 +40 -0
- package/Dockerfile +15 -0
- package/README.md +230 -10
- package/action.yml +7 -3
- package/docs/assets/terminal-demo.svg +19 -0
- package/docs/comparison.md +168 -0
- package/docs/launch-plan.md +35 -17
- package/docs/market-analysis.md +3 -1
- package/docs/marketplace-listing.md +59 -0
- package/docs/npm-publishing.md +68 -0
- package/docs/release-checklist.md +71 -0
- package/docs/report-gallery.md +166 -0
- package/docs/roadmap.md +41 -7
- package/docs/rule-authoring.md +99 -0
- package/docs/schemas.md +16 -0
- package/docs/setup-recipes.md +199 -0
- package/docs/site/index.html +280 -0
- package/examples/.gitlab-ci.yml +6 -0
- package/examples/.vscode/tasks.json +33 -0
- package/examples/README.md +11 -0
- package/examples/awguard.config.example.json +14 -0
- package/examples/corpus/.cursor/rules/autonomy.mdc +3 -0
- package/examples/corpus/.github/prompts/auto-fix.prompt.md +3 -0
- package/examples/corpus/.github/workflows/agentic-pr-review.yml +20 -0
- package/examples/corpus/.github/workflows/pull-request-target-head.yml +13 -0
- package/examples/corpus/.mcp.json +15 -0
- package/examples/corpus/AGENTS.md +5 -0
- package/examples/corpus/README.md +23 -0
- package/examples/dashboard/README.md +55 -0
- package/examples/dashboard/index.html +313 -0
- package/examples/dashboard/sample-history.json +53 -0
- package/examples/lab/README.md +33 -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/pr-comment-bot.yml +43 -0
- package/examples/pre-commit-config.yaml +8 -0
- package/examples/pull-request-target.yml +1 -1
- package/examples/safe-agent.yml +1 -1
- package/examples/unsafe-agent.yml +1 -1
- package/examples/vscode-extension/README.md +49 -0
- package/examples/vscode-extension/assets/problems-panel.svg +23 -0
- package/examples/vscode-extension/package.json +68 -0
- package/examples/vscode-extension/src/extension.js +116 -0
- package/package.json +3 -1
- package/schemas/awguard.badge.schema.json +25 -0
- package/schemas/awguard.baseline.schema.json +40 -0
- package/schemas/awguard.comparison.schema.json +146 -0
- package/schemas/awguard.config.schema.json +167 -0
- package/schemas/awguard.inventory.schema.json +124 -0
- package/schemas/awguard.report.schema.json +121 -0
- package/src/autofix.js +201 -0
- package/src/badges.js +63 -0
- package/src/baseline.js +77 -0
- package/src/cli.js +281 -5
- package/src/compare.js +166 -0
- package/src/config.js +58 -2
- package/src/demo.js +90 -0
- package/src/doctor.js +189 -0
- package/src/explain.js +147 -0
- package/src/graph.js +6 -1
- package/src/init.js +84 -0
- package/src/inventory.js +11 -0
- package/src/migration.js +10 -0
- package/src/policy-packs.js +99 -0
- package/src/policy-wizard.js +165 -0
- package/src/presets.js +2 -1
- package/src/remediation.js +92 -1
- package/src/reporters.js +92 -5
- package/src/scanner.js +295 -10
- package/src/score.js +3 -0
- package/src/templates.js +132 -0
package/src/demo.js
ADDED
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
import path from 'node:path';
|
|
2
|
+
import { fileURLToPath } from 'node:url';
|
|
3
|
+
import { buildComparison } from './compare.js';
|
|
4
|
+
import { scanWorkflows } from './scanner.js';
|
|
5
|
+
import { calculateScore } from './score.js';
|
|
6
|
+
|
|
7
|
+
export function renderDemoWalkthrough({ packageRoot = getPackageRoot() } = {}) {
|
|
8
|
+
const unsafeRoot = path.join(packageRoot, 'examples', 'lab', 'unsafe');
|
|
9
|
+
const fixedRoot = path.join(packageRoot, 'examples', 'lab', 'fixed');
|
|
10
|
+
const unsafe = scanWorkflows({ root: unsafeRoot });
|
|
11
|
+
const fixed = scanWorkflows({ root: fixedRoot });
|
|
12
|
+
const comparison = buildComparison(toPortableReport(unsafe), toPortableReport(fixed));
|
|
13
|
+
const unsafeScore = calculateScore(unsafe);
|
|
14
|
+
const fixedScore = calculateScore(fixed);
|
|
15
|
+
|
|
16
|
+
const lines = [
|
|
17
|
+
'# Agentic Workflow Guard Demo',
|
|
18
|
+
'',
|
|
19
|
+
'This offline demo scans the built-in vulnerable lab and its fixed version.',
|
|
20
|
+
'',
|
|
21
|
+
'## Commands',
|
|
22
|
+
'',
|
|
23
|
+
'```bash',
|
|
24
|
+
'npx awguard@latest examples/lab/unsafe --format inventory',
|
|
25
|
+
'npx awguard@latest examples/lab/fixed --format inventory',
|
|
26
|
+
'npx awguard@latest examples/lab/fixed --fail-on high',
|
|
27
|
+
'```',
|
|
28
|
+
'',
|
|
29
|
+
'## Before And After',
|
|
30
|
+
'',
|
|
31
|
+
'| Lab | Scanned files | Findings | Highest | AWI score |',
|
|
32
|
+
'| --- | ---: | ---: | --- | --- |',
|
|
33
|
+
`| Unsafe | ${unsafe.scannedFiles.length} | ${unsafe.findings.length} | ${unsafe.summary.highest} | ${unsafeScore.grade} ${unsafeScore.score}/100 |`,
|
|
34
|
+
`| Fixed | ${fixed.scannedFiles.length} | ${fixed.findings.length} | ${fixed.summary.highest} | ${fixedScore.grade} ${fixedScore.score}/100 |`,
|
|
35
|
+
'',
|
|
36
|
+
'## Resolved Risk',
|
|
37
|
+
'',
|
|
38
|
+
`Resolved findings: **${comparison.summary.resolvedFindings}**`,
|
|
39
|
+
`Remaining findings: **${comparison.summary.currentFindings}**`,
|
|
40
|
+
'',
|
|
41
|
+
'## Unsafe Findings',
|
|
42
|
+
''
|
|
43
|
+
];
|
|
44
|
+
|
|
45
|
+
appendFindings(lines, unsafe.findings);
|
|
46
|
+
lines.push(
|
|
47
|
+
'',
|
|
48
|
+
'## Fixed Result',
|
|
49
|
+
'',
|
|
50
|
+
fixed.findings.length === 0
|
|
51
|
+
? 'The fixed lab is clean for the enabled rules.'
|
|
52
|
+
: `The fixed lab still has ${fixed.findings.length} finding(s).`,
|
|
53
|
+
'',
|
|
54
|
+
'Lab docs: `examples/lab/README.md`'
|
|
55
|
+
);
|
|
56
|
+
|
|
57
|
+
return lines.join('\n');
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
function appendFindings(lines, findings) {
|
|
61
|
+
if (findings.length === 0) {
|
|
62
|
+
lines.push('None.');
|
|
63
|
+
return;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
lines.push('| Severity | Rule | Location | Finding |', '| --- | --- | --- | --- |');
|
|
67
|
+
for (const finding of findings) {
|
|
68
|
+
lines.push(
|
|
69
|
+
`| ${escapeMarkdown(finding.severity)} | ${escapeMarkdown(finding.ruleId)} | \`${escapeMarkdown(
|
|
70
|
+
`${finding.file}:${finding.line}`
|
|
71
|
+
)}\` | ${escapeMarkdown(finding.title)} |`
|
|
72
|
+
);
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
function toPortableReport(result) {
|
|
77
|
+
return {
|
|
78
|
+
...result,
|
|
79
|
+
scannedFiles: result.scannedFiles.map((file) => path.relative(result.root, file) || file)
|
|
80
|
+
};
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
function getPackageRoot() {
|
|
84
|
+
const sourceDir = path.dirname(fileURLToPath(import.meta.url));
|
|
85
|
+
return path.resolve(sourceDir, '..');
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
function escapeMarkdown(value) {
|
|
89
|
+
return String(value).replaceAll('|', '\\|');
|
|
90
|
+
}
|
package/src/doctor.js
ADDED
|
@@ -0,0 +1,189 @@
|
|
|
1
|
+
import fs from 'node:fs';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
import process from 'node:process';
|
|
4
|
+
import { fileURLToPath } from 'node:url';
|
|
5
|
+
import { loadConfig } from './config.js';
|
|
6
|
+
import { scanWorkflows } from './scanner.js';
|
|
7
|
+
|
|
8
|
+
const schemaPath = 'schemas/awguard.config.schema.json';
|
|
9
|
+
|
|
10
|
+
export function buildDoctorReport({ root = '.', configPath = '', presets = [], env = process.env } = {}) {
|
|
11
|
+
const checks = [];
|
|
12
|
+
const packageInfo = readPackageInfo();
|
|
13
|
+
const packageRoot = getPackageRoot();
|
|
14
|
+
|
|
15
|
+
addCheck(
|
|
16
|
+
checks,
|
|
17
|
+
nodeMajor(process.version) >= 20 ? 'ok' : 'fail',
|
|
18
|
+
'Node.js runtime',
|
|
19
|
+
`${process.version} detected; AWGuard requires Node.js 20 or newer.`,
|
|
20
|
+
'Install a current Node.js release before running AWGuard.'
|
|
21
|
+
);
|
|
22
|
+
|
|
23
|
+
addCheck(
|
|
24
|
+
checks,
|
|
25
|
+
packageInfo.version ? 'ok' : 'warn',
|
|
26
|
+
'AWGuard package metadata',
|
|
27
|
+
packageInfo.version ? `awguard ${packageInfo.version}` : 'Could not read package.json version.',
|
|
28
|
+
'Reinstall the package if this command is running from a broken checkout.'
|
|
29
|
+
);
|
|
30
|
+
|
|
31
|
+
const absoluteRoot = path.resolve(root);
|
|
32
|
+
if (!fs.existsSync(absoluteRoot)) {
|
|
33
|
+
addCheck(
|
|
34
|
+
checks,
|
|
35
|
+
'fail',
|
|
36
|
+
'Scan target',
|
|
37
|
+
`Target does not exist: ${absoluteRoot}`,
|
|
38
|
+
'Pass a repository directory, workflow file, agent instruction file, or MCP config file.'
|
|
39
|
+
);
|
|
40
|
+
return finish(checks);
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
const targetStat = fs.statSync(absoluteRoot);
|
|
44
|
+
addCheck(
|
|
45
|
+
checks,
|
|
46
|
+
targetStat.isDirectory() || targetStat.isFile() ? 'ok' : 'fail',
|
|
47
|
+
'Scan target',
|
|
48
|
+
`${absoluteRoot} is a ${targetStat.isDirectory() ? 'directory' : targetStat.isFile() ? 'file' : 'special file'}.`,
|
|
49
|
+
'Pass a regular file or directory.'
|
|
50
|
+
);
|
|
51
|
+
|
|
52
|
+
let configResult;
|
|
53
|
+
try {
|
|
54
|
+
configResult = loadConfig({ configPath, root: absoluteRoot, presets });
|
|
55
|
+
addCheck(
|
|
56
|
+
checks,
|
|
57
|
+
'ok',
|
|
58
|
+
'Configuration',
|
|
59
|
+
configResult.path
|
|
60
|
+
? `Loaded ${path.relative(process.cwd(), configResult.path) || configResult.path}.`
|
|
61
|
+
: 'No config file found; using defaults and CLI presets.',
|
|
62
|
+
`Add "$schema": "https://raw.githubusercontent.com/Mughal-Baig/agentic-workflow-guard/main/${schemaPath}" to awguard.config.json for editor validation.`
|
|
63
|
+
);
|
|
64
|
+
} catch (error) {
|
|
65
|
+
addCheck(checks, 'fail', 'Configuration', error.message, 'Fix the config file or run without --config.');
|
|
66
|
+
return finish(checks);
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
addCheck(
|
|
70
|
+
checks,
|
|
71
|
+
fs.existsSync(path.join(packageRoot, schemaPath)) ? 'ok' : 'warn',
|
|
72
|
+
'Config schema',
|
|
73
|
+
`Schema path: ${schemaPath}`,
|
|
74
|
+
'Publish package files with the schemas directory included.'
|
|
75
|
+
);
|
|
76
|
+
|
|
77
|
+
let result;
|
|
78
|
+
try {
|
|
79
|
+
result = scanWorkflows({ root: absoluteRoot, config: configResult.config });
|
|
80
|
+
} catch (error) {
|
|
81
|
+
addCheck(checks, 'fail', 'Scanner smoke test', error.message, 'Check file permissions and scan target contents.');
|
|
82
|
+
return finish(checks);
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
if (result.scannedFiles.length === 0) {
|
|
86
|
+
addCheck(
|
|
87
|
+
checks,
|
|
88
|
+
'warn',
|
|
89
|
+
'Scanner smoke test',
|
|
90
|
+
'No GitHub Actions workflow, agent instruction, or MCP config files were found.',
|
|
91
|
+
'Run AWGuard again after adding agent workflows, AGENTS.md-style files, or MCP configs.'
|
|
92
|
+
);
|
|
93
|
+
} else {
|
|
94
|
+
const findingText =
|
|
95
|
+
result.findings.length === 0
|
|
96
|
+
? 'no findings'
|
|
97
|
+
: `${result.findings.length} finding(s), highest severity ${result.summary.highest}`;
|
|
98
|
+
addCheck(
|
|
99
|
+
checks,
|
|
100
|
+
result.findings.length === 0 ? 'ok' : 'warn',
|
|
101
|
+
'Scanner smoke test',
|
|
102
|
+
`Scanned ${result.scannedFiles.length} file(s); ${findingText}.`,
|
|
103
|
+
'Use --format score, --format inventory, or --format sarif for CI-ready reports.'
|
|
104
|
+
);
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
if (env.GITHUB_ACTIONS === 'true') {
|
|
108
|
+
addCheck(
|
|
109
|
+
checks,
|
|
110
|
+
env.GITHUB_STEP_SUMMARY ? 'ok' : 'warn',
|
|
111
|
+
'GitHub Actions summary',
|
|
112
|
+
env.GITHUB_STEP_SUMMARY
|
|
113
|
+
? 'GITHUB_STEP_SUMMARY is available for job summary output.'
|
|
114
|
+
: 'GITHUB_STEP_SUMMARY is not available in this environment.',
|
|
115
|
+
'Run inside a modern GitHub Actions job to receive the Markdown job summary.'
|
|
116
|
+
);
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
return finish(checks);
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
export function renderDoctorReport(report) {
|
|
123
|
+
const lines = [
|
|
124
|
+
'# Agentic Workflow Guard Doctor',
|
|
125
|
+
'',
|
|
126
|
+
`Status: **${report.status.toUpperCase()}**`,
|
|
127
|
+
'',
|
|
128
|
+
'| Check | Status | Detail |',
|
|
129
|
+
'| --- | --- | --- |'
|
|
130
|
+
];
|
|
131
|
+
|
|
132
|
+
for (const check of report.checks) {
|
|
133
|
+
lines.push(`| ${escapeMarkdown(check.title)} | ${statusLabel(check.status)} | ${escapeMarkdown(check.detail)} |`);
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
const nextSteps = report.checks.filter((check) => check.status !== 'ok' && check.nextStep);
|
|
137
|
+
if (nextSteps.length > 0) {
|
|
138
|
+
lines.push('', '## Next Steps', '');
|
|
139
|
+
for (const check of nextSteps) {
|
|
140
|
+
lines.push(`- ${check.title}: ${check.nextStep}`);
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
return lines.join('\n');
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
function addCheck(checks, status, title, detail, nextStep = '') {
|
|
148
|
+
checks.push({ status, title, detail, nextStep });
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
function finish(checks) {
|
|
152
|
+
return {
|
|
153
|
+
status: checks.some((check) => check.status === 'fail')
|
|
154
|
+
? 'fail'
|
|
155
|
+
: checks.some((check) => check.status === 'warn')
|
|
156
|
+
? 'warn'
|
|
157
|
+
: 'ok',
|
|
158
|
+
checks
|
|
159
|
+
};
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
function nodeMajor(version) {
|
|
163
|
+
const match = String(version).match(/^v?(\d+)/);
|
|
164
|
+
return match ? Number(match[1]) : 0;
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
function readPackageInfo() {
|
|
168
|
+
const packageFile = path.join(getPackageRoot(), 'package.json');
|
|
169
|
+
try {
|
|
170
|
+
return JSON.parse(fs.readFileSync(packageFile, 'utf8'));
|
|
171
|
+
} catch {
|
|
172
|
+
return {};
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
function getPackageRoot() {
|
|
177
|
+
const sourceDir = path.dirname(fileURLToPath(import.meta.url));
|
|
178
|
+
return path.resolve(sourceDir, '..');
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
function statusLabel(status) {
|
|
182
|
+
if (status === 'ok') return 'OK';
|
|
183
|
+
if (status === 'warn') return 'WARN';
|
|
184
|
+
return 'FAIL';
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
function escapeMarkdown(value) {
|
|
188
|
+
return String(value).replaceAll('|', '\\|').replaceAll('\n', ' ');
|
|
189
|
+
}
|
package/src/explain.js
ADDED
|
@@ -0,0 +1,147 @@
|
|
|
1
|
+
import { ruleCatalog } from './scanner.js';
|
|
2
|
+
|
|
3
|
+
const ruleDetails = {
|
|
4
|
+
AWG001: {
|
|
5
|
+
detects: 'User-controlled GitHub issue, pull request, comment, branch, or event text reaching an AI prompt.',
|
|
6
|
+
why: 'The model may treat attacker-supplied text as instructions and use privileged tools or repository context.',
|
|
7
|
+
safePattern: 'Delimit untrusted text, keep the job read-only, and require human review before any write action.'
|
|
8
|
+
},
|
|
9
|
+
AWG002: {
|
|
10
|
+
detects: 'Untrusted GitHub context interpolated directly into shell scripts.',
|
|
11
|
+
why: 'Shell interpolation can turn event text into command execution or argument injection.',
|
|
12
|
+
safePattern: 'Move values into environment variables and reference them with shell quoting.'
|
|
13
|
+
},
|
|
14
|
+
AWG003: {
|
|
15
|
+
detects: 'pull_request_target workflows that check out pull request head code.',
|
|
16
|
+
why: 'Untrusted fork code can run with base-repository privileges.',
|
|
17
|
+
safePattern: 'Use pull_request for untrusted code or keep pull_request_target limited to metadata-only work.'
|
|
18
|
+
},
|
|
19
|
+
AWG004: {
|
|
20
|
+
detects: 'Agent jobs with broad write-capable GitHub token permissions.',
|
|
21
|
+
why: 'Prompt injection becomes much more damaging when the agent can write repository state.',
|
|
22
|
+
safePattern: 'Use contents: read by default and isolate write actions behind a reviewed apply job.'
|
|
23
|
+
},
|
|
24
|
+
AWG005: {
|
|
25
|
+
detects: 'Secrets exposed to workflows driven by untrusted agent input.',
|
|
26
|
+
why: 'An attacker can steer the agent or scripts into revealing credentials.',
|
|
27
|
+
safePattern: 'Keep secrets out of untrusted event workflows and use a separate approved privileged workflow.'
|
|
28
|
+
},
|
|
29
|
+
AWG006: {
|
|
30
|
+
detects: 'Agent command flags that skip approvals or permission prompts.',
|
|
31
|
+
why: 'Autonomous execution removes the human gate that normally stops unsafe tool use.',
|
|
32
|
+
safePattern: 'Require confirmation for file writes, command execution, and repository changes.'
|
|
33
|
+
},
|
|
34
|
+
AWG007: {
|
|
35
|
+
detects: 'Model or agent output flowing into eval, shell, or pipe-to-shell execution.',
|
|
36
|
+
why: 'Model output is data, not trusted code.',
|
|
37
|
+
safePattern: 'Validate structured output with a narrow parser before using it.'
|
|
38
|
+
},
|
|
39
|
+
AWG008: {
|
|
40
|
+
detects: 'Agent workflows without explicit permissions.',
|
|
41
|
+
why: 'Default permissions are easy to misunderstand and can drift as repository settings change.',
|
|
42
|
+
safePattern: 'Declare the minimum required permissions in every agent workflow.'
|
|
43
|
+
},
|
|
44
|
+
AWG009: {
|
|
45
|
+
detects: 'workflow_run jobs that consume artifacts before scripts execute.',
|
|
46
|
+
why: 'Artifacts can carry untrusted content from an earlier workflow into a more privileged job.',
|
|
47
|
+
safePattern: 'Verify artifact provenance, names, checksums, and expected schema before use.'
|
|
48
|
+
},
|
|
49
|
+
AWG010: {
|
|
50
|
+
detects: 'Third-party actions that are not pinned to commit SHAs in sensitive agent workflows.',
|
|
51
|
+
why: 'Mutable tags can change behavior after review.',
|
|
52
|
+
safePattern: 'Pin third-party actions to reviewed commit SHAs.'
|
|
53
|
+
},
|
|
54
|
+
AWG011: {
|
|
55
|
+
detects: 'Suppression comments that are malformed, unjustified, or disallowed by config.',
|
|
56
|
+
why: 'Weak suppressions can hide real agentic workflow risk.',
|
|
57
|
+
safePattern: 'Use narrow rule IDs and a clear reviewed false-positive reason.'
|
|
58
|
+
},
|
|
59
|
+
AWG012: {
|
|
60
|
+
detects: 'Persistent agent instructions that weaken approval, permission, or secret boundaries.',
|
|
61
|
+
why: 'Instruction files are durable context that can affect future agent runs.',
|
|
62
|
+
safePattern: 'Keep repository instructions conservative and explicit about human review.'
|
|
63
|
+
},
|
|
64
|
+
AWG013: {
|
|
65
|
+
detects: 'MCP configs that start mutable packages, unpinned containers, or shell wrappers.',
|
|
66
|
+
why: 'MCP servers expand agent tool authority before runtime scanners can inspect behavior.',
|
|
67
|
+
safePattern: 'Pin packages/images and avoid shell wrappers for project-scoped MCP servers.'
|
|
68
|
+
},
|
|
69
|
+
AWG014: {
|
|
70
|
+
detects: 'MCP configs that hardcode credentials or authorization material.',
|
|
71
|
+
why: 'Committed secrets can be used by anyone with repository access and may be exposed by agents.',
|
|
72
|
+
safePattern: 'Move credentials into prompts, environment variables, or a secret manager.'
|
|
73
|
+
},
|
|
74
|
+
AWG015: {
|
|
75
|
+
detects: 'Agentic files, MCP servers, packages, or commands outside configured policy allowlists.',
|
|
76
|
+
why: 'New agent surfaces should be visible in review before they gain trust.',
|
|
77
|
+
safePattern: 'Approve only reviewed files, servers, packages, and commands.'
|
|
78
|
+
},
|
|
79
|
+
AWG016: {
|
|
80
|
+
detects: 'actions/checkout credentials persisting in elevated agent workflows.',
|
|
81
|
+
why: 'Persisted checkout credentials can give later agent or shell steps repository write access.',
|
|
82
|
+
safePattern: 'Use persist-credentials: false and keep writeback in a separate reviewed job.'
|
|
83
|
+
},
|
|
84
|
+
AWG017: {
|
|
85
|
+
detects: 'Agent jobs with write permissions that commit, tag, publish, or push without a reviewable boundary.',
|
|
86
|
+
why: 'Autonomous writeback can change protected repository state before a maintainer reviews the result.',
|
|
87
|
+
safePattern: 'Push to an isolated branch, open a draft pull request, or upload artifacts for a human apply step.'
|
|
88
|
+
},
|
|
89
|
+
AWG018: {
|
|
90
|
+
detects: 'Untrusted GitHub event text flowing into MCP tool arguments or environment variables.',
|
|
91
|
+
why: 'MCP tools can bridge prompt input into external systems, so injected text can become tool instructions.',
|
|
92
|
+
safePattern: 'Treat event text as untrusted data, sanitize it, and require review before passing it to MCP tools.'
|
|
93
|
+
},
|
|
94
|
+
AWG019: {
|
|
95
|
+
detects: 'MCP package specs outside configured trusted package scopes.',
|
|
96
|
+
why: 'Project-scoped MCP packages expand agent capability, so publisher reputation should be reviewed before trust is granted.',
|
|
97
|
+
safePattern: 'Keep trusted package scopes narrow, pin approved packages, and document the review in policy.'
|
|
98
|
+
}
|
|
99
|
+
};
|
|
100
|
+
|
|
101
|
+
export function renderRuleExplanation(ruleId = '') {
|
|
102
|
+
const normalizedRuleId = String(ruleId || '').toUpperCase();
|
|
103
|
+
if (!normalizedRuleId) return renderRuleIndex();
|
|
104
|
+
|
|
105
|
+
const rule = ruleCatalog[normalizedRuleId];
|
|
106
|
+
if (!rule) {
|
|
107
|
+
throw new Error(`unknown rule id: ${normalizedRuleId}. Use awguard explain to list available rules.`);
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
const details = ruleDetails[normalizedRuleId] || {};
|
|
111
|
+
return [
|
|
112
|
+
`# ${normalizedRuleId}: ${rule.title}`,
|
|
113
|
+
'',
|
|
114
|
+
`Severity: **${rule.severity}**`,
|
|
115
|
+
'',
|
|
116
|
+
`Remediation code: \`${rule.remediationCode}\``,
|
|
117
|
+
'',
|
|
118
|
+
`Detects: ${details.detects || rule.title}`,
|
|
119
|
+
'',
|
|
120
|
+
`Why it matters: ${details.why || 'This pattern can increase agentic workflow risk.'}`,
|
|
121
|
+
'',
|
|
122
|
+
`Safe pattern: ${details.safePattern || rule.suggestion}`,
|
|
123
|
+
'',
|
|
124
|
+
`Suggested fix: ${rule.suggestion}`,
|
|
125
|
+
'',
|
|
126
|
+
'Useful commands:',
|
|
127
|
+
'',
|
|
128
|
+
'```bash',
|
|
129
|
+
'npx awguard@latest . --format inventory',
|
|
130
|
+
'npx awguard@latest . --fix-dry-run',
|
|
131
|
+
'npx awguard@latest . --format sarif --output awguard.sarif --fail-on none',
|
|
132
|
+
'```'
|
|
133
|
+
].join('\n');
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
function renderRuleIndex() {
|
|
137
|
+
const lines = ['# Agentic Workflow Guard Rules', '', '| Rule | Severity | Remediation Code | Title |', '| --- | --- | --- | --- |'];
|
|
138
|
+
for (const [ruleId, rule] of Object.entries(ruleCatalog)) {
|
|
139
|
+
lines.push(`| ${ruleId} | ${rule.severity} | \`${rule.remediationCode}\` | ${escapeMarkdown(rule.title)} |`);
|
|
140
|
+
}
|
|
141
|
+
lines.push('', 'Run `awguard explain AWG001` for details about one rule.');
|
|
142
|
+
return lines.join('\n');
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
function escapeMarkdown(value) {
|
|
146
|
+
return String(value).replaceAll('|', '\\|');
|
|
147
|
+
}
|
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,84 @@
|
|
|
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@v6',
|
|
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
|
+
$schema:
|
|
44
|
+
'https://raw.githubusercontent.com/Mughal-Baig/agentic-workflow-guard/main/schemas/awguard.config.schema.json',
|
|
45
|
+
extends: ['strict'],
|
|
46
|
+
policy: {
|
|
47
|
+
approvedFiles: ['AGENTS.md', '.github/workflows/*'],
|
|
48
|
+
approvedMcpServers: [],
|
|
49
|
+
approvedMcpPackages: [],
|
|
50
|
+
approvedMcpPackageScopes: ['@modelcontextprotocol/'],
|
|
51
|
+
approvedMcpCommands: ['npx', 'node', 'uvx', 'docker']
|
|
52
|
+
},
|
|
53
|
+
suppressions: {
|
|
54
|
+
minimumReasonLength: 20
|
|
55
|
+
}
|
|
56
|
+
},
|
|
57
|
+
null,
|
|
58
|
+
2
|
|
59
|
+
),
|
|
60
|
+
'```',
|
|
61
|
+
'',
|
|
62
|
+
'Adopt without breaking existing CI:',
|
|
63
|
+
'',
|
|
64
|
+
'```bash',
|
|
65
|
+
'npx awguard@latest . --write-baseline awguard.baseline.json --fail-on none',
|
|
66
|
+
'npx awguard@latest . --baseline awguard.baseline.json --fail-on high',
|
|
67
|
+
'```',
|
|
68
|
+
'',
|
|
69
|
+
'Generate useful reports:',
|
|
70
|
+
'',
|
|
71
|
+
'```bash',
|
|
72
|
+
'npx awguard@latest . --format inventory',
|
|
73
|
+
'npx awguard@latest . --format inventory-json --output awguard-inventory.json',
|
|
74
|
+
'npx awguard@latest . --format score',
|
|
75
|
+
'npx awguard@latest . --format badge --output docs/awguard-badge.json',
|
|
76
|
+
'```',
|
|
77
|
+
'',
|
|
78
|
+
'README badge:',
|
|
79
|
+
'',
|
|
80
|
+
'```markdown',
|
|
81
|
+
'[](docs/awguard-badge.json)',
|
|
82
|
+
'```'
|
|
83
|
+
].join('\n');
|
|
84
|
+
}
|
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
|
}
|