ai-saas-guard 0.9.0 → 0.10.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 +17 -3
- package/dist/hosted/contracts.d.ts +171 -0
- package/dist/hosted/contracts.js +321 -0
- package/docs/github-action.md +1 -1
- package/docs/github-app-design.md +14 -0
- package/docs/hosted-deployment-model.md +222 -0
- package/docs/hosted-first-service-slice.md +199 -0
- package/docs/hosted-operational-release-gate.md +226 -0
- package/docs/hosted-preimplementation-contracts.md +160 -0
- package/docs/hosted-pricing-packaging.md +195 -0
- package/docs/hosted-uninstall-data-deletion.md +166 -0
- package/docs/npm-publishing.md +3 -3
- package/docs/project-handoff.md +11 -10
- package/examples/hosted-compact-report.json +38 -0
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -51,8 +51,8 @@ The CLI is published on npm as `ai-saas-guard`, and the GitHub Action is availab
|
|
|
51
51
|
| JSON and SARIF output | Available |
|
|
52
52
|
| Composite GitHub Action | Available |
|
|
53
53
|
| Project config | `.ai-saas-guard.json` rule toggles, severity overrides, and fail thresholds |
|
|
54
|
-
| Versioned Action tags | `v0.
|
|
55
|
-
| npm package | `ai-saas-guard@0.
|
|
54
|
+
| Versioned Action tags | `v0.10.0`, `v0` |
|
|
55
|
+
| npm package | `ai-saas-guard@0.10.0` |
|
|
56
56
|
| npm publishing | Trusted Publisher/OIDC, no long-lived publish token |
|
|
57
57
|
|
|
58
58
|
## Quick Start
|
|
@@ -182,6 +182,20 @@ Use [docs/stripe-webhook-replay.md](docs/stripe-webhook-replay.md) after `check-
|
|
|
182
182
|
|
|
183
183
|
See [docs/github-app-design.md](docs/github-app-design.md) for the proposed hosted GitHub App layer. The note covers least-privilege permissions, selected repositories, webhook verification, PR comments, check runs, privacy, data retention, prompt injection handling, and why the hosted app should not replace the local CLI.
|
|
184
184
|
|
|
185
|
+
The first hosted service slice is defined in [docs/hosted-first-service-slice.md](docs/hosted-first-service-slice.md). It is intentionally check-run-only: signed GitHub App webhook intake, trusted scan identity, idempotent scan queueing, read-only worker behavior, compact report storage, and no PR comments, dashboard, billing, or AI summaries.
|
|
186
|
+
|
|
187
|
+
The hosted deployment model is documented in [docs/hosted-deployment-model.md](docs/hosted-deployment-model.md). It chooses a containerized Node.js ingress and worker model with a managed durable queue, platform secret manager, structured redacted logs, installation/repository rate limits, and rollback/incident response paths.
|
|
188
|
+
|
|
189
|
+
The hosted operational release gate is documented in [docs/hosted-operational-release-gate.md](docs/hosted-operational-release-gate.md). It defines the hosted-specific CI, replay, queue, worker cleanup, privacy, monitoring, rollback, and incident-response evidence required before any hosted environment is exposed to users.
|
|
190
|
+
|
|
191
|
+
Hosted uninstall and data deletion behavior is documented in [docs/hosted-uninstall-data-deletion.md](docs/hosted-uninstall-data-deletion.md). It defines repository removal, full app uninstall, compact report deletion, queue cancellation, audit record retention, repeated cleanup, and user-facing deletion wording.
|
|
192
|
+
|
|
193
|
+
Hosted pricing and packaging boundaries are documented in [docs/hosted-pricing-packaging.md](docs/hosted-pricing-packaging.md). Core local scanning stays useful without an account; hosted plans may add workflow convenience, saved reports, team policy, and optional human review, but they do not gate local CLI scanning.
|
|
194
|
+
|
|
195
|
+
Hosted pre-implementation pure contracts are documented in [docs/hosted-preimplementation-contracts.md](docs/hosted-preimplementation-contracts.md). They cover queue-safe webhook event parsing, bounded check-run summary rendering, idempotent queue cleanup planning, worker checkout cleanup planning, and other service-free helpers exported from `ai-saas-guard/hosted/contracts`.
|
|
196
|
+
|
|
197
|
+
A public hosted compact report schema fixture is available at [examples/hosted-compact-report.json](examples/hosted-compact-report.json). It is synthetic and public-safe: compact evidence only, no raw source, raw diffs, secrets, webhook payload bodies, customer payloads, private URLs, or worker checkout paths.
|
|
198
|
+
|
|
185
199
|
The proposed hosted app permission boundary is intentionally narrow: repository contents read, pull requests read, checks write, and metadata read for the first version. Optional PR comments require repository policy opt-in, and broad permissions such as administration, deployments, Actions write, and repository secrets are out of scope.
|
|
186
200
|
|
|
187
201
|
The repository also includes pure pre-implementation hosted contract helpers and tests for webhook verification, installation token scoping, queue idempotency, compact reports, and retention limits. These helpers do not implement or deploy a hosted service.
|
|
@@ -218,7 +232,7 @@ Use `suppressions` for narrower false-positive handling when one rule is noisy o
|
|
|
218
232
|
|
|
219
233
|
## GitHub Action
|
|
220
234
|
|
|
221
|
-
The repo includes a composite Action. Use `v0` for the latest compatible pre-1.0 Action, a specific release tag such as `v0.
|
|
235
|
+
The repo includes a composite Action. Use `v0` for the latest compatible pre-1.0 Action, a specific release tag such as `v0.10.0` for controlled upgrades, or pin a reviewed commit SHA for stricter supply-chain control:
|
|
222
236
|
|
|
223
237
|
```yaml
|
|
224
238
|
name: ai-saas-guard
|
|
@@ -32,6 +32,20 @@ export interface HostedScanIdentity {
|
|
|
32
32
|
headSha: string;
|
|
33
33
|
scannerVersion: string;
|
|
34
34
|
}
|
|
35
|
+
export type PullRequestEventRejectReason = "unsupported_action" | "draft_pull_request" | "missing_required_field";
|
|
36
|
+
export interface HostedPullRequestEventInput {
|
|
37
|
+
payload: unknown;
|
|
38
|
+
scannerVersion: string;
|
|
39
|
+
allowDraft?: boolean;
|
|
40
|
+
supportedActions?: string[];
|
|
41
|
+
}
|
|
42
|
+
export interface HostedPullRequestEventDecision {
|
|
43
|
+
accepted: boolean;
|
|
44
|
+
shouldQueueScanJob: boolean;
|
|
45
|
+
reason?: PullRequestEventRejectReason;
|
|
46
|
+
action?: string;
|
|
47
|
+
identity?: HostedScanIdentity;
|
|
48
|
+
}
|
|
35
49
|
export type InstallationScopeRejectReason = "installation_mismatch" | "repository_not_installed" | "repository_removed_from_installation";
|
|
36
50
|
export interface InstallationScopeInput {
|
|
37
51
|
identity: HostedScanIdentity;
|
|
@@ -62,6 +76,85 @@ export interface HostedScanJobDecision {
|
|
|
62
76
|
shouldCreateCheckRun: boolean;
|
|
63
77
|
shouldCreatePrComment: boolean;
|
|
64
78
|
}
|
|
79
|
+
export type HostedQueueJobStatus = "queued" | "running" | "completed" | "failed" | "cancelled";
|
|
80
|
+
export type HostedQueueCleanupTrigger = "repository_removed" | "installation_deleted" | "repeated_cleanup";
|
|
81
|
+
export interface HostedQueueCleanupJobState {
|
|
82
|
+
key: string;
|
|
83
|
+
identity: HostedScanIdentity;
|
|
84
|
+
status: HostedQueueJobStatus;
|
|
85
|
+
attempt?: number;
|
|
86
|
+
deliveryIds?: string[];
|
|
87
|
+
}
|
|
88
|
+
export interface HostedQueueCleanupPlanInput {
|
|
89
|
+
trigger: HostedQueueCleanupTrigger;
|
|
90
|
+
installationId: number;
|
|
91
|
+
repositoryId?: number;
|
|
92
|
+
requestedAt: string;
|
|
93
|
+
jobs: HostedQueueCleanupJobState[];
|
|
94
|
+
}
|
|
95
|
+
export interface HostedQueueCleanupPlan {
|
|
96
|
+
trigger: HostedQueueCleanupTrigger;
|
|
97
|
+
scope: "repository" | "installation";
|
|
98
|
+
installationId: number;
|
|
99
|
+
repositoryId?: number;
|
|
100
|
+
requestedAt: string;
|
|
101
|
+
idempotencyKey: string;
|
|
102
|
+
idempotent: true;
|
|
103
|
+
matchedJobKeys: string[];
|
|
104
|
+
cancelQueuedJobKeys: string[];
|
|
105
|
+
requestRunningCancellationJobKeys: string[];
|
|
106
|
+
preserveTerminalJobKeys: string[];
|
|
107
|
+
keepUnmatchedJobKeys: string[];
|
|
108
|
+
cancelQueuedJobs: true;
|
|
109
|
+
requestRunningCancellation: true;
|
|
110
|
+
deleteRawSource: false;
|
|
111
|
+
deleteRawDiffs: false;
|
|
112
|
+
deleteSecrets: false;
|
|
113
|
+
deleteCustomerPayloads: false;
|
|
114
|
+
}
|
|
115
|
+
export type HostedWorkerCheckoutTerminalState = "success" | "failure" | "timeout" | "cancellation" | "cleanup_failure";
|
|
116
|
+
export type HostedWorkerCheckoutCleanupAction = "delete_checkout" | "record_cleanup_failure";
|
|
117
|
+
export interface HostedWorkerCheckoutCleanupInput {
|
|
118
|
+
identity: HostedScanIdentity;
|
|
119
|
+
jobKey: string;
|
|
120
|
+
terminalState: HostedWorkerCheckoutTerminalState;
|
|
121
|
+
finishedAt: string;
|
|
122
|
+
checkoutPath?: string;
|
|
123
|
+
cleanupError?: string;
|
|
124
|
+
rawSource?: string;
|
|
125
|
+
rawDiff?: string;
|
|
126
|
+
secretValues?: string[];
|
|
127
|
+
customerPayload?: unknown;
|
|
128
|
+
}
|
|
129
|
+
export interface HostedWorkerCheckoutCleanupPlan {
|
|
130
|
+
cleanupAction: HostedWorkerCheckoutCleanupAction;
|
|
131
|
+
shouldDeleteWorkerCheckout: boolean;
|
|
132
|
+
shouldRemoveCredentials: boolean;
|
|
133
|
+
shouldRemoveRawSource: boolean;
|
|
134
|
+
shouldRemoveRawDiffs: boolean;
|
|
135
|
+
shouldRemoveGeneratedArtifacts: boolean;
|
|
136
|
+
requiresOperatorReview: boolean;
|
|
137
|
+
preserveAuditRecord: true;
|
|
138
|
+
visibleUserMessage: string;
|
|
139
|
+
safeMetadata: {
|
|
140
|
+
jobKey: string;
|
|
141
|
+
installationId: number;
|
|
142
|
+
repositoryId: number;
|
|
143
|
+
repositoryFullName: string;
|
|
144
|
+
pullRequestNumber: number;
|
|
145
|
+
scannerVersion: string;
|
|
146
|
+
terminalState: HostedWorkerCheckoutTerminalState;
|
|
147
|
+
finishedAt: string;
|
|
148
|
+
};
|
|
149
|
+
privacy: {
|
|
150
|
+
returnsCheckoutPath: false;
|
|
151
|
+
returnsCleanupError: false;
|
|
152
|
+
returnsRawSource: false;
|
|
153
|
+
returnsRawDiffs: false;
|
|
154
|
+
returnsSecrets: false;
|
|
155
|
+
returnsCustomerPayloads: false;
|
|
156
|
+
};
|
|
157
|
+
}
|
|
65
158
|
export interface CompactHostedFinding {
|
|
66
159
|
ruleId: string;
|
|
67
160
|
severity: string;
|
|
@@ -98,17 +191,95 @@ export interface CompactHostedReport {
|
|
|
98
191
|
modelTraining: "disabled";
|
|
99
192
|
workerCheckoutDeletion: "after_scan_completion";
|
|
100
193
|
}
|
|
194
|
+
export type HostedCheckRunConclusion = "success" | "neutral" | "failure";
|
|
195
|
+
export type HostedCheckRunSeverityThreshold = "critical" | "high" | "medium" | "low" | "info";
|
|
196
|
+
export type HostedCheckRunAnnotationLevel = "notice" | "warning" | "failure";
|
|
197
|
+
export interface HostedCheckRunSummaryInput {
|
|
198
|
+
report: CompactHostedReport;
|
|
199
|
+
failOnSeverity?: HostedCheckRunSeverityThreshold;
|
|
200
|
+
maxMarkdownChars?: number;
|
|
201
|
+
}
|
|
202
|
+
export interface HostedCheckRunAnnotation {
|
|
203
|
+
path: string;
|
|
204
|
+
startLine: number;
|
|
205
|
+
endLine: number;
|
|
206
|
+
annotationLevel: HostedCheckRunAnnotationLevel;
|
|
207
|
+
title: string;
|
|
208
|
+
message: string;
|
|
209
|
+
}
|
|
210
|
+
export interface HostedCheckRunSummary {
|
|
211
|
+
name: "AI SaaS Guard";
|
|
212
|
+
conclusion: HostedCheckRunConclusion;
|
|
213
|
+
output: {
|
|
214
|
+
title: string;
|
|
215
|
+
summary: string;
|
|
216
|
+
text: string;
|
|
217
|
+
};
|
|
218
|
+
annotations: HostedCheckRunAnnotation[];
|
|
219
|
+
localCliCommand: string;
|
|
220
|
+
privacy: {
|
|
221
|
+
includesRawSource: false;
|
|
222
|
+
includesRawDiffs: false;
|
|
223
|
+
includesSecrets: false;
|
|
224
|
+
includesCustomerPayloads: false;
|
|
225
|
+
modelTraining: "disabled";
|
|
226
|
+
};
|
|
227
|
+
}
|
|
228
|
+
export type HostedDeletionTrigger = "repository_removed" | "installation_deleted" | "repeated_cleanup";
|
|
229
|
+
export interface HostedDeletionPlanInput {
|
|
230
|
+
trigger: HostedDeletionTrigger;
|
|
231
|
+
installationId: number;
|
|
232
|
+
repositoryId?: number;
|
|
233
|
+
requestedAt: string;
|
|
234
|
+
auditRecordRetentionDays?: number;
|
|
235
|
+
}
|
|
236
|
+
export interface HostedDeletionPlan {
|
|
237
|
+
trigger: HostedDeletionTrigger;
|
|
238
|
+
scope: "repository" | "installation";
|
|
239
|
+
installationId: number;
|
|
240
|
+
repositoryId?: number;
|
|
241
|
+
requestedAt: string;
|
|
242
|
+
idempotencyKey: string;
|
|
243
|
+
idempotent: true;
|
|
244
|
+
deleteCompactReports: true;
|
|
245
|
+
cancelQueuedJobs: true;
|
|
246
|
+
deleteWorkerCheckouts: true;
|
|
247
|
+
deleteRawSource: false;
|
|
248
|
+
deleteRawDiffs: false;
|
|
249
|
+
deleteSecrets: false;
|
|
250
|
+
deleteCustomerPayloads: false;
|
|
251
|
+
deleteGitHubOwnedCheckRuns: false;
|
|
252
|
+
preserveAuditRecord: true;
|
|
253
|
+
auditRecordRetentionDays: number;
|
|
254
|
+
visibleUserMessage: string;
|
|
255
|
+
}
|
|
101
256
|
export declare const HOSTED_PRIVACY_DEFAULTS: {
|
|
102
257
|
readonly retentionDays: 30;
|
|
258
|
+
readonly auditRecordRetentionDays: 90;
|
|
103
259
|
readonly modelTraining: "disabled";
|
|
104
260
|
readonly deleteWorkerCheckout: "after_scan_completion";
|
|
105
261
|
};
|
|
106
262
|
export declare function verifyGitHubWebhook(input: GitHubWebhookInput): GitHubWebhookDecision;
|
|
107
263
|
export declare function buildHostedScanIdentity(input: HostedScanIdentityInput): HostedScanIdentity;
|
|
264
|
+
export declare function parseHostedPullRequestEvent(input: HostedPullRequestEventInput): HostedPullRequestEventDecision;
|
|
108
265
|
export declare function authorizeInstallationTokenScope(input: InstallationScopeInput): InstallationScopeDecision;
|
|
109
266
|
export declare function getHostedScanIdempotencyKey(identity: HostedScanIdentity): string;
|
|
110
267
|
export declare function upsertHostedScanJob(queue: Map<string, HostedScanJobState>, input: HostedScanJobInput): HostedScanJobDecision;
|
|
268
|
+
export declare function getHostedQueueCleanupIdempotencyKey(input: {
|
|
269
|
+
trigger: HostedQueueCleanupTrigger;
|
|
270
|
+
installationId: number;
|
|
271
|
+
repositoryId?: number;
|
|
272
|
+
}): string;
|
|
273
|
+
export declare function createHostedQueueCleanupPlan(input: HostedQueueCleanupPlanInput): HostedQueueCleanupPlan;
|
|
274
|
+
export declare function createHostedWorkerCheckoutCleanupPlan(input: HostedWorkerCheckoutCleanupInput): HostedWorkerCheckoutCleanupPlan;
|
|
111
275
|
export declare function resolveHostedRetentionDays(input?: {
|
|
112
276
|
teamRequestedDays?: number;
|
|
113
277
|
}): number;
|
|
114
278
|
export declare function createCompactHostedReport(input: CompactHostedReportInput): CompactHostedReport;
|
|
279
|
+
export declare function createHostedCheckRunSummary(input: HostedCheckRunSummaryInput): HostedCheckRunSummary;
|
|
280
|
+
export declare function getHostedDeletionIdempotencyKey(input: {
|
|
281
|
+
trigger: HostedDeletionTrigger;
|
|
282
|
+
installationId: number;
|
|
283
|
+
repositoryId?: number;
|
|
284
|
+
}): string;
|
|
285
|
+
export declare function createHostedDeletionPlan(input: HostedDeletionPlanInput): HostedDeletionPlan;
|
package/dist/hosted/contracts.js
CHANGED
|
@@ -1,9 +1,21 @@
|
|
|
1
1
|
import { createHmac, timingSafeEqual } from "node:crypto";
|
|
2
2
|
export const HOSTED_PRIVACY_DEFAULTS = {
|
|
3
3
|
retentionDays: 30,
|
|
4
|
+
auditRecordRetentionDays: 90,
|
|
4
5
|
modelTraining: "disabled",
|
|
5
6
|
deleteWorkerCheckout: "after_scan_completion"
|
|
6
7
|
};
|
|
8
|
+
const CHECK_RUN_NAME = "AI SaaS Guard";
|
|
9
|
+
const DEFAULT_CHECK_RUN_MARKDOWN_CHARS = 4000;
|
|
10
|
+
const MAX_CHECK_RUN_ANNOTATIONS = 50;
|
|
11
|
+
const severityOrder = ["critical", "high", "medium", "low", "info"];
|
|
12
|
+
const severityRanks = {
|
|
13
|
+
critical: 5,
|
|
14
|
+
high: 4,
|
|
15
|
+
medium: 3,
|
|
16
|
+
low: 2,
|
|
17
|
+
info: 1
|
|
18
|
+
};
|
|
7
19
|
export function verifyGitHubWebhook(input) {
|
|
8
20
|
const { deliveryId, seenDeliveryIds, signatureHeader } = input;
|
|
9
21
|
if (!signatureHeader) {
|
|
@@ -41,6 +53,59 @@ export function buildHostedScanIdentity(input) {
|
|
|
41
53
|
scannerVersion: input.scannerVersion
|
|
42
54
|
};
|
|
43
55
|
}
|
|
56
|
+
export function parseHostedPullRequestEvent(input) {
|
|
57
|
+
const supportedActions = input.supportedActions ?? [
|
|
58
|
+
"opened",
|
|
59
|
+
"reopened",
|
|
60
|
+
"synchronize",
|
|
61
|
+
"ready_for_review"
|
|
62
|
+
];
|
|
63
|
+
const payload = input.payload;
|
|
64
|
+
if (!isRecord(payload)) {
|
|
65
|
+
return rejectPullRequestEvent("missing_required_field");
|
|
66
|
+
}
|
|
67
|
+
const action = valueAsString(payload.action);
|
|
68
|
+
if (!action || !supportedActions.includes(action)) {
|
|
69
|
+
return rejectPullRequestEvent("unsupported_action", action);
|
|
70
|
+
}
|
|
71
|
+
const installation = valueAsRecord(payload.installation);
|
|
72
|
+
const repository = valueAsRecord(payload.repository);
|
|
73
|
+
const pullRequest = valueAsRecord(payload.pull_request);
|
|
74
|
+
const base = valueAsRecord(pullRequest?.base);
|
|
75
|
+
const head = valueAsRecord(pullRequest?.head);
|
|
76
|
+
const installationId = valueAsNumber(installation?.id);
|
|
77
|
+
const repositoryId = valueAsNumber(repository?.id);
|
|
78
|
+
const repositoryFullName = valueAsString(repository?.full_name);
|
|
79
|
+
const pullRequestNumber = valueAsNumber(pullRequest?.number);
|
|
80
|
+
const baseSha = valueAsString(base?.sha);
|
|
81
|
+
const headSha = valueAsString(head?.sha);
|
|
82
|
+
const draft = pullRequest?.draft === true;
|
|
83
|
+
if (installationId === undefined ||
|
|
84
|
+
repositoryId === undefined ||
|
|
85
|
+
!repositoryFullName ||
|
|
86
|
+
pullRequestNumber === undefined ||
|
|
87
|
+
!baseSha ||
|
|
88
|
+
!headSha) {
|
|
89
|
+
return rejectPullRequestEvent("missing_required_field", action);
|
|
90
|
+
}
|
|
91
|
+
if (draft && !input.allowDraft) {
|
|
92
|
+
return rejectPullRequestEvent("draft_pull_request", action);
|
|
93
|
+
}
|
|
94
|
+
return {
|
|
95
|
+
accepted: true,
|
|
96
|
+
shouldQueueScanJob: true,
|
|
97
|
+
action,
|
|
98
|
+
identity: buildHostedScanIdentity({
|
|
99
|
+
installationId,
|
|
100
|
+
repositoryId,
|
|
101
|
+
repositoryFullName,
|
|
102
|
+
pullRequestNumber,
|
|
103
|
+
baseSha,
|
|
104
|
+
headSha,
|
|
105
|
+
scannerVersion: input.scannerVersion
|
|
106
|
+
})
|
|
107
|
+
};
|
|
108
|
+
}
|
|
44
109
|
export function authorizeInstallationTokenScope(input) {
|
|
45
110
|
if (input.installationId !== input.identity.installationId) {
|
|
46
111
|
return rejectInstallationScope("installation_mismatch");
|
|
@@ -98,6 +163,80 @@ export function upsertHostedScanJob(queue, input) {
|
|
|
98
163
|
shouldCreatePrComment: false
|
|
99
164
|
};
|
|
100
165
|
}
|
|
166
|
+
export function getHostedQueueCleanupIdempotencyKey(input) {
|
|
167
|
+
return ["queue-cleanup", input.trigger, input.installationId, input.repositoryId ?? "all"].join(":");
|
|
168
|
+
}
|
|
169
|
+
export function createHostedQueueCleanupPlan(input) {
|
|
170
|
+
const scope = input.trigger === "installation_deleted" ? "installation" : "repository";
|
|
171
|
+
const matchedJobs = input.jobs.filter((job) => queueCleanupMatches(job, input, scope));
|
|
172
|
+
const unmatchedJobs = input.jobs.filter((job) => !queueCleanupMatches(job, input, scope));
|
|
173
|
+
return {
|
|
174
|
+
trigger: input.trigger,
|
|
175
|
+
scope,
|
|
176
|
+
installationId: input.installationId,
|
|
177
|
+
...(scope === "repository" && input.repositoryId !== undefined
|
|
178
|
+
? { repositoryId: input.repositoryId }
|
|
179
|
+
: {}),
|
|
180
|
+
requestedAt: input.requestedAt,
|
|
181
|
+
idempotencyKey: getHostedQueueCleanupIdempotencyKey({
|
|
182
|
+
trigger: input.trigger,
|
|
183
|
+
installationId: input.installationId,
|
|
184
|
+
repositoryId: scope === "repository" ? input.repositoryId : undefined
|
|
185
|
+
}),
|
|
186
|
+
idempotent: true,
|
|
187
|
+
matchedJobKeys: matchedJobs.map((job) => job.key),
|
|
188
|
+
cancelQueuedJobKeys: matchedJobs
|
|
189
|
+
.filter((job) => job.status === "queued")
|
|
190
|
+
.map((job) => job.key),
|
|
191
|
+
requestRunningCancellationJobKeys: matchedJobs
|
|
192
|
+
.filter((job) => job.status === "running")
|
|
193
|
+
.map((job) => job.key),
|
|
194
|
+
preserveTerminalJobKeys: matchedJobs
|
|
195
|
+
.filter((job) => isTerminalQueueStatus(job.status))
|
|
196
|
+
.map((job) => job.key),
|
|
197
|
+
keepUnmatchedJobKeys: unmatchedJobs.map((job) => job.key),
|
|
198
|
+
cancelQueuedJobs: true,
|
|
199
|
+
requestRunningCancellation: true,
|
|
200
|
+
deleteRawSource: false,
|
|
201
|
+
deleteRawDiffs: false,
|
|
202
|
+
deleteSecrets: false,
|
|
203
|
+
deleteCustomerPayloads: false
|
|
204
|
+
};
|
|
205
|
+
}
|
|
206
|
+
export function createHostedWorkerCheckoutCleanupPlan(input) {
|
|
207
|
+
const cleanupFailed = input.terminalState === "cleanup_failure";
|
|
208
|
+
return {
|
|
209
|
+
cleanupAction: cleanupFailed ? "record_cleanup_failure" : "delete_checkout",
|
|
210
|
+
shouldDeleteWorkerCheckout: !cleanupFailed,
|
|
211
|
+
shouldRemoveCredentials: !cleanupFailed,
|
|
212
|
+
shouldRemoveRawSource: !cleanupFailed,
|
|
213
|
+
shouldRemoveRawDiffs: !cleanupFailed,
|
|
214
|
+
shouldRemoveGeneratedArtifacts: !cleanupFailed,
|
|
215
|
+
requiresOperatorReview: cleanupFailed,
|
|
216
|
+
preserveAuditRecord: true,
|
|
217
|
+
visibleUserMessage: cleanupFailed
|
|
218
|
+
? "Worker checkout cleanup failed; manual cleanup review is required without exposing checkout data."
|
|
219
|
+
: "Worker checkout is scheduled for deletion after scan completion.",
|
|
220
|
+
safeMetadata: {
|
|
221
|
+
jobKey: input.jobKey,
|
|
222
|
+
installationId: input.identity.installationId,
|
|
223
|
+
repositoryId: input.identity.repositoryId,
|
|
224
|
+
repositoryFullName: input.identity.repositoryFullName,
|
|
225
|
+
pullRequestNumber: input.identity.pullRequestNumber,
|
|
226
|
+
scannerVersion: input.identity.scannerVersion,
|
|
227
|
+
terminalState: input.terminalState,
|
|
228
|
+
finishedAt: input.finishedAt
|
|
229
|
+
},
|
|
230
|
+
privacy: {
|
|
231
|
+
returnsCheckoutPath: false,
|
|
232
|
+
returnsCleanupError: false,
|
|
233
|
+
returnsRawSource: false,
|
|
234
|
+
returnsRawDiffs: false,
|
|
235
|
+
returnsSecrets: false,
|
|
236
|
+
returnsCustomerPayloads: false
|
|
237
|
+
}
|
|
238
|
+
};
|
|
239
|
+
}
|
|
101
240
|
export function resolveHostedRetentionDays(input = {}) {
|
|
102
241
|
if (input.teamRequestedDays === undefined) {
|
|
103
242
|
return HOSTED_PRIVACY_DEFAULTS.retentionDays;
|
|
@@ -129,6 +268,71 @@ export function createCompactHostedReport(input) {
|
|
|
129
268
|
workerCheckoutDeletion: HOSTED_PRIVACY_DEFAULTS.deleteWorkerCheckout
|
|
130
269
|
};
|
|
131
270
|
}
|
|
271
|
+
export function createHostedCheckRunSummary(input) {
|
|
272
|
+
const { report } = input;
|
|
273
|
+
const totalFindings = getHostedReportFindingTotal(report);
|
|
274
|
+
const localCliCommand = `npx ai-saas-guard@${report.scannerVersion} pr-risk --root .`;
|
|
275
|
+
const conclusion = resolveCheckRunConclusion(report, input.failOnSeverity);
|
|
276
|
+
return {
|
|
277
|
+
name: CHECK_RUN_NAME,
|
|
278
|
+
conclusion,
|
|
279
|
+
output: {
|
|
280
|
+
title: formatCheckRunTitle(totalFindings, conclusion, input.failOnSeverity),
|
|
281
|
+
summary: "Review first: verify this launch-readiness signal before release; it is not a full security audit, pentest, or certification.",
|
|
282
|
+
text: truncateMarkdown(formatCheckRunMarkdown(report, conclusion, localCliCommand), input.maxMarkdownChars)
|
|
283
|
+
},
|
|
284
|
+
annotations: report.evidence.slice(0, MAX_CHECK_RUN_ANNOTATIONS).map((finding) => {
|
|
285
|
+
const line = finding.line ?? 1;
|
|
286
|
+
return {
|
|
287
|
+
path: finding.file,
|
|
288
|
+
startLine: line,
|
|
289
|
+
endLine: line,
|
|
290
|
+
annotationLevel: annotationLevelForSeverity(finding.severity),
|
|
291
|
+
title: finding.ruleId,
|
|
292
|
+
message: `${finding.severity} finding. Review locally before launch.`
|
|
293
|
+
};
|
|
294
|
+
}),
|
|
295
|
+
localCliCommand,
|
|
296
|
+
privacy: {
|
|
297
|
+
includesRawSource: false,
|
|
298
|
+
includesRawDiffs: false,
|
|
299
|
+
includesSecrets: false,
|
|
300
|
+
includesCustomerPayloads: false,
|
|
301
|
+
modelTraining: HOSTED_PRIVACY_DEFAULTS.modelTraining
|
|
302
|
+
}
|
|
303
|
+
};
|
|
304
|
+
}
|
|
305
|
+
export function getHostedDeletionIdempotencyKey(input) {
|
|
306
|
+
return [input.trigger, input.installationId, input.repositoryId ?? "all"].join(":");
|
|
307
|
+
}
|
|
308
|
+
export function createHostedDeletionPlan(input) {
|
|
309
|
+
const scope = input.trigger === "installation_deleted" ? "installation" : "repository";
|
|
310
|
+
const repositoryId = scope === "repository" ? input.repositoryId : undefined;
|
|
311
|
+
return {
|
|
312
|
+
trigger: input.trigger,
|
|
313
|
+
scope,
|
|
314
|
+
installationId: input.installationId,
|
|
315
|
+
...(repositoryId === undefined ? {} : { repositoryId }),
|
|
316
|
+
requestedAt: input.requestedAt,
|
|
317
|
+
idempotencyKey: getHostedDeletionIdempotencyKey({
|
|
318
|
+
trigger: input.trigger,
|
|
319
|
+
installationId: input.installationId,
|
|
320
|
+
repositoryId
|
|
321
|
+
}),
|
|
322
|
+
idempotent: true,
|
|
323
|
+
deleteCompactReports: true,
|
|
324
|
+
cancelQueuedJobs: true,
|
|
325
|
+
deleteWorkerCheckouts: true,
|
|
326
|
+
deleteRawSource: false,
|
|
327
|
+
deleteRawDiffs: false,
|
|
328
|
+
deleteSecrets: false,
|
|
329
|
+
deleteCustomerPayloads: false,
|
|
330
|
+
deleteGitHubOwnedCheckRuns: false,
|
|
331
|
+
preserveAuditRecord: true,
|
|
332
|
+
auditRecordRetentionDays: input.auditRecordRetentionDays ?? HOSTED_PRIVACY_DEFAULTS.auditRecordRetentionDays,
|
|
333
|
+
visibleUserMessage: "Hosted app-side compact reports and queued work are removed; GitHub-owned check runs remain in GitHub according to repository settings."
|
|
334
|
+
};
|
|
335
|
+
}
|
|
132
336
|
function rejectWebhook(reason, deliveryId) {
|
|
133
337
|
return {
|
|
134
338
|
accepted: false,
|
|
@@ -138,6 +342,14 @@ function rejectWebhook(reason, deliveryId) {
|
|
|
138
342
|
deliveryId
|
|
139
343
|
};
|
|
140
344
|
}
|
|
345
|
+
function rejectPullRequestEvent(reason, action) {
|
|
346
|
+
return {
|
|
347
|
+
accepted: false,
|
|
348
|
+
shouldQueueScanJob: false,
|
|
349
|
+
reason,
|
|
350
|
+
...(action === undefined ? {} : { action })
|
|
351
|
+
};
|
|
352
|
+
}
|
|
141
353
|
function rejectInstallationScope(reason) {
|
|
142
354
|
return {
|
|
143
355
|
authorized: false,
|
|
@@ -145,6 +357,115 @@ function rejectInstallationScope(reason) {
|
|
|
145
357
|
reason
|
|
146
358
|
};
|
|
147
359
|
}
|
|
360
|
+
function queueCleanupMatches(job, input, scope) {
|
|
361
|
+
if (job.identity.installationId !== input.installationId) {
|
|
362
|
+
return false;
|
|
363
|
+
}
|
|
364
|
+
return scope === "installation" || job.identity.repositoryId === input.repositoryId;
|
|
365
|
+
}
|
|
366
|
+
function isTerminalQueueStatus(status) {
|
|
367
|
+
return status === "completed" || status === "failed" || status === "cancelled";
|
|
368
|
+
}
|
|
369
|
+
function isRecord(value) {
|
|
370
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
371
|
+
}
|
|
372
|
+
function valueAsRecord(value) {
|
|
373
|
+
return isRecord(value) ? value : undefined;
|
|
374
|
+
}
|
|
375
|
+
function valueAsString(value) {
|
|
376
|
+
return typeof value === "string" && value.length > 0 ? value : undefined;
|
|
377
|
+
}
|
|
378
|
+
function valueAsNumber(value) {
|
|
379
|
+
return typeof value === "number" && Number.isSafeInteger(value) && value > 0
|
|
380
|
+
? value
|
|
381
|
+
: undefined;
|
|
382
|
+
}
|
|
383
|
+
function resolveCheckRunConclusion(report, failOnSeverity) {
|
|
384
|
+
if (getHostedReportFindingTotal(report) === 0) {
|
|
385
|
+
return "success";
|
|
386
|
+
}
|
|
387
|
+
if (failOnSeverity &&
|
|
388
|
+
report.evidence.some((finding) => severityRank(finding.severity) >= severityRanks[failOnSeverity])) {
|
|
389
|
+
return "failure";
|
|
390
|
+
}
|
|
391
|
+
return "neutral";
|
|
392
|
+
}
|
|
393
|
+
function getHostedReportFindingTotal(report) {
|
|
394
|
+
const countedBySeverity = Object.entries(report.summaryCounts).reduce((total, [severity, count]) => severity === "total" ? total : total + (typeof count === "number" ? count : 0), 0);
|
|
395
|
+
const explicitTotal = typeof report.summaryCounts.total === "number" ? report.summaryCounts.total : 0;
|
|
396
|
+
return Math.max(countedBySeverity, explicitTotal, report.evidence.length);
|
|
397
|
+
}
|
|
398
|
+
function formatCheckRunTitle(totalFindings, conclusion, failOnSeverity) {
|
|
399
|
+
if (totalFindings === 0) {
|
|
400
|
+
return "AI SaaS Guard found no launch-readiness findings";
|
|
401
|
+
}
|
|
402
|
+
if (conclusion === "failure" && failOnSeverity) {
|
|
403
|
+
return `AI SaaS Guard found findings at or above ${failOnSeverity}`;
|
|
404
|
+
}
|
|
405
|
+
return `AI SaaS Guard found ${totalFindings} finding${totalFindings === 1 ? "" : "s"} to review`;
|
|
406
|
+
}
|
|
407
|
+
function formatCheckRunMarkdown(report, conclusion, localCliCommand) {
|
|
408
|
+
const findingLines = report.evidence.length === 0
|
|
409
|
+
? ["No findings in the compact hosted report."]
|
|
410
|
+
: [
|
|
411
|
+
"| Severity | Rule | Evidence |",
|
|
412
|
+
"| --- | --- | --- |",
|
|
413
|
+
...report.evidence.map((finding) => `| ${escapeMarkdownTableCell(finding.severity)} | ${escapeMarkdownTableCell(finding.ruleId)} | ${escapeMarkdownTableCell(formatFindingLocation(finding))} |`)
|
|
414
|
+
];
|
|
415
|
+
return [
|
|
416
|
+
"### AI SaaS Guard",
|
|
417
|
+
"",
|
|
418
|
+
"Review first: verify findings locally before launch. This hosted check is not a full security audit, pentest, or certification.",
|
|
419
|
+
"",
|
|
420
|
+
`Conclusion: ${conclusion}`,
|
|
421
|
+
`Local CLI: \`${localCliCommand}\``,
|
|
422
|
+
`Retention: compact report ${report.retentionDays} days; raw source, raw diffs, secrets, and customer payloads are not retained.`,
|
|
423
|
+
"",
|
|
424
|
+
"Summary:",
|
|
425
|
+
...severityOrder.map((severity) => `- ${capitalize(severity)}: ${report.summaryCounts[severity] ?? 0}`),
|
|
426
|
+
"",
|
|
427
|
+
"Findings:",
|
|
428
|
+
...findingLines
|
|
429
|
+
].join("\n");
|
|
430
|
+
}
|
|
431
|
+
function truncateMarkdown(markdown, maxMarkdownChars) {
|
|
432
|
+
const maxChars = maxMarkdownChars === undefined
|
|
433
|
+
? DEFAULT_CHECK_RUN_MARKDOWN_CHARS
|
|
434
|
+
: Math.max(1, Math.floor(maxMarkdownChars));
|
|
435
|
+
if (markdown.length <= maxChars) {
|
|
436
|
+
return markdown;
|
|
437
|
+
}
|
|
438
|
+
const suffix = "\n\n_Additional findings truncated by hosted check output limit. Run the local CLI for the full report._";
|
|
439
|
+
if (maxChars <= suffix.length) {
|
|
440
|
+
return suffix.slice(0, maxChars);
|
|
441
|
+
}
|
|
442
|
+
return `${markdown.slice(0, maxChars - suffix.length).trimEnd()}${suffix}`;
|
|
443
|
+
}
|
|
444
|
+
function severityRank(severity) {
|
|
445
|
+
const normalized = severity.toLowerCase();
|
|
446
|
+
return normalized in severityRanks
|
|
447
|
+
? severityRanks[normalized]
|
|
448
|
+
: 0;
|
|
449
|
+
}
|
|
450
|
+
function annotationLevelForSeverity(severity) {
|
|
451
|
+
const rank = severityRank(severity);
|
|
452
|
+
if (rank >= severityRanks.high) {
|
|
453
|
+
return "failure";
|
|
454
|
+
}
|
|
455
|
+
if (rank >= severityRanks.medium) {
|
|
456
|
+
return "warning";
|
|
457
|
+
}
|
|
458
|
+
return "notice";
|
|
459
|
+
}
|
|
460
|
+
function formatFindingLocation(finding) {
|
|
461
|
+
return `${finding.file}${finding.line === undefined ? "" : `:${finding.line}`}`;
|
|
462
|
+
}
|
|
463
|
+
function escapeMarkdownTableCell(value) {
|
|
464
|
+
return value.replace(/\|/g, "\\|").replace(/\r?\n/g, " ");
|
|
465
|
+
}
|
|
466
|
+
function capitalize(value) {
|
|
467
|
+
return `${value.charAt(0).toUpperCase()}${value.slice(1)}`;
|
|
468
|
+
}
|
|
148
469
|
function parseSha256Signature(signatureHeader) {
|
|
149
470
|
const match = /^sha256=([0-9a-f]{64})$/i.exec(signatureHeader.trim());
|
|
150
471
|
if (!match?.[1]) {
|
package/docs/github-action.md
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
`ai-saas-guard` ships as a composite GitHub Action for pull request and code scanning workflows.
|
|
4
4
|
|
|
5
|
-
Use `zr9959/ai-saas-guard@v0` for the latest compatible pre-1.0 Action. Use a specific tag such as `v0.
|
|
5
|
+
Use `zr9959/ai-saas-guard@v0` for the latest compatible pre-1.0 Action. Use a specific tag such as `v0.10.0` or a reviewed commit SHA when reproducibility is more important than automatic minor updates.
|
|
6
6
|
|
|
7
7
|
## PR Summary
|
|
8
8
|
|
|
@@ -46,6 +46,20 @@ Start with a PR review assistant that runs the same deterministic scanner logic
|
|
|
46
46
|
|
|
47
47
|
The first version should prefer check runs over noisy PR comments. PR comments should be opt-in per repository.
|
|
48
48
|
|
|
49
|
+
The first hosted service slice is scoped in [docs/hosted-first-service-slice.md](hosted-first-service-slice.md). That slice is check-run-only and exists to prove signed webhook intake, trusted identity construction, idempotent queueing, read-only worker behavior, compact report storage, and local-first positioning before any PR comments, dashboard, billing, or AI summaries are added.
|
|
50
|
+
|
|
51
|
+
The hosted deployment model is scoped in [docs/hosted-deployment-model.md](hosted-deployment-model.md). It chooses containerized Node.js ingress and worker roles connected by a managed durable queue, with platform-managed secrets, structured redacted logs, installation/repository rate limits, rollback, and incident response paths.
|
|
52
|
+
|
|
53
|
+
The hosted operational release gate is scoped in [docs/hosted-operational-release-gate.md](hosted-operational-release-gate.md). It blocks hosted exposure unless CI, webhook replay, signature verification, token scoping, idempotency, privacy and retention, worker cleanup, monitoring, alerting, rollback, and incident response evidence are fresh for the release candidate.
|
|
54
|
+
|
|
55
|
+
Hosted uninstall and data deletion behavior is scoped in [docs/hosted-uninstall-data-deletion.md](hosted-uninstall-data-deletion.md). It defines repository removal, full app uninstall, compact report deletion, queue cancellation, limited audit record retention, repeated cleanup idempotency, and user-facing deletion wording.
|
|
56
|
+
|
|
57
|
+
Hosted pricing and packaging boundaries are scoped in [docs/hosted-pricing-packaging.md](hosted-pricing-packaging.md). Core local scanning stays useful without an account; future hosted plans may charge for workflow convenience, saved reports, team policy, private repo hosted behavior, and optional human review, but not for access to useful local scanning.
|
|
58
|
+
|
|
59
|
+
Hosted pre-implementation pure contracts are scoped in [docs/hosted-preimplementation-contracts.md](hosted-preimplementation-contracts.md). They define service-free helpers such as queue-safe pull request event parsing, bounded check-run summary rendering, idempotent queue cleanup planning, and worker checkout cleanup planning before any hosted ingress, queue, worker, or GitHub API integration is added.
|
|
60
|
+
|
|
61
|
+
The hosted compact report schema fixture is published at [examples/hosted-compact-report.json](../examples/hosted-compact-report.json). It is synthetic and public-safe, and it documents the compact evidence shape without raw source, raw diffs, secrets, customer payloads, or worker checkout paths.
|
|
62
|
+
|
|
49
63
|
## Least-Privilege Permissions
|
|
50
64
|
|
|
51
65
|
Use least-privilege permissions and install on selected repositories only. This section is the Implementation-ready permission contract for the first hosted GitHub App version.
|