ai-saas-guard 0.44.0 → 0.45.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/README.md +25 -18
- package/action.yml +4 -2
- package/dist/cli.js +5 -0
- package/dist/commands/scan.js +6 -13
- package/dist/context.js +3 -0
- package/dist/hosted/contracts.js +67 -35
- package/dist/hosted/production-adapters.js +2 -0
- package/dist/hosted/worker.js +7 -4
- package/dist/report/comment.js +26 -14
- package/dist/report/findings.d.ts +1 -1
- package/dist/report/findings.js +16 -1
- package/dist/report/launchGate.js +9 -4
- package/dist/report/markdown.js +131 -111
- package/dist/report/presentation.d.ts +8 -0
- package/dist/report/presentation.js +151 -0
- package/dist/report/sarif.js +4 -3
- package/dist/report/summary.js +52 -93
- package/dist/report/terminal.js +79 -71
- package/dist/rules/catalog.js +14 -0
- package/dist/scanners/actions.js +7 -2
- package/dist/scanners/apiRoutes.js +36 -1
- package/dist/scanners/gitDiff.d.ts +1 -0
- package/dist/scanners/gitDiff.js +40 -3
- package/dist/scanners/mcp.js +31 -1
- package/dist/scanners/secrets.js +4 -4
- package/dist/scanners/stripe.js +27 -5
- package/dist/scanners/supabase.js +92 -1
- package/dist/stackInventory.js +22 -11
- package/dist/utils/files.d.ts +1 -1
- package/dist/utils/files.js +44 -12
- package/docs/CODEX_HANDOFF.md +18 -4
- package/docs/CODEX_RECENT_CHANGES.md +40 -1
- package/docs/CODEX_STATE.md +37 -6
- package/docs/CODEX_TODO.md +34 -9
- package/docs/README.zh-CN.md +24 -17
- package/docs/demo-terminal-output.txt +23 -21
- package/docs/demo-terminal-screenshot.svg +16 -13
- package/docs/github-action.md +1 -1
- package/docs/project-handoff.md +12 -4
- package/docs/release-quality-audit-2026-07-15.md +172 -0
- package/docs/rules.md +2 -0
- package/docs/sample-launch-report.md +80 -86
- package/hosted/cloudflare-worker/README.md +6 -2
- package/hosted/cloudflare-worker/src/index.js +250 -22
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -124,23 +124,30 @@ The demo command uses packaged public fixtures: `examples/demo-risky-saas` curre
|
|
|
124
124
|
The report is designed to be read before launch or before merging an AI-heavy PR. A longer copy-paste example is in [docs/sample-launch-report.md](docs/sample-launch-report.md).
|
|
125
125
|
|
|
126
126
|
```text
|
|
127
|
-
ai-saas-guard
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
127
|
+
ai-saas-guard | SCAN SUMMARY
|
|
128
|
+
----------------------------------------
|
|
129
|
+
Target /path/to/your-saas
|
|
130
|
+
Launch gate blocked: critical launch-readiness findings need review before inviting users
|
|
131
|
+
Findings 20 findings | 2 critical | 6 high | 8 medium | 3 low | 1 info
|
|
132
|
+
Decision Can a real user get access they should not have? Review auth, tenant ownership, Supabase RLS, webhook entitlement, and data mutation findings first.
|
|
133
|
+
|
|
134
|
+
TOP RISKS
|
|
135
|
+
1. CRITICAL stripe.webhook.missing-signature at app/api/stripe/webhook/route.ts:1 - Stripe webhook does not verify the Stripe signature
|
|
136
|
+
2. CRITICAL supabase.rls.broad-policy at supabase/migrations/001_accounts.sql:10 - Broad Supabase RLS policy on public.accounts
|
|
137
|
+
3. HIGH silent-success.swallowed-error at app/api/billing/checkout/route.ts:4 - Catch block may turn upstream failure into success: app/api/billing/checkout/route.ts
|
|
138
|
+
|
|
139
|
+
MANUAL PROOF
|
|
140
|
+
1. Send a request without a valid Stripe signature and confirm the handler rejects it before changing entitlement state.
|
|
141
|
+
2. Run the generated two-account IDOR test and confirm User B cannot read, update, or delete User A resources.
|
|
142
|
+
3. Force the upstream API, auth provider, billing provider, or database call to fail and confirm the route returns an error or disclosed degraded mode, not a fake success.
|
|
143
|
+
|
|
144
|
+
NEXT STEPS
|
|
145
|
+
1. Fix critical and high trust-boundary findings first: auth/session, billing/webhook, tenant data, and silent-success paths.
|
|
146
|
+
2. Run the manual proof steps above in staging and confirm each risky path fails closed.
|
|
147
|
+
3. Treat low and info deploy/CI hygiene hints as cleanup after critical, high, and medium launch paths are understood.
|
|
148
|
+
|
|
149
|
+
FULL REPORT
|
|
150
|
+
Rerun without --summary, or use --json, --sarif, or --markdown where supported.
|
|
144
151
|
```
|
|
145
152
|
|
|
146
153
|
## What You Get
|
|
@@ -338,7 +345,7 @@ node dist/cli.js pr-risk --root /path/to/your-saas --base origin/main --json
|
|
|
338
345
|
node dist/cli.js pr-risk --root /path/to/your-saas --base origin/main --markdown
|
|
339
346
|
```
|
|
340
347
|
|
|
341
|
-
If `--base` cannot be resolved, `pr-risk` emits `pr-risk.diff-unavailable` instead of silently reporting a clean or empty diff. In GitHub Actions, use `actions/checkout` with `fetch-depth: 0` when you need merge-base comparison against `origin/main`.
|
|
348
|
+
If `--base` cannot be resolved, `pr-risk` emits `pr-risk.diff-unavailable` instead of silently reporting a clean or empty diff. `--base` accepts branch, tag, full ref, or commit SHA names only (letters, numbers, dots, underscores, slashes, hyphens); revision expressions such as `HEAD~3` are rejected so option-like or malformed values never reach git argument parsing. In GitHub Actions, use `actions/checkout` with `fetch-depth: 0` when you need merge-base comparison against `origin/main`.
|
|
342
349
|
|
|
343
350
|
## Commands
|
|
344
351
|
|
package/action.yml
CHANGED
|
@@ -12,7 +12,7 @@ inputs:
|
|
|
12
12
|
required: false
|
|
13
13
|
default: ${{ github.workspace }}
|
|
14
14
|
format:
|
|
15
|
-
description: "Output format: terminal, json, sarif, markdown, or
|
|
15
|
+
description: "Output format: terminal, json, sarif, markdown, summary, or comment. Use summary for first-run CLI output, markdown for PR summaries, sarif for code scanning, and comment for PR-comment-ready markdown (pr-risk)."
|
|
16
16
|
required: false
|
|
17
17
|
default: terminal
|
|
18
18
|
fail-on:
|
|
@@ -67,7 +67,7 @@ runs:
|
|
|
67
67
|
esac
|
|
68
68
|
|
|
69
69
|
case "${INPUT_FORMAT}" in
|
|
70
|
-
terminal|json|sarif|markdown|summary) ;;
|
|
70
|
+
terminal|json|sarif|markdown|summary|comment) ;;
|
|
71
71
|
*)
|
|
72
72
|
echo "Invalid format input: ${INPUT_FORMAT}" >&2
|
|
73
73
|
exit 2
|
|
@@ -102,6 +102,8 @@ runs:
|
|
|
102
102
|
args+=("--markdown")
|
|
103
103
|
elif [ "${INPUT_FORMAT}" = "summary" ]; then
|
|
104
104
|
args+=("--summary")
|
|
105
|
+
elif [ "${INPUT_FORMAT}" = "comment" ]; then
|
|
106
|
+
args+=("--format" "comment")
|
|
105
107
|
fi
|
|
106
108
|
|
|
107
109
|
if [ "${INPUT_FAIL_ON}" != "none" ]; then
|
package/dist/cli.js
CHANGED
|
@@ -8,6 +8,7 @@ import { formatMarkdownReport } from "./report/markdown.js";
|
|
|
8
8
|
import { formatSarifReport } from "./report/sarif.js";
|
|
9
9
|
import { formatSummaryReport } from "./report/summary.js";
|
|
10
10
|
import { formatTerminalReport } from "./report/terminal.js";
|
|
11
|
+
import { isSafeGitBaseRef } from "./scanners/gitDiff.js";
|
|
11
12
|
async function main(argv) {
|
|
12
13
|
const args = parseArgs(argv);
|
|
13
14
|
if (!args.command || args.command === "help") {
|
|
@@ -120,6 +121,9 @@ function parseArgs(argv) {
|
|
|
120
121
|
const value = argv[index + 1];
|
|
121
122
|
if (!value)
|
|
122
123
|
throw new Error("--base requires a branch or ref");
|
|
124
|
+
if (!isSafeGitBaseRef(value)) {
|
|
125
|
+
throw new Error("--base must be a safe branch or ref using letters, numbers, dots, underscores, slashes, or hyphens");
|
|
126
|
+
}
|
|
123
127
|
result.base = value;
|
|
124
128
|
index += 1;
|
|
125
129
|
continue;
|
|
@@ -202,6 +206,7 @@ Defaults:
|
|
|
202
206
|
- first-run launch summary with --summary
|
|
203
207
|
- project config auto-loaded from .ai-saas-guard.json when present
|
|
204
208
|
- --doctor is deprecated: the Supabase RLS doctor section is always included in check-supabase output; the flag is still accepted but has no effect
|
|
209
|
+
- --base accepts branch, tag, full ref, or commit SHA names only (letters, numbers, dots, underscores, slashes, hyphens); revisions like HEAD~3 are rejected
|
|
205
210
|
`;
|
|
206
211
|
}
|
|
207
212
|
main(process.argv.slice(2)).then((code) => {
|
package/dist/commands/scan.js
CHANGED
|
@@ -16,7 +16,12 @@ export async function scanRepository(options) {
|
|
|
16
16
|
scanSecrets(context),
|
|
17
17
|
scanNextPublicEnv(context),
|
|
18
18
|
checkStripe(context),
|
|
19
|
-
|
|
19
|
+
// Never gate on the stack inventory: it only recognizes standard
|
|
20
|
+
// layouts (supabase/ paths, package deps, policy syntax), so SQL
|
|
21
|
+
// migrations in non-standard paths would silently skip every Supabase
|
|
22
|
+
// rule while the scan still reported clear. checkSupabase already
|
|
23
|
+
// returns an empty report when it finds no Supabase context.
|
|
24
|
+
checkSupabase(context),
|
|
20
25
|
checkMcp(context),
|
|
21
26
|
scanApiRoutes(context),
|
|
22
27
|
scanDeployConfig(context),
|
|
@@ -35,15 +40,3 @@ export async function scanRepository(options) {
|
|
|
35
40
|
...actionsReport.findings
|
|
36
41
|
]), { stackInventory, fileCollection: context.fileCollection });
|
|
37
42
|
}
|
|
38
|
-
function createSkippedSupabaseReport(rootDir) {
|
|
39
|
-
return createReport("check-supabase", rootDir, [], {
|
|
40
|
-
riskyTables: [],
|
|
41
|
-
riskyPolicies: [],
|
|
42
|
-
manualAuthorizationTest: [],
|
|
43
|
-
doctor: {
|
|
44
|
-
staticChecks: [],
|
|
45
|
-
twoAccountVerificationSteps: [],
|
|
46
|
-
sqlCookbook: []
|
|
47
|
-
}
|
|
48
|
-
});
|
|
49
|
-
}
|
package/dist/context.js
CHANGED
|
@@ -1,6 +1,9 @@
|
|
|
1
1
|
import { collectTextFilesWithDiagnostics } from "./utils/files.js";
|
|
2
2
|
export async function createScanContext(rootDir) {
|
|
3
3
|
const collection = await collectTextFilesWithDiagnostics(rootDir);
|
|
4
|
+
if (collection.diagnostics.unreadableDirectories.includes(".")) {
|
|
5
|
+
throw new Error(`Could not read scan root: ${rootDir}`);
|
|
6
|
+
}
|
|
4
7
|
const { files } = collection;
|
|
5
8
|
const filesByPath = new Map(files.map((file) => [file.path, file]));
|
|
6
9
|
return {
|
package/dist/hosted/contracts.js
CHANGED
|
@@ -477,7 +477,7 @@ export function createCompactHostedReport(input) {
|
|
|
477
477
|
export function createHostedCheckRunSummary(input) {
|
|
478
478
|
const { report } = input;
|
|
479
479
|
const totalFindings = getHostedReportFindingTotal(report);
|
|
480
|
-
const localCliCommand = `npx ai-saas-guard@${report.scannerVersion} pr-risk --root . --base ${report.baseSha} --json`;
|
|
480
|
+
const localCliCommand = `npx ai-saas-guard@${safeHostedCliVersion(report.scannerVersion)} pr-risk --root . --base ${safeHostedBaseSha(report.baseSha)} --json`;
|
|
481
481
|
const conclusion = resolveCheckRunConclusion(report, input.failOnSeverity);
|
|
482
482
|
const launchGate = hostedLaunchGateVerdict(report);
|
|
483
483
|
return {
|
|
@@ -486,7 +486,7 @@ export function createHostedCheckRunSummary(input) {
|
|
|
486
486
|
output: {
|
|
487
487
|
title: formatCheckRunTitle(totalFindings, conclusion, input.failOnSeverity),
|
|
488
488
|
summary: [
|
|
489
|
-
`Launch
|
|
489
|
+
`Launch gate: ${launchGate}. ${totalFindings} finding${totalFindings === 1 ? "" : "s"}.`,
|
|
490
490
|
"Review task: inspect risk areas and files before merge.",
|
|
491
491
|
"Manual proof: prove changed auth, billing, data, deploy, or tests fail closed.",
|
|
492
492
|
"Boundary: selected repository only; not an AI reviewer, pentest, full audit, or certification."
|
|
@@ -1146,54 +1146,61 @@ function formatCheckRunMarkdown(report, conclusion, localCliCommand, launchGate)
|
|
|
1146
1146
|
const filesToReview = getHostedCheckRunFiles(report);
|
|
1147
1147
|
const findingLines = report.evidence.length === 0
|
|
1148
1148
|
? ["No findings in the compact hosted report."]
|
|
1149
|
-
: [
|
|
1150
|
-
|
|
1151
|
-
|
|
1152
|
-
|
|
1153
|
-
];
|
|
1149
|
+
: report.evidence.flatMap((finding, index) => [
|
|
1150
|
+
`${index + 1}. **${escapeHostedMarkdownInline(finding.severity.toUpperCase())}** ${hostedMarkdownCode(finding.ruleId)}`,
|
|
1151
|
+
` - Location: ${hostedMarkdownCode(formatFindingLocation(finding))}`
|
|
1152
|
+
]);
|
|
1154
1153
|
return [
|
|
1155
|
-
"
|
|
1154
|
+
"## AI SaaS Guard",
|
|
1156
1155
|
"",
|
|
1157
|
-
|
|
1158
|
-
"Manual proof: prove changed auth, billing, data, deploy, or tests fail closed.",
|
|
1159
|
-
"Boundary: selected repository only; not an AI reviewer, pentest, full audit, or certification.",
|
|
1156
|
+
`**Launch gate:** ${escapeHostedMarkdownInline(launchGate)}`,
|
|
1160
1157
|
"",
|
|
1161
|
-
|
|
1162
|
-
`Conclusion: ${conclusion}`,
|
|
1163
|
-
`Local CLI: \`${localCliCommand}\``,
|
|
1164
|
-
`Retention: compact report ${report.retentionDays} days; no raw source, diffs, secrets, or customer payloads.`,
|
|
1158
|
+
`**Conclusion:** ${escapeHostedMarkdownInline(conclusion)}`,
|
|
1165
1159
|
"",
|
|
1166
|
-
|
|
1167
|
-
...(categories.length === 0 ? ["- None"] : categories.map((category) => `- ${category}`)),
|
|
1160
|
+
`**Review categories:** ${categories.length === 0 ? "None" : categories.map(hostedMarkdownCode).join(", ")}`,
|
|
1168
1161
|
"",
|
|
1169
|
-
"
|
|
1170
|
-
"
|
|
1171
|
-
"- Reproduce locally with the CLI command above.",
|
|
1172
|
-
"- Prove changed auth, billing, data, deploy, or tests fail closed.",
|
|
1162
|
+
"> Review task: inspect the risk areas and files below before merge.",
|
|
1163
|
+
"> Manual proof: prove changed auth, billing, data, deploy, or tests fail closed.",
|
|
1173
1164
|
"",
|
|
1174
|
-
|
|
1165
|
+
`**Reproduce locally:** ${hostedMarkdownCode(localCliCommand)}`,
|
|
1166
|
+
"",
|
|
1167
|
+
"**Scope:** Selected repository only. No raw source, diffs, secrets, installation tokens, or customer payloads are included.",
|
|
1168
|
+
"",
|
|
1169
|
+
"### Review First",
|
|
1175
1170
|
...(riskAreas.length === 0
|
|
1176
|
-
? ["
|
|
1177
|
-
: riskAreas.map((area) =>
|
|
1171
|
+
? ["No risk areas in the compact hosted report."]
|
|
1172
|
+
: riskAreas.map((area, index) => `${index + 1}. **${escapeHostedMarkdownInline(area.name)}** - ${area.count} finding(s). ${escapeHostedMarkdownInline(area.proof)}`)),
|
|
1173
|
+
"",
|
|
1174
|
+
"### Files",
|
|
1175
|
+
...(filesToReview.length === 0
|
|
1176
|
+
? ["No files in the review queue."]
|
|
1177
|
+
: filesToReview.map((file, index) => `${index + 1}. ${hostedMarkdownCode(file)}`)),
|
|
1178
1178
|
"",
|
|
1179
|
-
"
|
|
1179
|
+
"### Findings",
|
|
1180
|
+
...findingLines,
|
|
1181
|
+
"",
|
|
1182
|
+
"### Manual Proof",
|
|
1183
|
+
"1. Inspect each listed file locally before release or merge.",
|
|
1184
|
+
"2. Reproduce the result with the local CLI command below.",
|
|
1185
|
+
"3. Prove changed auth, billing, data, deploy, or tests fail closed.",
|
|
1186
|
+
"",
|
|
1187
|
+
"### Launch Decision Queue",
|
|
1180
1188
|
"- Can a real user get access they should not have?",
|
|
1181
1189
|
"- Can the app claim success when something failed?",
|
|
1182
1190
|
"- Can launch infrastructure do too much damage?",
|
|
1183
1191
|
"",
|
|
1184
|
-
"Summary
|
|
1192
|
+
"### Scan Summary",
|
|
1185
1193
|
...severityOrder.map((severity) => `- ${capitalize(severity)}: ${report.summaryCounts[severity] ?? 0}`),
|
|
1186
1194
|
"",
|
|
1187
|
-
"
|
|
1188
|
-
...(filesToReview.length === 0 ? ["- None"] : filesToReview.map((file) => `- ${file}`)),
|
|
1189
|
-
"",
|
|
1190
|
-
"Launch-boundary reviewer checklist:",
|
|
1195
|
+
"### Reviewer Checklist",
|
|
1191
1196
|
"- What changed at the launch boundary?",
|
|
1192
1197
|
"- Why this auth billing data or deploy decision is safe?",
|
|
1193
1198
|
"- What manual test proves it fails closed?",
|
|
1194
1199
|
"",
|
|
1195
|
-
"
|
|
1196
|
-
|
|
1200
|
+
"### Scope And Privacy",
|
|
1201
|
+
"- Boundary: selected repository only; not an AI reviewer, pentest, full audit, or certification.",
|
|
1202
|
+
`- Retention: compact report ${report.retentionDays} days.`,
|
|
1203
|
+
"- No raw source, diffs, secrets, installation tokens, or customer payloads are included."
|
|
1197
1204
|
].join("\n");
|
|
1198
1205
|
}
|
|
1199
1206
|
function truncateMarkdown(markdown, maxMarkdownChars) {
|
|
@@ -1207,7 +1214,11 @@ function truncateMarkdown(markdown, maxMarkdownChars) {
|
|
|
1207
1214
|
if (maxChars <= suffix.length) {
|
|
1208
1215
|
return suffix.slice(0, maxChars);
|
|
1209
1216
|
}
|
|
1210
|
-
|
|
1217
|
+
const availableChars = maxChars - suffix.length;
|
|
1218
|
+
const candidate = markdown.slice(0, availableChars);
|
|
1219
|
+
const lastLineBreak = candidate.lastIndexOf("\n");
|
|
1220
|
+
const safePrefix = lastLineBreak > 0 ? candidate.slice(0, lastLineBreak) : candidate;
|
|
1221
|
+
return `${safePrefix.trimEnd()}${suffix}`;
|
|
1211
1222
|
}
|
|
1212
1223
|
function severityRank(severity) {
|
|
1213
1224
|
const normalized = severity.toLowerCase();
|
|
@@ -1312,8 +1323,29 @@ function categoryForRuleId(ruleId) {
|
|
|
1312
1323
|
function getHostedCheckRunFiles(report) {
|
|
1313
1324
|
return [...new Set(report.evidence.map((finding) => finding.file))].slice(0, 10);
|
|
1314
1325
|
}
|
|
1315
|
-
function
|
|
1316
|
-
return value
|
|
1326
|
+
function escapeHostedMarkdownInline(value) {
|
|
1327
|
+
return normalizeHostedMarkdownInline(value)
|
|
1328
|
+
.replaceAll("&", "&")
|
|
1329
|
+
.replaceAll("<", "<")
|
|
1330
|
+
.replaceAll(">", ">")
|
|
1331
|
+
.replace(/([\\`*_[\]])/g, "\\$1")
|
|
1332
|
+
.replaceAll("|", "\\|");
|
|
1333
|
+
}
|
|
1334
|
+
function hostedMarkdownCode(value) {
|
|
1335
|
+
return `\`${normalizeHostedMarkdownInline(value).replaceAll("`", "'")}\``;
|
|
1336
|
+
}
|
|
1337
|
+
function normalizeHostedMarkdownInline(value) {
|
|
1338
|
+
return value
|
|
1339
|
+
.replace(/[\u0000-\u0008\u000b\u000c\u000e-\u001f\u007f-\u009f]/g, "")
|
|
1340
|
+
.replace(/[\r\n]+/g, " ")
|
|
1341
|
+
.replace(/\s+/g, " ")
|
|
1342
|
+
.trim();
|
|
1343
|
+
}
|
|
1344
|
+
function safeHostedCliVersion(value) {
|
|
1345
|
+
return /^\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?$/.test(value) ? value : "latest";
|
|
1346
|
+
}
|
|
1347
|
+
function safeHostedBaseSha(value) {
|
|
1348
|
+
return /^[a-f0-9]{40}$/i.test(value) ? value : "<base-sha>";
|
|
1317
1349
|
}
|
|
1318
1350
|
function capitalize(value) {
|
|
1319
1351
|
return `${value.charAt(0).toUpperCase()}${value.slice(1)}`;
|
|
@@ -198,6 +198,8 @@ function safeGitHubApiBaseUrl(apiBaseUrl) {
|
|
|
198
198
|
url.password ||
|
|
199
199
|
url.search ||
|
|
200
200
|
url.hash ||
|
|
201
|
+
url.port ||
|
|
202
|
+
url.hostname.toLowerCase() !== "api.github.com" ||
|
|
201
203
|
!isRootPath(url.pathname) ||
|
|
202
204
|
isUnsafeHostedHostname(url.hostname)) {
|
|
203
205
|
return undefined;
|
package/dist/hosted/worker.js
CHANGED
|
@@ -462,10 +462,13 @@ function parseRepositoryFullName(value) {
|
|
|
462
462
|
const match = /^([A-Za-z0-9_.-]+)\/([A-Za-z0-9_.-]+)$/.exec(value);
|
|
463
463
|
if (!match)
|
|
464
464
|
return undefined;
|
|
465
|
-
|
|
466
|
-
|
|
467
|
-
|
|
468
|
-
|
|
465
|
+
const owner = match[1];
|
|
466
|
+
const repo = match[2];
|
|
467
|
+
// Reject path-traversal segments: `.`/`..` in the clone URL path could
|
|
468
|
+
// resolve outside the intended repository location.
|
|
469
|
+
if (owner === "." || owner === ".." || repo === "." || repo === "..")
|
|
470
|
+
return undefined;
|
|
471
|
+
return { owner, repo };
|
|
469
472
|
}
|
|
470
473
|
function normalizeSafeCloneBaseUrl(value) {
|
|
471
474
|
try {
|
package/dist/report/comment.js
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { launchGateVerdict, nextSteps, reviewFirst, trustStatement } from "./launchGate.js";
|
|
2
|
+
import { escapeMarkdownInline, formatScanCoverage, markdownCode } from "./presentation.js";
|
|
2
3
|
/**
|
|
3
4
|
* `--format comment` renders a compact PR-comment-ready markdown body.
|
|
4
5
|
* It is optimized for `pr-risk` (paste into a PR review), but works for
|
|
@@ -9,14 +10,27 @@ export function formatCommentReport(report) {
|
|
|
9
10
|
return `${formatPrRiskComment(report)}\n`;
|
|
10
11
|
return `${formatGenericComment(report)}\n`;
|
|
11
12
|
}
|
|
13
|
+
function appendCoverageWarning(lines, report) {
|
|
14
|
+
const coverage = formatScanCoverage(report);
|
|
15
|
+
if (coverage) {
|
|
16
|
+
lines.push("");
|
|
17
|
+
lines.push(`**Coverage:** ${escapeMarkdownInline(coverage)}`);
|
|
18
|
+
}
|
|
19
|
+
}
|
|
12
20
|
function formatPrRiskComment(report) {
|
|
13
21
|
const lines = [];
|
|
14
22
|
lines.push("## \u{1F6E1}\uFE0F ai-saas-guard PR risk");
|
|
15
23
|
lines.push("");
|
|
16
|
-
lines.push(`**Verdict:** ${
|
|
24
|
+
lines.push(`**Verdict:** ${escapeMarkdownInline(launchGateVerdict(report))}`);
|
|
25
|
+
appendCoverageWarning(lines, report);
|
|
26
|
+
const diffUnavailable = report.findings.filter((finding) => finding.ruleId === "pr-risk.diff-unavailable");
|
|
27
|
+
if (diffUnavailable.length > 0) {
|
|
28
|
+
lines.push("");
|
|
29
|
+
lines.push(`> \u26A0\uFE0F ${escapeMarkdownInline(diffUnavailable[0].title)}. ${escapeMarkdownInline(diffUnavailable[0].suggestedFix)}`);
|
|
30
|
+
}
|
|
17
31
|
if (report.categories.length > 0) {
|
|
18
32
|
lines.push("");
|
|
19
|
-
lines.push(`**This PR touches:** ${report.categories.map((category) =>
|
|
33
|
+
lines.push(`**This PR touches:** ${report.categories.map((category) => markdownCode(category)).join(", ")}`);
|
|
20
34
|
}
|
|
21
35
|
const topFiles = report.topRiskyFiles.slice(0, 3);
|
|
22
36
|
lines.push("");
|
|
@@ -28,7 +42,7 @@ function formatPrRiskComment(report) {
|
|
|
28
42
|
else {
|
|
29
43
|
lines.push("");
|
|
30
44
|
for (const [index, file] of topFiles.entries()) {
|
|
31
|
-
lines.push(`${index + 1}.
|
|
45
|
+
lines.push(`${index + 1}. ${markdownCode(file.path)} — ${file.categories.map((category) => markdownCode(category)).join(", ")} (+${file.added}/-${file.removed})`);
|
|
32
46
|
}
|
|
33
47
|
}
|
|
34
48
|
const verification = report.requiredTests.length > 0 ? report.requiredTests : report.reviewChecklist;
|
|
@@ -37,7 +51,7 @@ function formatPrRiskComment(report) {
|
|
|
37
51
|
lines.push("### \u2705 Verify before merge");
|
|
38
52
|
lines.push("");
|
|
39
53
|
for (const item of verification.slice(0, 4)) {
|
|
40
|
-
lines.push(`- ${
|
|
54
|
+
lines.push(`- ${escapeMarkdownInline(item)}`);
|
|
41
55
|
}
|
|
42
56
|
}
|
|
43
57
|
const split = report.suggestedSplit.filter((item) => !/no split required/i.test(item));
|
|
@@ -46,36 +60,34 @@ function formatPrRiskComment(report) {
|
|
|
46
60
|
lines.push("### \u2702\uFE0F Suggested split");
|
|
47
61
|
lines.push("");
|
|
48
62
|
for (const item of split.slice(0, 3)) {
|
|
49
|
-
lines.push(`- ${
|
|
63
|
+
lines.push(`- ${escapeMarkdownInline(item)}`);
|
|
50
64
|
}
|
|
51
65
|
}
|
|
52
66
|
lines.push("");
|
|
53
67
|
lines.push("---");
|
|
54
|
-
lines.push(`<sub>Posted by \`ai-saas-guard pr-risk --format comment\`. ${trustStatement().map(
|
|
68
|
+
lines.push(`<sub>Posted by \`ai-saas-guard pr-risk --format comment\`. ${trustStatement().map(escapeMarkdownInline).join(" ")}</sub>`);
|
|
55
69
|
return lines.join("\n");
|
|
56
70
|
}
|
|
57
71
|
function formatGenericComment(report) {
|
|
58
72
|
const lines = [];
|
|
59
|
-
lines.push(`## \u{1F6E1}\uFE0F ai-saas-guard ${
|
|
73
|
+
lines.push(`## \u{1F6E1}\uFE0F ai-saas-guard ${escapeMarkdownInline(report.command)}`);
|
|
60
74
|
lines.push("");
|
|
61
|
-
lines.push(`**Verdict:** ${
|
|
75
|
+
lines.push(`**Verdict:** ${escapeMarkdownInline(launchGateVerdict(report))}`);
|
|
76
|
+
appendCoverageWarning(lines, report);
|
|
62
77
|
lines.push("");
|
|
63
78
|
lines.push("### Review first");
|
|
64
79
|
lines.push("");
|
|
65
80
|
for (const item of reviewFirst(report.findings, 3)) {
|
|
66
|
-
lines.push(`- ${
|
|
81
|
+
lines.push(`- ${escapeMarkdownInline(item)}`);
|
|
67
82
|
}
|
|
68
83
|
lines.push("");
|
|
69
84
|
lines.push("### Next");
|
|
70
85
|
lines.push("");
|
|
71
86
|
for (const item of nextSteps(report.findings).slice(0, 3)) {
|
|
72
|
-
lines.push(`- ${
|
|
87
|
+
lines.push(`- ${escapeMarkdownInline(item)}`);
|
|
73
88
|
}
|
|
74
89
|
lines.push("");
|
|
75
90
|
lines.push("---");
|
|
76
|
-
lines.push(`<sub>${trustStatement().map(
|
|
91
|
+
lines.push(`<sub>${trustStatement().map(escapeMarkdownInline).join(" ")}</sub>`);
|
|
77
92
|
return lines.join("\n");
|
|
78
93
|
}
|
|
79
|
-
function escapeInline(value) {
|
|
80
|
-
return value.replace(/\r?\n/g, " ").replaceAll("|", "\\|").trim();
|
|
81
|
-
}
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import type { BaseReport, CommandName, Finding, Summary } from "../types.js";
|
|
2
2
|
export declare function summarizeFindings(findings: Finding[]): Summary;
|
|
3
|
-
export declare function createReport<T extends BaseReport>(command: CommandName, rootDir: string, findings: Finding[], extra: Omit<T, keyof BaseReport | "command">): T;
|
|
3
|
+
export declare function createReport<T extends BaseReport>(command: CommandName, rootDir: string, findings: Finding[], extra: Omit<T, keyof BaseReport | "command"> & Partial<Pick<BaseReport, "stackInventory" | "fileCollection">>): T;
|
|
4
4
|
export declare function sortFindings(findings: Finding[]): Finding[];
|
|
5
5
|
export declare function finding(input: Finding): Finding;
|
|
6
6
|
export declare function uniqueFindings(findings: Finding[]): Finding[];
|
package/dist/report/findings.js
CHANGED
|
@@ -28,7 +28,19 @@ export function sortFindings(findings) {
|
|
|
28
28
|
const severityDelta = severities.indexOf(a.severity) - severities.indexOf(b.severity);
|
|
29
29
|
if (severityDelta !== 0)
|
|
30
30
|
return severityDelta;
|
|
31
|
-
|
|
31
|
+
const ruleDelta = compareText(a.ruleId, b.ruleId);
|
|
32
|
+
if (ruleDelta !== 0)
|
|
33
|
+
return ruleDelta;
|
|
34
|
+
const fileDelta = compareText(a.evidence[0]?.file ?? "", b.evidence[0]?.file ?? "");
|
|
35
|
+
if (fileDelta !== 0)
|
|
36
|
+
return fileDelta;
|
|
37
|
+
const lineDelta = (a.evidence[0]?.line ?? 0) - (b.evidence[0]?.line ?? 0);
|
|
38
|
+
if (lineDelta !== 0)
|
|
39
|
+
return lineDelta;
|
|
40
|
+
const columnDelta = (a.evidence[0]?.column ?? 0) - (b.evidence[0]?.column ?? 0);
|
|
41
|
+
if (columnDelta !== 0)
|
|
42
|
+
return columnDelta;
|
|
43
|
+
return compareText(a.title, b.title);
|
|
32
44
|
});
|
|
33
45
|
}
|
|
34
46
|
export function finding(input) {
|
|
@@ -47,3 +59,6 @@ export function uniqueFindings(findings) {
|
|
|
47
59
|
}
|
|
48
60
|
return result;
|
|
49
61
|
}
|
|
62
|
+
function compareText(left, right) {
|
|
63
|
+
return left < right ? -1 : left > right ? 1 : 0;
|
|
64
|
+
}
|
|
@@ -1,15 +1,20 @@
|
|
|
1
|
+
import { hasIncompleteScanCoverage } from "./presentation.js";
|
|
1
2
|
export function launchGateVerdict(report) {
|
|
3
|
+
const coverageNote = hasIncompleteScanCoverage(report) ? "; scan coverage is incomplete" : "";
|
|
2
4
|
if (report.summary.critical > 0) {
|
|
3
|
-
return
|
|
5
|
+
return `blocked: critical launch-readiness findings need review before inviting users${coverageNote}`;
|
|
4
6
|
}
|
|
5
7
|
if (report.summary.high > 0) {
|
|
6
|
-
return
|
|
8
|
+
return `review required: high-risk launch paths need manual verification before launch${coverageNote}`;
|
|
7
9
|
}
|
|
8
10
|
if (report.summary.medium > 0) {
|
|
9
|
-
return
|
|
11
|
+
return `check before launch: medium-risk findings should be verified by an owner${coverageNote}`;
|
|
10
12
|
}
|
|
11
13
|
if (report.summary.low > 0 || report.summary.info > 0) {
|
|
12
|
-
return
|
|
14
|
+
return `low-noise review: confirm these hints against the launch checklist${coverageNote}`;
|
|
15
|
+
}
|
|
16
|
+
if (hasIncompleteScanCoverage(report)) {
|
|
17
|
+
return "incomplete: scan coverage was limited; no clear launch verdict is available";
|
|
13
18
|
}
|
|
14
19
|
return "clear from current heuristics: no findings from this command, not a certification";
|
|
15
20
|
}
|