awguard 1.6.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 +32 -0
- package/Dockerfile +8 -1
- package/README.md +176 -12
- package/action.yml +5 -1
- package/docs/comparison.md +161 -16
- package/docs/launch-plan.md +12 -2
- package/docs/marketplace-listing.md +19 -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 +32 -2
- 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 +29 -0
- package/examples/.vscode/tasks.json +17 -1
- package/examples/README.md +7 -0
- package/examples/awguard.config.example.json +8 -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 +6 -0
- package/examples/pr-comment-bot.yml +43 -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 +2 -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 +248 -6
- package/src/compare.js +60 -4
- package/src/config.js +31 -2
- package/src/demo.js +90 -0
- package/src/doctor.js +189 -0
- package/src/explain.js +147 -0
- package/src/init.js +4 -1
- package/src/policy-packs.js +99 -0
- package/src/policy-wizard.js +165 -0
- package/src/remediation.js +73 -1
- package/src/reporters.js +86 -3
- package/src/scanner.js +204 -5
- package/src/templates.js +132 -0
package/src/baseline.js
CHANGED
|
@@ -18,6 +18,51 @@ export function applyBaseline(result, baseline) {
|
|
|
18
18
|
};
|
|
19
19
|
}
|
|
20
20
|
|
|
21
|
+
export function reviewBaseline(result, baseline) {
|
|
22
|
+
const currentByFingerprint = new Map(result.findings.map((finding) => [finding.fingerprint, finding]));
|
|
23
|
+
const baselineFindings = baseline.findings || [];
|
|
24
|
+
const known = [];
|
|
25
|
+
const resolved = [];
|
|
26
|
+
|
|
27
|
+
for (const baselineFinding of baselineFindings) {
|
|
28
|
+
const currentFinding = currentByFingerprint.get(baselineFinding.fingerprint);
|
|
29
|
+
if (currentFinding) {
|
|
30
|
+
known.push({
|
|
31
|
+
...baselineFinding,
|
|
32
|
+
current: currentFinding
|
|
33
|
+
});
|
|
34
|
+
} else {
|
|
35
|
+
resolved.push(baselineFinding);
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
const baselineFingerprints = new Set(baselineFindings.map((finding) => finding.fingerprint));
|
|
40
|
+
const newFindings = result.findings.filter((finding) => !baselineFingerprints.has(finding.fingerprint));
|
|
41
|
+
|
|
42
|
+
return {
|
|
43
|
+
summary: {
|
|
44
|
+
baselineFindings: baselineFindings.length,
|
|
45
|
+
known: known.length,
|
|
46
|
+
resolved: resolved.length,
|
|
47
|
+
new: newFindings.length,
|
|
48
|
+
pruneRecommended: resolved.length > 0
|
|
49
|
+
},
|
|
50
|
+
known,
|
|
51
|
+
resolved,
|
|
52
|
+
newFindings
|
|
53
|
+
};
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
export function pruneBaseline(baseline, review) {
|
|
57
|
+
const knownFingerprints = new Set(review.known.map((finding) => finding.fingerprint));
|
|
58
|
+
return {
|
|
59
|
+
...baseline,
|
|
60
|
+
generatedAt: new Date().toISOString(),
|
|
61
|
+
prunedAt: new Date().toISOString(),
|
|
62
|
+
findings: (baseline.findings || []).filter((finding) => knownFingerprints.has(finding.fingerprint))
|
|
63
|
+
};
|
|
64
|
+
}
|
|
65
|
+
|
|
21
66
|
export function createBaseline(result) {
|
|
22
67
|
return {
|
|
23
68
|
version: 1,
|
|
@@ -55,6 +100,38 @@ export function writeBaseline(file, baseline) {
|
|
|
55
100
|
return absoluteFile;
|
|
56
101
|
}
|
|
57
102
|
|
|
103
|
+
export function renderBaselineReview(review, { format = 'text', baselineFile = '' } = {}) {
|
|
104
|
+
if (format === 'json') return JSON.stringify(review, null, 2);
|
|
105
|
+
|
|
106
|
+
const lines = ['Agentic Workflow Guard Baseline Review', ''];
|
|
107
|
+
if (baselineFile) lines.push(`Baseline: ${baselineFile}`);
|
|
108
|
+
lines.push(
|
|
109
|
+
`Known findings: ${review.summary.known}`,
|
|
110
|
+
`Resolved baseline entries: ${review.summary.resolved}`,
|
|
111
|
+
`New findings not in baseline: ${review.summary.new}`,
|
|
112
|
+
''
|
|
113
|
+
);
|
|
114
|
+
|
|
115
|
+
if (review.summary.resolved > 0) {
|
|
116
|
+
lines.push('Resolved baseline entries that can be pruned:', '');
|
|
117
|
+
for (const finding of review.resolved) {
|
|
118
|
+
lines.push(`- ${finding.ruleId} ${finding.file}:${finding.line} ${finding.title}`);
|
|
119
|
+
}
|
|
120
|
+
lines.push('', 'Run with `--prune` to rewrite the baseline without resolved entries.', '');
|
|
121
|
+
} else {
|
|
122
|
+
lines.push('No stale baseline entries found.', '');
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
if (review.summary.new > 0) {
|
|
126
|
+
lines.push('New findings not covered by the baseline:', '');
|
|
127
|
+
for (const finding of review.newFindings) {
|
|
128
|
+
lines.push(`- ${finding.ruleId} ${finding.file}:${finding.line} ${finding.title}`);
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
return lines.join('\n').trimEnd();
|
|
133
|
+
}
|
|
134
|
+
|
|
58
135
|
function summarizeBaseline(findings) {
|
|
59
136
|
return findings.reduce(
|
|
60
137
|
(summary, finding) => {
|
package/src/cli.js
CHANGED
|
@@ -1,16 +1,33 @@
|
|
|
1
1
|
import process from 'node:process';
|
|
2
2
|
import fs from 'node:fs';
|
|
3
3
|
import path from 'node:path';
|
|
4
|
-
import {
|
|
5
|
-
import {
|
|
4
|
+
import { applyAutofixPlan, buildAutofixPlan, renderAutofixPlan } from './autofix.js';
|
|
5
|
+
import {
|
|
6
|
+
applyBaseline,
|
|
7
|
+
createBaseline,
|
|
8
|
+
loadBaseline,
|
|
9
|
+
pruneBaseline,
|
|
10
|
+
renderBaselineReview,
|
|
11
|
+
reviewBaseline,
|
|
12
|
+
writeBaseline
|
|
13
|
+
} from './baseline.js';
|
|
14
|
+
import { renderBadgeSnippets } from './badges.js';
|
|
15
|
+
import { loadReport, renderComparison, renderComparisonJson } from './compare.js';
|
|
16
|
+
import { renderDemoWalkthrough } from './demo.js';
|
|
6
17
|
import { loadConfig } from './config.js';
|
|
18
|
+
import { buildDoctorReport, renderDoctorReport } from './doctor.js';
|
|
19
|
+
import { renderRuleExplanation } from './explain.js';
|
|
7
20
|
import { renderInitGuide } from './init.js';
|
|
21
|
+
import { renderPolicyPack } from './policy-packs.js';
|
|
22
|
+
import { buildPolicyWizard, renderPolicyWizard } from './policy-wizard.js';
|
|
8
23
|
import { renderFixDryRun } from './remediation.js';
|
|
9
24
|
import { scanWorkflows, severityRank } from './scanner.js';
|
|
25
|
+
import { renderTemplates } from './templates.js';
|
|
10
26
|
import {
|
|
11
27
|
renderBadge,
|
|
12
28
|
renderGithubAnnotations,
|
|
13
29
|
renderGraph,
|
|
30
|
+
renderGithubStepSummary,
|
|
14
31
|
renderHtml,
|
|
15
32
|
renderJson,
|
|
16
33
|
renderMarkdown,
|
|
@@ -25,12 +42,28 @@ import {
|
|
|
25
42
|
const HELP = `Agentic Workflow Guard
|
|
26
43
|
|
|
27
44
|
Usage:
|
|
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]
|
|
45
|
+
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] [--fix] [--fail-on none|low|medium|high|critical]
|
|
29
46
|
awguard init
|
|
47
|
+
awguard doctor [path] [--config file] [--preset name]
|
|
48
|
+
awguard explain [AWG###]
|
|
49
|
+
awguard badges [--repo OWNER/REPO] [--branch main] [--badge-file docs/awguard-badge.json] [--site URL]
|
|
50
|
+
awguard demo
|
|
51
|
+
awguard templates [all|github|code-scanning|gitlab|pre-commit|vscode]
|
|
52
|
+
awguard policy-pack [oss|strict|enterprise]
|
|
53
|
+
awguard policy-wizard [path] [--config file] [--preset name] [--dry-run] [--format markdown|json] [--output file]
|
|
54
|
+
awguard baseline-review [path] --baseline file [--config file] [--preset name] [--format text|json] [--prune]
|
|
30
55
|
awguard --compare previous.json current.json
|
|
31
56
|
|
|
32
57
|
Examples:
|
|
33
58
|
awguard init
|
|
59
|
+
awguard doctor
|
|
60
|
+
awguard explain AWG001
|
|
61
|
+
awguard badges --repo OWNER/REPO --site https://OWNER.github.io/REPO/
|
|
62
|
+
awguard demo
|
|
63
|
+
awguard templates github
|
|
64
|
+
awguard policy-pack strict
|
|
65
|
+
awguard policy-wizard . --dry-run
|
|
66
|
+
awguard baseline-review . --baseline awguard.baseline.json
|
|
34
67
|
awguard .
|
|
35
68
|
awguard .mcp.json
|
|
36
69
|
awguard . --config awguard.config.json
|
|
@@ -43,6 +76,7 @@ Examples:
|
|
|
43
76
|
awguard . --format score
|
|
44
77
|
awguard . --format badge --output awguard-badge.json
|
|
45
78
|
awguard . --fix-dry-run
|
|
79
|
+
awguard . --fix
|
|
46
80
|
awguard . --format sarif --output awguard.sarif --fail-on none
|
|
47
81
|
awguard . --write-baseline awguard.baseline.json
|
|
48
82
|
awguard . --baseline awguard.baseline.json --fail-on high
|
|
@@ -56,6 +90,74 @@ export async function runCli(args, env = process.env) {
|
|
|
56
90
|
return;
|
|
57
91
|
}
|
|
58
92
|
|
|
93
|
+
if (args[0] === 'doctor') {
|
|
94
|
+
const options = parseArgs(args.slice(1), env);
|
|
95
|
+
const report = buildDoctorReport({
|
|
96
|
+
root: options.path,
|
|
97
|
+
configPath: options.config,
|
|
98
|
+
presets: options.presets,
|
|
99
|
+
env
|
|
100
|
+
});
|
|
101
|
+
console.log(renderDoctorReport(report));
|
|
102
|
+
if (report.status === 'fail') process.exitCode = 1;
|
|
103
|
+
return;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
if (args[0] === 'explain') {
|
|
107
|
+
console.log(renderRuleExplanation(args[1]));
|
|
108
|
+
return;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
if (args[0] === 'badges') {
|
|
112
|
+
console.log(renderBadgeSnippets(parseBadgeArgs(args.slice(1))));
|
|
113
|
+
return;
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
if (args[0] === 'demo') {
|
|
117
|
+
console.log(renderDemoWalkthrough());
|
|
118
|
+
return;
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
if (args[0] === 'templates') {
|
|
122
|
+
console.log(renderTemplates(args[1] || 'all'));
|
|
123
|
+
return;
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
if (args[0] === 'policy-pack') {
|
|
127
|
+
console.log(renderPolicyPack(args[1] || 'oss'));
|
|
128
|
+
return;
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
if (args[0] === 'policy-wizard') {
|
|
132
|
+
const options = parsePolicyWizardArgs(args.slice(1));
|
|
133
|
+
const loaded = loadConfig({ configPath: options.config, root: options.path, presets: options.presets });
|
|
134
|
+
const result = scanWorkflows({ root: options.path, config: loaded.config });
|
|
135
|
+
const wizard = buildPolicyWizard(result, { existingConfig: loaded.path ? JSON.parse(fs.readFileSync(loaded.path, 'utf8')) : {} });
|
|
136
|
+
const output = renderPolicyWizard(wizard, { format: options.format });
|
|
137
|
+
if (options.output && !options.dryRun) {
|
|
138
|
+
const outputFile = writeOutput(options.output, `${output}\n`);
|
|
139
|
+
console.error(`Wrote ${outputFile}`);
|
|
140
|
+
} else {
|
|
141
|
+
console.log(output);
|
|
142
|
+
}
|
|
143
|
+
return;
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
if (args[0] === 'baseline-review') {
|
|
147
|
+
const options = parseBaselineReviewArgs(args.slice(1));
|
|
148
|
+
if (!options.baseline) throw new Error('baseline-review requires --baseline file');
|
|
149
|
+
const { config } = loadConfig({ configPath: options.config, root: options.path, presets: options.presets });
|
|
150
|
+
const result = scanWorkflows({ root: options.path, config });
|
|
151
|
+
const baseline = loadBaseline(options.baseline);
|
|
152
|
+
const review = reviewBaseline(result, baseline);
|
|
153
|
+
if (options.prune) {
|
|
154
|
+
writeBaseline(options.baseline, pruneBaseline(baseline, review));
|
|
155
|
+
}
|
|
156
|
+
console.log(renderBaselineReview(review, { format: options.format, baselineFile: options.baseline }));
|
|
157
|
+
if (options.prune && options.format !== 'json') console.error(`Pruned baseline ${path.resolve(options.baseline)}`);
|
|
158
|
+
return;
|
|
159
|
+
}
|
|
160
|
+
|
|
59
161
|
const options = parseArgs(args, env);
|
|
60
162
|
|
|
61
163
|
if (options.help) {
|
|
@@ -64,7 +166,9 @@ export async function runCli(args, env = process.env) {
|
|
|
64
166
|
}
|
|
65
167
|
|
|
66
168
|
if (options.compare.length > 0) {
|
|
67
|
-
const
|
|
169
|
+
const previous = loadReport(options.compare[0]);
|
|
170
|
+
const current = loadReport(options.compare[1]);
|
|
171
|
+
const output = options.format === 'json' ? renderComparisonJson(previous, current) : renderComparison(previous, current);
|
|
68
172
|
if (options.output) {
|
|
69
173
|
const outputFile = writeOutput(options.output, output);
|
|
70
174
|
console.error(`Wrote ${outputFile}`);
|
|
@@ -86,15 +190,24 @@ export async function runCli(args, env = process.env) {
|
|
|
86
190
|
console.error(`Wrote baseline ${baselineFile}`);
|
|
87
191
|
}
|
|
88
192
|
|
|
89
|
-
|
|
193
|
+
if (options.fix) {
|
|
194
|
+
const plan = applyAutofixPlan(buildAutofixPlan(result));
|
|
195
|
+
console.log(renderAutofixPlan(plan, { applied: true }));
|
|
196
|
+
return;
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
const output = options.fixDryRun ? renderFixDryRun(result, { autofixPlan: buildAutofixPlan(result) }) : render(result, options.format);
|
|
90
200
|
|
|
201
|
+
let outputFile = '';
|
|
91
202
|
if (options.output) {
|
|
92
|
-
|
|
203
|
+
outputFile = writeOutput(options.output, output);
|
|
93
204
|
console.error(`Wrote ${outputFile}`);
|
|
94
205
|
} else if (output.trim().length > 0) {
|
|
95
206
|
console.log(output);
|
|
96
207
|
}
|
|
97
208
|
|
|
209
|
+
writeGithubStepSummary({ env, result, options, outputFile });
|
|
210
|
+
|
|
98
211
|
const findingsToFailOn = result.findings.filter((finding) => finding.baselineState !== 'known');
|
|
99
212
|
if (shouldFail(findingsToFailOn, options.failOn)) {
|
|
100
213
|
process.exitCode = 1;
|
|
@@ -114,6 +227,7 @@ export function parseArgs(args, env = {}) {
|
|
|
114
227
|
compare: [],
|
|
115
228
|
presets: splitList(readInput(env, 'preset') || readInput(env, 'presets') || ''),
|
|
116
229
|
fixDryRun: readBoolInput(env, 'fix_dry_run') || readBoolInput(env, 'fix-dry-run'),
|
|
230
|
+
fix: readBoolInput(env, 'fix'),
|
|
117
231
|
help: false
|
|
118
232
|
};
|
|
119
233
|
|
|
@@ -156,6 +270,8 @@ export function parseArgs(args, env = {}) {
|
|
|
156
270
|
options.presets.push(...splitList(arg.slice('--preset='.length)));
|
|
157
271
|
} else if (arg === '--fix-dry-run') {
|
|
158
272
|
options.fixDryRun = true;
|
|
273
|
+
} else if (arg === '--fix') {
|
|
274
|
+
options.fix = true;
|
|
159
275
|
} else if (!arg.startsWith('-')) {
|
|
160
276
|
options.path = arg;
|
|
161
277
|
} else {
|
|
@@ -217,6 +333,119 @@ function readBoolInput(env, name) {
|
|
|
217
333
|
return value === 'true' || value === '1' || value === 'yes';
|
|
218
334
|
}
|
|
219
335
|
|
|
336
|
+
function parsePolicyWizardArgs(args) {
|
|
337
|
+
const options = {
|
|
338
|
+
path: '.',
|
|
339
|
+
config: '',
|
|
340
|
+
presets: [],
|
|
341
|
+
dryRun: false,
|
|
342
|
+
format: 'markdown',
|
|
343
|
+
formatSpecified: false,
|
|
344
|
+
output: ''
|
|
345
|
+
};
|
|
346
|
+
|
|
347
|
+
for (let index = 0; index < args.length; index += 1) {
|
|
348
|
+
const arg = args[index];
|
|
349
|
+
if (arg === '--config') {
|
|
350
|
+
options.config = args[++index];
|
|
351
|
+
} else if (arg.startsWith('--config=')) {
|
|
352
|
+
options.config = arg.slice('--config='.length);
|
|
353
|
+
} else if (arg === '--preset') {
|
|
354
|
+
options.presets.push(...splitList(args[++index]));
|
|
355
|
+
} else if (arg.startsWith('--preset=')) {
|
|
356
|
+
options.presets.push(...splitList(arg.slice('--preset='.length)));
|
|
357
|
+
} else if (arg === '--dry-run') {
|
|
358
|
+
options.dryRun = true;
|
|
359
|
+
} else if (arg === '--format') {
|
|
360
|
+
options.format = args[++index];
|
|
361
|
+
options.formatSpecified = true;
|
|
362
|
+
} else if (arg.startsWith('--format=')) {
|
|
363
|
+
options.format = arg.slice('--format='.length);
|
|
364
|
+
options.formatSpecified = true;
|
|
365
|
+
} else if (arg === '--output') {
|
|
366
|
+
options.output = args[++index];
|
|
367
|
+
} else if (arg.startsWith('--output=')) {
|
|
368
|
+
options.output = arg.slice('--output='.length);
|
|
369
|
+
} else if (!arg.startsWith('-')) {
|
|
370
|
+
options.path = arg;
|
|
371
|
+
} else {
|
|
372
|
+
throw new Error(`unknown policy-wizard option: ${arg}`);
|
|
373
|
+
}
|
|
374
|
+
}
|
|
375
|
+
|
|
376
|
+
if (options.output && !options.formatSpecified) options.format = 'json';
|
|
377
|
+
validateEnum('policy-wizard format', options.format, ['markdown', 'json']);
|
|
378
|
+
return options;
|
|
379
|
+
}
|
|
380
|
+
|
|
381
|
+
function parseBaselineReviewArgs(args) {
|
|
382
|
+
const options = {
|
|
383
|
+
path: '.',
|
|
384
|
+
baseline: '',
|
|
385
|
+
config: '',
|
|
386
|
+
presets: [],
|
|
387
|
+
format: 'text',
|
|
388
|
+
prune: false
|
|
389
|
+
};
|
|
390
|
+
|
|
391
|
+
for (let index = 0; index < args.length; index += 1) {
|
|
392
|
+
const arg = args[index];
|
|
393
|
+
if (arg === '--baseline') {
|
|
394
|
+
options.baseline = args[++index];
|
|
395
|
+
} else if (arg.startsWith('--baseline=')) {
|
|
396
|
+
options.baseline = arg.slice('--baseline='.length);
|
|
397
|
+
} else if (arg === '--config') {
|
|
398
|
+
options.config = args[++index];
|
|
399
|
+
} else if (arg.startsWith('--config=')) {
|
|
400
|
+
options.config = arg.slice('--config='.length);
|
|
401
|
+
} else if (arg === '--preset') {
|
|
402
|
+
options.presets.push(...splitList(args[++index]));
|
|
403
|
+
} else if (arg.startsWith('--preset=')) {
|
|
404
|
+
options.presets.push(...splitList(arg.slice('--preset='.length)));
|
|
405
|
+
} else if (arg === '--format') {
|
|
406
|
+
options.format = args[++index];
|
|
407
|
+
} else if (arg.startsWith('--format=')) {
|
|
408
|
+
options.format = arg.slice('--format='.length);
|
|
409
|
+
} else if (arg === '--prune') {
|
|
410
|
+
options.prune = true;
|
|
411
|
+
} else if (!arg.startsWith('-')) {
|
|
412
|
+
options.path = arg;
|
|
413
|
+
} else {
|
|
414
|
+
throw new Error(`unknown baseline-review option: ${arg}`);
|
|
415
|
+
}
|
|
416
|
+
}
|
|
417
|
+
|
|
418
|
+
validateEnum('baseline-review format', options.format, ['text', 'json']);
|
|
419
|
+
return options;
|
|
420
|
+
}
|
|
421
|
+
|
|
422
|
+
function parseBadgeArgs(args) {
|
|
423
|
+
const options = {};
|
|
424
|
+
for (let index = 0; index < args.length; index += 1) {
|
|
425
|
+
const arg = args[index];
|
|
426
|
+
if (arg === '--repo') {
|
|
427
|
+
options.repo = args[++index];
|
|
428
|
+
} else if (arg.startsWith('--repo=')) {
|
|
429
|
+
options.repo = arg.slice('--repo='.length);
|
|
430
|
+
} else if (arg === '--branch') {
|
|
431
|
+
options.branch = args[++index];
|
|
432
|
+
} else if (arg.startsWith('--branch=')) {
|
|
433
|
+
options.branch = arg.slice('--branch='.length);
|
|
434
|
+
} else if (arg === '--badge-file') {
|
|
435
|
+
options.badgeFile = args[++index];
|
|
436
|
+
} else if (arg.startsWith('--badge-file=')) {
|
|
437
|
+
options.badgeFile = arg.slice('--badge-file='.length);
|
|
438
|
+
} else if (arg === '--site') {
|
|
439
|
+
options.site = args[++index];
|
|
440
|
+
} else if (arg.startsWith('--site=')) {
|
|
441
|
+
options.site = arg.slice('--site='.length);
|
|
442
|
+
} else {
|
|
443
|
+
throw new Error(`unknown badges option: ${arg}`);
|
|
444
|
+
}
|
|
445
|
+
}
|
|
446
|
+
return options;
|
|
447
|
+
}
|
|
448
|
+
|
|
220
449
|
function writeOutput(file, output) {
|
|
221
450
|
const absoluteFile = path.resolve(file);
|
|
222
451
|
fs.mkdirSync(path.dirname(absoluteFile), { recursive: true });
|
|
@@ -224,6 +453,19 @@ function writeOutput(file, output) {
|
|
|
224
453
|
return absoluteFile;
|
|
225
454
|
}
|
|
226
455
|
|
|
456
|
+
function writeGithubStepSummary({ env, result, options, outputFile }) {
|
|
457
|
+
if (env.GITHUB_ACTIONS !== 'true' || !env.GITHUB_STEP_SUMMARY) return;
|
|
458
|
+
|
|
459
|
+
try {
|
|
460
|
+
fs.appendFileSync(
|
|
461
|
+
env.GITHUB_STEP_SUMMARY,
|
|
462
|
+
`${renderGithubStepSummary(result, { format: options.format, failOn: options.failOn, outputFile })}\n`
|
|
463
|
+
);
|
|
464
|
+
} catch (error) {
|
|
465
|
+
console.error(`awguard: could not write GitHub job summary: ${error.message}`);
|
|
466
|
+
}
|
|
467
|
+
}
|
|
468
|
+
|
|
227
469
|
function shouldFail(findings, threshold) {
|
|
228
470
|
if (threshold === 'none') return false;
|
|
229
471
|
const thresholdRank = severityRank[threshold];
|
package/src/compare.js
CHANGED
|
@@ -1,5 +1,13 @@
|
|
|
1
1
|
import fs from 'node:fs';
|
|
2
2
|
import path from 'node:path';
|
|
3
|
+
import { classifyScanFile } from './scanner.js';
|
|
4
|
+
|
|
5
|
+
const surfaceLabels = {
|
|
6
|
+
'github-workflow': 'GitHub Actions workflows',
|
|
7
|
+
'agent-context': 'Agent context files',
|
|
8
|
+
'mcp-config': 'MCP configs',
|
|
9
|
+
other: 'Other scanned files'
|
|
10
|
+
};
|
|
3
11
|
|
|
4
12
|
export function loadReport(file) {
|
|
5
13
|
const absoluteFile = path.resolve(file);
|
|
@@ -28,6 +36,10 @@ export function buildComparison(previous, current) {
|
|
|
28
36
|
.filter(([fingerprint]) => !currentFindings.has(fingerprint))
|
|
29
37
|
.map(([, finding]) => finding);
|
|
30
38
|
const unchangedFindings = [...currentFindings.keys()].filter((fingerprint) => previousFindings.has(fingerprint));
|
|
39
|
+
const addedFiles = [...currentFiles].filter((file) => !previousFiles.has(file)).sort();
|
|
40
|
+
const removedFiles = [...previousFiles].filter((file) => !currentFiles.has(file)).sort();
|
|
41
|
+
const addedSurfaces = groupFilesBySurface(addedFiles, current.root || previous.root);
|
|
42
|
+
const removedSurfaces = groupFilesBySurface(removedFiles, previous.root || current.root);
|
|
31
43
|
|
|
32
44
|
return {
|
|
33
45
|
summary: {
|
|
@@ -36,13 +48,17 @@ export function buildComparison(previous, current) {
|
|
|
36
48
|
introducedFindings: introducedFindings.length,
|
|
37
49
|
resolvedFindings: resolvedFindings.length,
|
|
38
50
|
unchangedFindings: unchangedFindings.length,
|
|
39
|
-
addedFiles:
|
|
40
|
-
removedFiles:
|
|
51
|
+
addedFiles: addedFiles.length,
|
|
52
|
+
removedFiles: removedFiles.length,
|
|
53
|
+
addedSurfaces: addedSurfaces.length,
|
|
54
|
+
removedSurfaces: removedSurfaces.length
|
|
41
55
|
},
|
|
42
56
|
introducedFindings,
|
|
43
57
|
resolvedFindings,
|
|
44
|
-
addedFiles
|
|
45
|
-
removedFiles
|
|
58
|
+
addedFiles,
|
|
59
|
+
removedFiles,
|
|
60
|
+
addedSurfaces,
|
|
61
|
+
removedSurfaces
|
|
46
62
|
};
|
|
47
63
|
}
|
|
48
64
|
|
|
@@ -65,6 +81,10 @@ export function renderComparison(previous, current) {
|
|
|
65
81
|
appendFindings(lines, comparison.introducedFindings);
|
|
66
82
|
lines.push('', '## Resolved Findings', '');
|
|
67
83
|
appendFindings(lines, comparison.resolvedFindings);
|
|
84
|
+
lines.push('', '## Added Agentic Surfaces', '');
|
|
85
|
+
appendSurfaces(lines, comparison.addedSurfaces);
|
|
86
|
+
lines.push('', '## Removed Agentic Surfaces', '');
|
|
87
|
+
appendSurfaces(lines, comparison.removedSurfaces);
|
|
68
88
|
lines.push('', '## Added Files', '');
|
|
69
89
|
appendFiles(lines, comparison.addedFiles);
|
|
70
90
|
lines.push('', '## Removed Files', '');
|
|
@@ -73,6 +93,10 @@ export function renderComparison(previous, current) {
|
|
|
73
93
|
return lines.join('\n');
|
|
74
94
|
}
|
|
75
95
|
|
|
96
|
+
export function renderComparisonJson(previous, current) {
|
|
97
|
+
return JSON.stringify(buildComparison(previous, current), null, 2);
|
|
98
|
+
}
|
|
99
|
+
|
|
76
100
|
function appendFindings(lines, findings) {
|
|
77
101
|
if (findings.length === 0) {
|
|
78
102
|
lines.push('None.');
|
|
@@ -101,10 +125,42 @@ function appendFiles(lines, files) {
|
|
|
101
125
|
}
|
|
102
126
|
}
|
|
103
127
|
|
|
128
|
+
function appendSurfaces(lines, surfaces) {
|
|
129
|
+
if (surfaces.length === 0) {
|
|
130
|
+
lines.push('None.');
|
|
131
|
+
return;
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
lines.push('| Surface | Files |');
|
|
135
|
+
lines.push('| --- | ---: |');
|
|
136
|
+
for (const surface of surfaces) {
|
|
137
|
+
lines.push(`| ${escapeMarkdown(surface.label)} | ${surface.files.length} |`);
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
|
|
104
141
|
function mapByFingerprint(findings) {
|
|
105
142
|
return new Map(findings.map((finding) => [finding.fingerprint || `${finding.ruleId}:${finding.file}:${finding.line}`, finding]));
|
|
106
143
|
}
|
|
107
144
|
|
|
145
|
+
function groupFilesBySurface(files, root = process.cwd()) {
|
|
146
|
+
const groups = new Map();
|
|
147
|
+
for (const file of files) {
|
|
148
|
+
const surface = classifyScanFile(file, root || process.cwd());
|
|
149
|
+
if (!groups.has(surface)) {
|
|
150
|
+
groups.set(surface, {
|
|
151
|
+
surface,
|
|
152
|
+
label: surfaceLabels[surface] || surfaceLabels.other,
|
|
153
|
+
files: []
|
|
154
|
+
});
|
|
155
|
+
}
|
|
156
|
+
groups.get(surface).files.push(file);
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
return [...groups.values()]
|
|
160
|
+
.map((group) => ({ ...group, files: group.files.sort() }))
|
|
161
|
+
.sort((a, b) => a.label.localeCompare(b.label));
|
|
162
|
+
}
|
|
163
|
+
|
|
108
164
|
function escapeMarkdown(value) {
|
|
109
165
|
return String(value).replaceAll('|', '\\|');
|
|
110
166
|
}
|
package/src/config.js
CHANGED
|
@@ -34,7 +34,8 @@ export function normalizeConfig(rawConfig = {}, source = 'config') {
|
|
|
34
34
|
return {
|
|
35
35
|
rules: normalizeRules(mergedConfig.rules || {}, source),
|
|
36
36
|
suppressions: normalizeSuppressions(mergedConfig.suppressions || {}, source),
|
|
37
|
-
policy: normalizePolicy(mergedConfig.policy || {}, source)
|
|
37
|
+
policy: normalizePolicy(mergedConfig.policy || {}, source),
|
|
38
|
+
scan: normalizeScan(mergedConfig.scan || {}, source)
|
|
38
39
|
};
|
|
39
40
|
}
|
|
40
41
|
|
|
@@ -53,7 +54,8 @@ function mergePresetConfigs(rawConfig, source) {
|
|
|
53
54
|
return mergeConfigObjects(merged, {
|
|
54
55
|
rules: rawConfig.rules || {},
|
|
55
56
|
suppressions: rawConfig.suppressions || {},
|
|
56
|
-
policy: rawConfig.policy || {}
|
|
57
|
+
policy: rawConfig.policy || {},
|
|
58
|
+
scan: rawConfig.scan || {}
|
|
57
59
|
});
|
|
58
60
|
}
|
|
59
61
|
|
|
@@ -70,6 +72,10 @@ function mergeConfigObjects(base, override) {
|
|
|
70
72
|
policy: {
|
|
71
73
|
...(base.policy || {}),
|
|
72
74
|
...(override.policy || {})
|
|
75
|
+
},
|
|
76
|
+
scan: {
|
|
77
|
+
...(base.scan || {}),
|
|
78
|
+
...(override.scan || {})
|
|
73
79
|
}
|
|
74
80
|
};
|
|
75
81
|
}
|
|
@@ -164,10 +170,33 @@ function normalizePolicy(policy, source) {
|
|
|
164
170
|
approvedFiles: normalizeStringArray(policy.approvedFiles || [], `${source} policy.approvedFiles`),
|
|
165
171
|
approvedMcpServers: normalizeStringArray(policy.approvedMcpServers || [], `${source} policy.approvedMcpServers`),
|
|
166
172
|
approvedMcpPackages: normalizeStringArray(policy.approvedMcpPackages || [], `${source} policy.approvedMcpPackages`),
|
|
173
|
+
approvedMcpPackageScopes: normalizeStringArray(policy.approvedMcpPackageScopes || [], `${source} policy.approvedMcpPackageScopes`),
|
|
167
174
|
approvedMcpCommands: normalizeStringArray(policy.approvedMcpCommands || [], `${source} policy.approvedMcpCommands`)
|
|
168
175
|
};
|
|
169
176
|
}
|
|
170
177
|
|
|
178
|
+
function normalizeScan(scan, source) {
|
|
179
|
+
if (!isObject(scan)) {
|
|
180
|
+
throw new Error(`${source} scan must be an object`);
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
return {
|
|
184
|
+
include: normalizeStringArray(scan.include || [], `${source} scan.include`),
|
|
185
|
+
exclude: normalizeStringArray(scan.exclude || [], `${source} scan.exclude`),
|
|
186
|
+
maxFiles: normalizeOptionalPositiveInteger(scan.maxFiles, `${source} scan.maxFiles`),
|
|
187
|
+
maxFileBytes: normalizeOptionalPositiveInteger(scan.maxFileBytes, `${source} scan.maxFileBytes`)
|
|
188
|
+
};
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
function normalizeOptionalPositiveInteger(value, source) {
|
|
192
|
+
if (value === undefined || value === null || value === '') return undefined;
|
|
193
|
+
const number = Number(value);
|
|
194
|
+
if (!Number.isInteger(number) || number < 1) {
|
|
195
|
+
throw new Error(`${source} must be a positive integer`);
|
|
196
|
+
}
|
|
197
|
+
return number;
|
|
198
|
+
}
|
|
199
|
+
|
|
171
200
|
function normalizeStringArray(value, source) {
|
|
172
201
|
if (!Array.isArray(value)) {
|
|
173
202
|
throw new Error(`${source} must be an array`);
|
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
|
+
}
|