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 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.9.0`, `v0` |
55
- | npm package | `ai-saas-guard@0.9.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.9.0` for controlled upgrades, or pin a reviewed commit SHA for stricter supply-chain control:
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;
@@ -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]) {
@@ -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.9.0` or a reviewed commit SHA when reproducibility is more important than automatic minor updates.
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.