ai-saas-guard 0.8.0 → 0.9.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 +10 -4
- package/dist/hosted/contracts.d.ts +114 -0
- package/dist/hosted/contracts.js +154 -0
- package/dist/scanners/stripe.js +4 -1
- package/docs/github-action.md +1 -1
- package/docs/github-app-design.md +126 -22
- package/docs/npm-publishing.md +3 -3
- package/docs/project-handoff.md +15 -7
- package/docs/rules.md +1 -1
- package/docs/stripe-webhook-replay.md +21 -0
- package/package.json +5 -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.9.0`, `v0` |
|
|
55
|
+
| npm package | `ai-saas-guard@0.9.0` |
|
|
56
56
|
| npm publishing | Trusted Publisher/OIDC, no long-lived publish token |
|
|
57
57
|
|
|
58
58
|
## Quick Start
|
|
@@ -180,7 +180,13 @@ Use [docs/stripe-webhook-replay.md](docs/stripe-webhook-replay.md) after `check-
|
|
|
180
180
|
|
|
181
181
|
## Hosted GitHub App Design
|
|
182
182
|
|
|
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, webhook verification, PR comments, check runs, privacy, data retention, prompt injection handling, and why the hosted app should not replace the local CLI.
|
|
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
|
+
|
|
185
|
+
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
|
+
|
|
187
|
+
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.
|
|
188
|
+
|
|
189
|
+
Users should prefer the local CLI for private repositories, offline review, or no-account workflows where hosted code processing is not acceptable.
|
|
184
190
|
|
|
185
191
|
## Project Configuration
|
|
186
192
|
|
|
@@ -212,7 +218,7 @@ Use `suppressions` for narrower false-positive handling when one rule is noisy o
|
|
|
212
218
|
|
|
213
219
|
## GitHub Action
|
|
214
220
|
|
|
215
|
-
The repo includes a composite Action. Use `v0` for the latest compatible pre-1.0 Action, a specific release tag such as `v0.
|
|
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:
|
|
216
222
|
|
|
217
223
|
```yaml
|
|
218
224
|
name: ai-saas-guard
|
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
export type WebhookRejectReason = "missing_signature" | "malformed_signature" | "invalid_signature" | "replayed_delivery_id";
|
|
2
|
+
export interface GitHubWebhookDecision {
|
|
3
|
+
accepted: boolean;
|
|
4
|
+
reason?: WebhookRejectReason;
|
|
5
|
+
shouldQueueScanJob: boolean;
|
|
6
|
+
shouldFetchRepository: boolean;
|
|
7
|
+
deliveryId?: string;
|
|
8
|
+
}
|
|
9
|
+
export interface GitHubWebhookInput {
|
|
10
|
+
payload: string | Buffer;
|
|
11
|
+
signatureHeader?: string;
|
|
12
|
+
signingKey: string | Buffer;
|
|
13
|
+
deliveryId?: string;
|
|
14
|
+
seenDeliveryIds?: Set<string>;
|
|
15
|
+
}
|
|
16
|
+
export interface HostedScanIdentityInput {
|
|
17
|
+
installationId: number;
|
|
18
|
+
repositoryId: number;
|
|
19
|
+
repositoryFullName: string;
|
|
20
|
+
pullRequestNumber: number;
|
|
21
|
+
baseSha: string;
|
|
22
|
+
headSha: string;
|
|
23
|
+
scannerVersion: string;
|
|
24
|
+
untrustedPrText?: string;
|
|
25
|
+
}
|
|
26
|
+
export interface HostedScanIdentity {
|
|
27
|
+
installationId: number;
|
|
28
|
+
repositoryId: number;
|
|
29
|
+
repositoryFullName: string;
|
|
30
|
+
pullRequestNumber: number;
|
|
31
|
+
baseSha: string;
|
|
32
|
+
headSha: string;
|
|
33
|
+
scannerVersion: string;
|
|
34
|
+
}
|
|
35
|
+
export type InstallationScopeRejectReason = "installation_mismatch" | "repository_not_installed" | "repository_removed_from_installation";
|
|
36
|
+
export interface InstallationScopeInput {
|
|
37
|
+
identity: HostedScanIdentity;
|
|
38
|
+
installationId: number;
|
|
39
|
+
selectedRepositoryIds: number[];
|
|
40
|
+
removedRepositoryIds?: number[];
|
|
41
|
+
}
|
|
42
|
+
export interface InstallationScopeDecision {
|
|
43
|
+
authorized: boolean;
|
|
44
|
+
shouldFetchSource: boolean;
|
|
45
|
+
reason?: InstallationScopeRejectReason;
|
|
46
|
+
}
|
|
47
|
+
export interface HostedScanJobState {
|
|
48
|
+
key: string;
|
|
49
|
+
attempt: number;
|
|
50
|
+
deliveryIds: string[];
|
|
51
|
+
}
|
|
52
|
+
export interface HostedScanJobInput {
|
|
53
|
+
identity: HostedScanIdentity;
|
|
54
|
+
deliveryId: string;
|
|
55
|
+
manualRerun?: boolean;
|
|
56
|
+
}
|
|
57
|
+
export interface HostedScanJobDecision {
|
|
58
|
+
key: string;
|
|
59
|
+
created: boolean;
|
|
60
|
+
reusedExistingReport: boolean;
|
|
61
|
+
attempt: number;
|
|
62
|
+
shouldCreateCheckRun: boolean;
|
|
63
|
+
shouldCreatePrComment: boolean;
|
|
64
|
+
}
|
|
65
|
+
export interface CompactHostedFinding {
|
|
66
|
+
ruleId: string;
|
|
67
|
+
severity: string;
|
|
68
|
+
file: string;
|
|
69
|
+
line?: number;
|
|
70
|
+
}
|
|
71
|
+
export interface CompactHostedReportInput {
|
|
72
|
+
identity: HostedScanIdentity;
|
|
73
|
+
summaryCounts: Record<string, number>;
|
|
74
|
+
findings: CompactHostedFinding[];
|
|
75
|
+
retentionDays?: number;
|
|
76
|
+
rawDiff?: string;
|
|
77
|
+
fullFileContents?: string;
|
|
78
|
+
secretValues?: string[];
|
|
79
|
+
customerPayload?: unknown;
|
|
80
|
+
}
|
|
81
|
+
export interface CompactHostedReport {
|
|
82
|
+
installationId: number;
|
|
83
|
+
repositoryId: number;
|
|
84
|
+
repositoryFullName: string;
|
|
85
|
+
pullRequestNumber: number;
|
|
86
|
+
baseSha: string;
|
|
87
|
+
headSha: string;
|
|
88
|
+
scannerVersion: string;
|
|
89
|
+
summaryCounts: Record<string, number>;
|
|
90
|
+
ruleIds: string[];
|
|
91
|
+
evidence: Array<{
|
|
92
|
+
ruleId: string;
|
|
93
|
+
severity: string;
|
|
94
|
+
file: string;
|
|
95
|
+
line?: number;
|
|
96
|
+
}>;
|
|
97
|
+
retentionDays: number;
|
|
98
|
+
modelTraining: "disabled";
|
|
99
|
+
workerCheckoutDeletion: "after_scan_completion";
|
|
100
|
+
}
|
|
101
|
+
export declare const HOSTED_PRIVACY_DEFAULTS: {
|
|
102
|
+
readonly retentionDays: 30;
|
|
103
|
+
readonly modelTraining: "disabled";
|
|
104
|
+
readonly deleteWorkerCheckout: "after_scan_completion";
|
|
105
|
+
};
|
|
106
|
+
export declare function verifyGitHubWebhook(input: GitHubWebhookInput): GitHubWebhookDecision;
|
|
107
|
+
export declare function buildHostedScanIdentity(input: HostedScanIdentityInput): HostedScanIdentity;
|
|
108
|
+
export declare function authorizeInstallationTokenScope(input: InstallationScopeInput): InstallationScopeDecision;
|
|
109
|
+
export declare function getHostedScanIdempotencyKey(identity: HostedScanIdentity): string;
|
|
110
|
+
export declare function upsertHostedScanJob(queue: Map<string, HostedScanJobState>, input: HostedScanJobInput): HostedScanJobDecision;
|
|
111
|
+
export declare function resolveHostedRetentionDays(input?: {
|
|
112
|
+
teamRequestedDays?: number;
|
|
113
|
+
}): number;
|
|
114
|
+
export declare function createCompactHostedReport(input: CompactHostedReportInput): CompactHostedReport;
|
|
@@ -0,0 +1,154 @@
|
|
|
1
|
+
import { createHmac, timingSafeEqual } from "node:crypto";
|
|
2
|
+
export const HOSTED_PRIVACY_DEFAULTS = {
|
|
3
|
+
retentionDays: 30,
|
|
4
|
+
modelTraining: "disabled",
|
|
5
|
+
deleteWorkerCheckout: "after_scan_completion"
|
|
6
|
+
};
|
|
7
|
+
export function verifyGitHubWebhook(input) {
|
|
8
|
+
const { deliveryId, seenDeliveryIds, signatureHeader } = input;
|
|
9
|
+
if (!signatureHeader) {
|
|
10
|
+
return rejectWebhook("missing_signature", deliveryId);
|
|
11
|
+
}
|
|
12
|
+
const parsedSignature = parseSha256Signature(signatureHeader);
|
|
13
|
+
if (!parsedSignature) {
|
|
14
|
+
return rejectWebhook("malformed_signature", deliveryId);
|
|
15
|
+
}
|
|
16
|
+
const expected = createHmac("sha256", input.signingKey).update(input.payload).digest();
|
|
17
|
+
if (!timingSafeEqual(parsedSignature, expected)) {
|
|
18
|
+
return rejectWebhook("invalid_signature", deliveryId);
|
|
19
|
+
}
|
|
20
|
+
if (deliveryId && seenDeliveryIds?.has(deliveryId)) {
|
|
21
|
+
return rejectWebhook("replayed_delivery_id", deliveryId);
|
|
22
|
+
}
|
|
23
|
+
if (deliveryId) {
|
|
24
|
+
seenDeliveryIds?.add(deliveryId);
|
|
25
|
+
}
|
|
26
|
+
return {
|
|
27
|
+
accepted: true,
|
|
28
|
+
shouldQueueScanJob: true,
|
|
29
|
+
shouldFetchRepository: false,
|
|
30
|
+
deliveryId
|
|
31
|
+
};
|
|
32
|
+
}
|
|
33
|
+
export function buildHostedScanIdentity(input) {
|
|
34
|
+
return {
|
|
35
|
+
installationId: input.installationId,
|
|
36
|
+
repositoryId: input.repositoryId,
|
|
37
|
+
repositoryFullName: input.repositoryFullName,
|
|
38
|
+
pullRequestNumber: input.pullRequestNumber,
|
|
39
|
+
baseSha: input.baseSha,
|
|
40
|
+
headSha: input.headSha,
|
|
41
|
+
scannerVersion: input.scannerVersion
|
|
42
|
+
};
|
|
43
|
+
}
|
|
44
|
+
export function authorizeInstallationTokenScope(input) {
|
|
45
|
+
if (input.installationId !== input.identity.installationId) {
|
|
46
|
+
return rejectInstallationScope("installation_mismatch");
|
|
47
|
+
}
|
|
48
|
+
if (input.removedRepositoryIds?.includes(input.identity.repositoryId)) {
|
|
49
|
+
return rejectInstallationScope("repository_removed_from_installation");
|
|
50
|
+
}
|
|
51
|
+
if (!input.selectedRepositoryIds.includes(input.identity.repositoryId)) {
|
|
52
|
+
return rejectInstallationScope("repository_not_installed");
|
|
53
|
+
}
|
|
54
|
+
return {
|
|
55
|
+
authorized: true,
|
|
56
|
+
shouldFetchSource: true
|
|
57
|
+
};
|
|
58
|
+
}
|
|
59
|
+
export function getHostedScanIdempotencyKey(identity) {
|
|
60
|
+
return [
|
|
61
|
+
identity.installationId,
|
|
62
|
+
identity.repositoryId,
|
|
63
|
+
identity.pullRequestNumber,
|
|
64
|
+
identity.headSha,
|
|
65
|
+
identity.scannerVersion
|
|
66
|
+
].join(":");
|
|
67
|
+
}
|
|
68
|
+
export function upsertHostedScanJob(queue, input) {
|
|
69
|
+
const key = getHostedScanIdempotencyKey(input.identity);
|
|
70
|
+
const existing = queue.get(key);
|
|
71
|
+
if (!existing) {
|
|
72
|
+
queue.set(key, {
|
|
73
|
+
key,
|
|
74
|
+
attempt: 1,
|
|
75
|
+
deliveryIds: [input.deliveryId]
|
|
76
|
+
});
|
|
77
|
+
return {
|
|
78
|
+
key,
|
|
79
|
+
created: true,
|
|
80
|
+
reusedExistingReport: false,
|
|
81
|
+
attempt: 1,
|
|
82
|
+
shouldCreateCheckRun: true,
|
|
83
|
+
shouldCreatePrComment: true
|
|
84
|
+
};
|
|
85
|
+
}
|
|
86
|
+
if (!existing.deliveryIds.includes(input.deliveryId)) {
|
|
87
|
+
existing.deliveryIds.push(input.deliveryId);
|
|
88
|
+
}
|
|
89
|
+
if (input.manualRerun) {
|
|
90
|
+
existing.attempt += 1;
|
|
91
|
+
}
|
|
92
|
+
return {
|
|
93
|
+
key,
|
|
94
|
+
created: false,
|
|
95
|
+
reusedExistingReport: true,
|
|
96
|
+
attempt: existing.attempt,
|
|
97
|
+
shouldCreateCheckRun: false,
|
|
98
|
+
shouldCreatePrComment: false
|
|
99
|
+
};
|
|
100
|
+
}
|
|
101
|
+
export function resolveHostedRetentionDays(input = {}) {
|
|
102
|
+
if (input.teamRequestedDays === undefined) {
|
|
103
|
+
return HOSTED_PRIVACY_DEFAULTS.retentionDays;
|
|
104
|
+
}
|
|
105
|
+
const requestedDays = Math.floor(input.teamRequestedDays);
|
|
106
|
+
return Math.min(HOSTED_PRIVACY_DEFAULTS.retentionDays, Math.max(1, requestedDays));
|
|
107
|
+
}
|
|
108
|
+
export function createCompactHostedReport(input) {
|
|
109
|
+
const { identity } = input;
|
|
110
|
+
const ruleIds = [...new Set(input.findings.map((finding) => finding.ruleId))];
|
|
111
|
+
return {
|
|
112
|
+
installationId: identity.installationId,
|
|
113
|
+
repositoryId: identity.repositoryId,
|
|
114
|
+
repositoryFullName: identity.repositoryFullName,
|
|
115
|
+
pullRequestNumber: identity.pullRequestNumber,
|
|
116
|
+
baseSha: identity.baseSha,
|
|
117
|
+
headSha: identity.headSha,
|
|
118
|
+
scannerVersion: identity.scannerVersion,
|
|
119
|
+
summaryCounts: { ...input.summaryCounts },
|
|
120
|
+
ruleIds,
|
|
121
|
+
evidence: input.findings.map((finding) => ({
|
|
122
|
+
ruleId: finding.ruleId,
|
|
123
|
+
severity: finding.severity,
|
|
124
|
+
file: finding.file,
|
|
125
|
+
...(finding.line === undefined ? {} : { line: finding.line })
|
|
126
|
+
})),
|
|
127
|
+
retentionDays: resolveHostedRetentionDays({ teamRequestedDays: input.retentionDays }),
|
|
128
|
+
modelTraining: HOSTED_PRIVACY_DEFAULTS.modelTraining,
|
|
129
|
+
workerCheckoutDeletion: HOSTED_PRIVACY_DEFAULTS.deleteWorkerCheckout
|
|
130
|
+
};
|
|
131
|
+
}
|
|
132
|
+
function rejectWebhook(reason, deliveryId) {
|
|
133
|
+
return {
|
|
134
|
+
accepted: false,
|
|
135
|
+
reason,
|
|
136
|
+
shouldQueueScanJob: false,
|
|
137
|
+
shouldFetchRepository: false,
|
|
138
|
+
deliveryId
|
|
139
|
+
};
|
|
140
|
+
}
|
|
141
|
+
function rejectInstallationScope(reason) {
|
|
142
|
+
return {
|
|
143
|
+
authorized: false,
|
|
144
|
+
shouldFetchSource: false,
|
|
145
|
+
reason
|
|
146
|
+
};
|
|
147
|
+
}
|
|
148
|
+
function parseSha256Signature(signatureHeader) {
|
|
149
|
+
const match = /^sha256=([0-9a-f]{64})$/i.exec(signatureHeader.trim());
|
|
150
|
+
if (!match?.[1]) {
|
|
151
|
+
return undefined;
|
|
152
|
+
}
|
|
153
|
+
return Buffer.from(match[1], "hex");
|
|
154
|
+
}
|
package/dist/scanners/stripe.js
CHANGED
|
@@ -3,11 +3,12 @@ import { createReport, finding, uniqueFindings } from "../report/findings.js";
|
|
|
3
3
|
import { lineAt, lineNumberForIndex } from "../utils/files.js";
|
|
4
4
|
const criticalEvents = [
|
|
5
5
|
"invoice.payment_failed",
|
|
6
|
+
"invoice.payment_action_required",
|
|
6
7
|
"customer.subscription.deleted",
|
|
7
8
|
"customer.subscription.updated",
|
|
8
9
|
"charge.refunded"
|
|
9
10
|
];
|
|
10
|
-
const eventPattern = /["']((?:checkout\.session\.completed|invoice\.payment_failed|customer\.subscription\.(?:deleted|updated|created)|charge\.(?:refunded|dispute\.created)|refund\.(?:created|updated)))["']/g;
|
|
11
|
+
const eventPattern = /["']((?:checkout\.session\.completed|invoice\.(?:payment_failed|payment_action_required)|customer\.subscription\.(?:deleted|updated|created)|charge\.(?:refunded|dispute\.created)|refund\.(?:created|updated)))["']/g;
|
|
11
12
|
export async function checkStripe(input) {
|
|
12
13
|
const context = await resolveScanContext(input);
|
|
13
14
|
const runtimeFiles = context.files.filter((file) => !isDocumentationFile(file.path));
|
|
@@ -32,6 +33,7 @@ export async function checkStripe(input) {
|
|
|
32
33
|
testCommands: [
|
|
33
34
|
"stripe trigger checkout.session.completed",
|
|
34
35
|
"stripe trigger invoice.payment_failed",
|
|
36
|
+
"stripe trigger invoice.payment_action_required",
|
|
35
37
|
"stripe trigger customer.subscription.updated",
|
|
36
38
|
"stripe trigger customer.subscription.deleted",
|
|
37
39
|
"stripe trigger charge.refunded"
|
|
@@ -143,6 +145,7 @@ export async function checkStripe(input) {
|
|
|
143
145
|
testCommands: [
|
|
144
146
|
"stripe trigger checkout.session.completed",
|
|
145
147
|
"stripe trigger invoice.payment_failed",
|
|
148
|
+
"stripe trigger invoice.payment_action_required",
|
|
146
149
|
"stripe trigger customer.subscription.updated",
|
|
147
150
|
"stripe trigger customer.subscription.deleted",
|
|
148
151
|
"stripe trigger charge.refunded"
|
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.9.0` or a reviewed commit SHA when reproducibility is more important than automatic minor updates.
|
|
6
6
|
|
|
7
7
|
## PR Summary
|
|
8
8
|
|
|
@@ -48,31 +48,48 @@ The first version should prefer check runs over noisy PR comments. PR comments s
|
|
|
48
48
|
|
|
49
49
|
## Least-Privilege Permissions
|
|
50
50
|
|
|
51
|
-
Use least-privilege permissions and install on selected repositories only.
|
|
51
|
+
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.
|
|
52
52
|
|
|
53
|
-
|
|
53
|
+
### Required First-Version Permissions
|
|
54
54
|
|
|
55
|
-
|
|
56
|
-
| --- | --- | --- |
|
|
57
|
-
| repository contents: read | Read | Fetch files or diffs needed for deterministic scans. |
|
|
58
|
-
| pull requests: read | Read | Read PR metadata, changed files, and base/head refs. |
|
|
59
|
-
| checks: write | Write | Publish a check run with summary, findings count, and report link. |
|
|
60
|
-
| metadata: read | Read | Required by GitHub Apps for repository identity. |
|
|
55
|
+
Only these permissions are required for the first hosted version. No required permission may be added without a new public issue that explains the product need, user impact, security tradeoff, and rollback path.
|
|
61
56
|
|
|
62
|
-
|
|
57
|
+
| Status | Permission | Access | Default | Why |
|
|
58
|
+
| --- | --- | --- | --- | --- |
|
|
59
|
+
| Required | repository contents: read | Read | Enabled | Fetch files or diffs needed for deterministic scans. |
|
|
60
|
+
| Required | pull requests: read | Read | Enabled | Read PR metadata, changed files, and base/head refs. |
|
|
61
|
+
| Required | checks: write | Write | Enabled | Publish a check run with summary, findings count, and report link. |
|
|
62
|
+
| Required | metadata: read | Read | Enabled | Required by GitHub Apps for repository identity. |
|
|
63
63
|
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
64
|
+
### Optional Permissions
|
|
65
|
+
|
|
66
|
+
Optional permissions are disabled by default and must be enabled through repository policy opt-in. Pull request comments require repository policy before the app can post or update any comment.
|
|
67
|
+
|
|
68
|
+
| Status | Permission | Access | Default | Why |
|
|
69
|
+
| --- | --- | --- | --- | --- |
|
|
70
|
+
| Optional | pull requests: write | Write | Disabled | Post one upserted PR comment only when repository policy opt-in enables comments. |
|
|
71
|
+
| Optional | issues: write | Write | Disabled | Only if GitHub represents PR comments through issue comment APIs for the chosen comment workflow. |
|
|
72
|
+
|
|
73
|
+
### Install Boundary
|
|
74
|
+
|
|
75
|
+
Selected repositories only:
|
|
76
|
+
|
|
77
|
+
- The app should default to selected repository installation, not all repositories.
|
|
78
|
+
- Repository admins should be able to remove a repository from the installation without affecting local CLI use.
|
|
79
|
+
- No organization-wide installation requirement.
|
|
80
|
+
- The hosted app does not replace the local CLI for repositories that do not install it.
|
|
81
|
+
|
|
82
|
+
### Out-Of-Scope Permissions
|
|
68
83
|
|
|
69
84
|
Avoid broad permissions:
|
|
70
85
|
|
|
71
86
|
- No administration permission for the first version.
|
|
72
|
-
- No secrets permission.
|
|
73
87
|
- No deployments permission.
|
|
74
|
-
- No actions write permission.
|
|
75
|
-
- No
|
|
88
|
+
- No actions: write permission.
|
|
89
|
+
- No repository secrets permission.
|
|
90
|
+
- No organization secrets permission.
|
|
91
|
+
- No repository security-events write permission unless a later public issue scopes SARIF upload behavior.
|
|
92
|
+
- No organization-wide installation requirement.
|
|
76
93
|
|
|
77
94
|
## Event Model
|
|
78
95
|
|
|
@@ -96,6 +113,21 @@ Additional requirements:
|
|
|
96
113
|
- Make scan jobs idempotent by installation, repository, PR, head SHA, and scanner version.
|
|
97
114
|
- Rate-limit repeated events for the same PR and commit.
|
|
98
115
|
|
|
116
|
+
### Webhook Verification Test Contract
|
|
117
|
+
|
|
118
|
+
Before hosted implementation starts, automated tests must cover:
|
|
119
|
+
|
|
120
|
+
- valid signature requests queue exactly one scan request.
|
|
121
|
+
- invalid signature requests are rejected.
|
|
122
|
+
- missing signature requests are rejected.
|
|
123
|
+
- malformed signature requests are rejected.
|
|
124
|
+
- replayed delivery ID requests are rejected or treated as already processed.
|
|
125
|
+
- duplicate event requests for the same delivery and PR head do not create duplicate scan work.
|
|
126
|
+
|
|
127
|
+
Failed verification produces no scan job and no repository fetch. The webhook handler must verify the signed-webhook boundary before any queue write, installation lookup, token lookup, repository lookup, or worker dispatch.
|
|
128
|
+
|
|
129
|
+
Fixtures for these tests must use no real credentials and no customer payloads. Payload examples should be reduced synthetic GitHub-shaped JSON with inert repository names, fake installation IDs, and fake SHAs.
|
|
130
|
+
|
|
99
131
|
## Data Flow
|
|
100
132
|
|
|
101
133
|
The hosted app should keep the data flow simple and inspectable:
|
|
@@ -108,7 +140,46 @@ The hosted app should keep the data flow simple and inspectable:
|
|
|
108
140
|
6. The app writes a check run and optional PR comment.
|
|
109
141
|
7. The report is retained according to team policy.
|
|
110
142
|
|
|
111
|
-
|
|
143
|
+
### Installation Token Scoping Test Contract
|
|
144
|
+
|
|
145
|
+
Every hosted scan request should carry explicit identity fields:
|
|
146
|
+
|
|
147
|
+
- `installationId`
|
|
148
|
+
- `repositoryId`
|
|
149
|
+
- `repositoryFullName`
|
|
150
|
+
- `pullRequestNumber`
|
|
151
|
+
- `baseSha`
|
|
152
|
+
- `headSha`
|
|
153
|
+
- `scannerVersion`
|
|
154
|
+
|
|
155
|
+
Automated tests must cover selected-repository installs, non-installed repositories, repository removal from an installation, and mismatched installation IDs.
|
|
156
|
+
|
|
157
|
+
Worker behavior:
|
|
158
|
+
|
|
159
|
+
- Token lookup must use `installationId` and `repositoryId` from the verified GitHub event, not from PR title, body, branch name, comments, README, or code.
|
|
160
|
+
- A token lookup failure stops before source fetch.
|
|
161
|
+
- Workers must never accept repository identity from untrusted PR text.
|
|
162
|
+
- Workers must keep read-only worker behavior: fetch content or diffs, run the scanner, write checks or permitted comments, and avoid repository mutation.
|
|
163
|
+
|
|
164
|
+
### Hosted Scan Queue Idempotency Test Contract
|
|
165
|
+
|
|
166
|
+
The idempotency key is:
|
|
167
|
+
|
|
168
|
+
```text
|
|
169
|
+
installationId:repositoryId:pullRequestNumber:headSha:scannerVersion
|
|
170
|
+
```
|
|
171
|
+
|
|
172
|
+
Automated tests must cover duplicate webhook deliveries, rapid synchronize events, manual reruns, and scanner version changes.
|
|
173
|
+
|
|
174
|
+
Queue behavior:
|
|
175
|
+
|
|
176
|
+
- Repeated events with the same idempotency key should reuse the existing report or update the existing queued/running job.
|
|
177
|
+
- Repeated events must produce no duplicate check runs and no duplicate PR comments.
|
|
178
|
+
- Manual reruns may create a new attempt record, but they should keep the same logical report identity unless `headSha` or `scannerVersion` changes.
|
|
179
|
+
- Scanner version changes should create a distinct scan identity so teams can compare old and new rule behavior.
|
|
180
|
+
- Failure and retry state should be observable by installation, repository, PR, head SHA, scanner version, attempt number, and error class without logging raw source content.
|
|
181
|
+
|
|
182
|
+
Prefer storing these compact report fields:
|
|
112
183
|
|
|
113
184
|
- repository ID and name
|
|
114
185
|
- PR number
|
|
@@ -120,7 +191,7 @@ Prefer storing:
|
|
|
120
191
|
- reviewer checklist
|
|
121
192
|
- suppression policy version
|
|
122
193
|
|
|
123
|
-
Avoid storing by default:
|
|
194
|
+
Avoid storing these fields by default:
|
|
124
195
|
|
|
125
196
|
- full file contents
|
|
126
197
|
- raw diffs
|
|
@@ -130,17 +201,44 @@ Avoid storing by default:
|
|
|
130
201
|
|
|
131
202
|
## Privacy And Data Retention
|
|
132
203
|
|
|
204
|
+
### Privacy And Data Retention Contract
|
|
205
|
+
|
|
133
206
|
Privacy should be a first-version feature, not a later add-on.
|
|
134
207
|
|
|
135
|
-
Default
|
|
208
|
+
Default app-side retention is 30 days for compact scan reports.
|
|
209
|
+
|
|
210
|
+
Stored fields:
|
|
211
|
+
|
|
212
|
+
- repository ID and name
|
|
213
|
+
- PR number
|
|
214
|
+
- base SHA and head SHA
|
|
215
|
+
- scanner version
|
|
216
|
+
- summary counts
|
|
217
|
+
- rule IDs
|
|
218
|
+
- evidence file paths and line numbers
|
|
219
|
+
- reviewer checklist
|
|
220
|
+
- suppression policy version
|
|
221
|
+
- scan state, attempt number, and error class
|
|
222
|
+
|
|
223
|
+
Avoided fields:
|
|
224
|
+
|
|
225
|
+
- full file contents
|
|
226
|
+
- raw diffs
|
|
227
|
+
- secrets or matched secret values
|
|
228
|
+
- generated logs with unredacted code snippets
|
|
229
|
+
- customer data copied from application fixtures
|
|
230
|
+
- private issue text, private comments, or unrelated repository documents that are not needed for the scan
|
|
231
|
+
|
|
232
|
+
Retention and deletion rules:
|
|
136
233
|
|
|
137
|
-
- Keep compact scan reports for 30 days.
|
|
138
234
|
- Keep check run and PR comment content in GitHub as controlled by the customer's repository settings.
|
|
139
|
-
-
|
|
235
|
+
- Raw worker checkout directories are deleted after scan completion.
|
|
140
236
|
- Do not train models on customer code or findings.
|
|
141
237
|
- Provide repository uninstall cleanup for stored app-side records.
|
|
238
|
+
- Team admins can shorten retention.
|
|
239
|
+
- Longer retention must be explicit and tied to paid audit history.
|
|
142
240
|
|
|
143
|
-
|
|
241
|
+
Users should prefer the local CLI for private repositories, offline review, strict no-account workflows, or repositories where hosted processing is not acceptable.
|
|
144
242
|
|
|
145
243
|
## Prompt Injection Handling
|
|
146
244
|
|
|
@@ -260,3 +358,9 @@ Do not start implementation until the hosted app has:
|
|
|
260
358
|
- privacy and data retention docs
|
|
261
359
|
- prompt injection abuse-case tests if AI summaries are included
|
|
262
360
|
- release gate evidence equivalent to the CLI package process
|
|
361
|
+
|
|
362
|
+
Current pre-implementation gate evidence:
|
|
363
|
+
|
|
364
|
+
- `tests/guard.test.mjs` verifies that this public design note keeps the permission, webhook, token scoping, idempotency, privacy, and retention contracts documented.
|
|
365
|
+
- `tests/hosted-contracts.test.mjs` verifies pure contract helpers for valid signature, invalid signature, missing signature, malformed signature, replayed delivery ID, selected-repository installs, non-installed repositories, repository removal from an installation, mismatched installation IDs, deterministic queue idempotency, duplicate event reuse, manual reruns, scanner version changes, compact reports, retention limits, and raw-source exclusion.
|
|
366
|
+
- `src/hosted/contracts.ts` is intentionally service-free: it does not start a server, call GitHub, request tokens, fetch repositories, write comments, or persist reports.
|
package/docs/npm-publishing.md
CHANGED
|
@@ -5,10 +5,10 @@
|
|
|
5
5
|
## Current State
|
|
6
6
|
|
|
7
7
|
- Package name: `ai-saas-guard`
|
|
8
|
-
- Current version: `0.
|
|
8
|
+
- Current version: `0.9.0`
|
|
9
9
|
- npm registry state: published at <https://www.npmjs.com/package/ai-saas-guard>
|
|
10
10
|
- First npm-published version: `0.1.1`
|
|
11
|
-
- GitHub Release: `v0.
|
|
11
|
+
- GitHub Release: `v0.9.0`
|
|
12
12
|
- Publish workflow: `.github/workflows/npm-publish.yml`
|
|
13
13
|
- Trusted Publisher: GitHub Actions, `zr9959/ai-saas-guard`, workflow `npm-publish.yml`, allowed action `npm publish`
|
|
14
14
|
- Long-lived npm publish token: not required
|
|
@@ -17,7 +17,7 @@
|
|
|
17
17
|
|
|
18
18
|
Use GitHub Actions with npm Trusted Publisher/OIDC:
|
|
19
19
|
|
|
20
|
-
1. Create and review a release tag such as `v0.
|
|
20
|
+
1. Create and review a release tag such as `v0.9.0`.
|
|
21
21
|
2. Publish from the GitHub Release or run the `Publish npm` workflow manually with `ref` set to that tag.
|
|
22
22
|
3. Keep `permissions.id-token: write` in the workflow so npm can exchange the GitHub Actions OIDC identity for a short-lived publish credential.
|
|
23
23
|
4. Run `npm publish --access public` from the workflow. Trusted publishing automatically generates provenance for this public package from this public repository.
|
package/docs/project-handoff.md
CHANGED
|
@@ -40,8 +40,8 @@ Implemented surfaces:
|
|
|
40
40
|
|
|
41
41
|
- secret-like values and risky public env exposure
|
|
42
42
|
- founder-readable launch-readiness checklist for two-account authorization, Stripe webhook verification, MCP config review, Supabase, deploy, CI, and rollback checks
|
|
43
|
-
- Stripe webhook signature, raw body, idempotency, and lifecycle handler heuristics
|
|
44
|
-
- Stripe webhook replay cookbook for checkout, renewal failure, updates, cancellation, refunds, duplicate delivery, and out-of-order review
|
|
43
|
+
- Stripe webhook signature, raw body, idempotency, and lifecycle handler heuristics, including payment-action-required invoice recovery
|
|
44
|
+
- Stripe webhook replay cookbook for checkout, renewal failure, payment action required, updates, cancellation, refunds, duplicate delivery, and out-of-order review
|
|
45
45
|
- Supabase RLS, tenant membership, ownership filter, weak `WITH CHECK`, and storage object policy heuristics
|
|
46
46
|
- sensitive API route heuristics
|
|
47
47
|
- MCP config side-effect and secret-bearing risk inventory
|
|
@@ -52,6 +52,10 @@ Implemented surfaces:
|
|
|
52
52
|
- project-local `.ai-saas-guard.json` config for rule toggles, severity overrides, path-specific suppressions, and default fail thresholds
|
|
53
53
|
- rule stability labels in catalog metadata, public rule docs, and SARIF rule properties
|
|
54
54
|
- hosted GitHub App design note covering least-privilege permissions, webhook verification, privacy, data retention, prompt-injection handling, and implementation gates
|
|
55
|
+
- implementation-ready hosted GitHub App permission contract for required permissions, optional PR comment permissions, selected repository installation, and out-of-scope broad permissions
|
|
56
|
+
- pure hosted GitHub App contract helpers and tests for webhook verification, installation token scoping, scan queue idempotency, compact reports, and retention limits
|
|
57
|
+
- GitHub issue templates for bug reports, false positives, false negatives, rule requests, and public-safe security reports
|
|
58
|
+
- CODEOWNERS for source, tests, docs, workflows, Action, and package metadata
|
|
55
59
|
- JSON output
|
|
56
60
|
- SARIF output
|
|
57
61
|
- composite GitHub Action wrapper
|
|
@@ -103,7 +107,11 @@ GitHub Project:
|
|
|
103
107
|
|
|
104
108
|
Current issue set:
|
|
105
109
|
|
|
106
|
-
-
|
|
110
|
+
- #14 `Roadmap: define first hosted service slice`
|
|
111
|
+
- #15 `Roadmap: choose hosted app deployment model`
|
|
112
|
+
- #16 `Roadmap: define hosted operational release gate`
|
|
113
|
+
- #17 `Roadmap: define hosted uninstall and data deletion`
|
|
114
|
+
- #18 `Roadmap: define hosted pricing and packaging boundaries`
|
|
107
115
|
|
|
108
116
|
CI:
|
|
109
117
|
|
|
@@ -115,7 +123,7 @@ CI:
|
|
|
115
123
|
Publishing:
|
|
116
124
|
|
|
117
125
|
- npm package: `ai-saas-guard`
|
|
118
|
-
- Current release line: `v0.
|
|
126
|
+
- Current release line: `v0.9.0`
|
|
119
127
|
- Publish workflow: `.github/workflows/npm-publish.yml`
|
|
120
128
|
- Trusted Publisher: GitHub Actions for `zr9959/ai-saas-guard`, workflow `npm-publish.yml`
|
|
121
129
|
- Long-lived npm publish tokens should not be required.
|
|
@@ -142,9 +150,9 @@ Not allowed:
|
|
|
142
150
|
|
|
143
151
|
Recommended order:
|
|
144
152
|
|
|
145
|
-
1.
|
|
146
|
-
2.
|
|
147
|
-
3.
|
|
153
|
+
1. Work #14 before writing hosted service code.
|
|
154
|
+
2. Work #15 and #16 before any hosted deployment.
|
|
155
|
+
3. Work #17 and #18 before private repo onboarding or paid hosted features.
|
|
148
156
|
|
|
149
157
|
For every feature, keep the scanner evidence-first:
|
|
150
158
|
|
package/docs/rules.md
CHANGED
|
@@ -51,7 +51,7 @@ Prefer fixing risky code over suppressing findings. When a finding is a reviewed
|
|
|
51
51
|
| `stripe.webhook.public-secret` | critical | Stripe secrets must not use `NEXT_PUBLIC_*`. |
|
|
52
52
|
| `stripe.webhook.missing-idempotency` | high | Stripe retries and duplicate events can drift billing state. |
|
|
53
53
|
| `stripe.webhook.no-entitlement-path` | medium | Returning HTTP 200 is not the same as changing app access state. |
|
|
54
|
-
| `stripe.webhook.missing-critical-event` | high/medium | Failure, cancellation, update, and refund paths need explicit handling. |
|
|
54
|
+
| `stripe.webhook.missing-critical-event` | high/medium | Failure, payment-action, cancellation, update, and refund paths need explicit handling. |
|
|
55
55
|
|
|
56
56
|
## Supabase
|
|
57
57
|
|
|
@@ -49,6 +49,7 @@ Run the commands one at a time. Watch both the `stripe listen` terminal and your
|
|
|
49
49
|
| --- | --- | --- |
|
|
50
50
|
| Checkout success | `stripe trigger checkout.session.completed` | The app maps the Checkout Session to the correct user or tenant and grants access only after signature verification. |
|
|
51
51
|
| Failed renewal | `stripe trigger invoice.payment_failed` | The app marks the subscription past-due, starts a grace path if intended, and does not leave unrestricted paid access forever. |
|
|
52
|
+
| Payment action required | `stripe trigger invoice.payment_action_required` | The app prompts recovery, records limited or pending access state, and does not leave paid access unrestricted without a policy. |
|
|
52
53
|
| Subscription update | `stripe trigger customer.subscription.updated` | Plan, quantity, cancel-at-period-end, period end, and status changes reconcile into local entitlement state. |
|
|
53
54
|
| Cancellation | `stripe trigger customer.subscription.deleted` | Access is revoked or downgraded deterministically for the correct customer or tenant. |
|
|
54
55
|
| Refund | `stripe trigger charge.refunded` | Refund handling does not accidentally grant access, double-credit an account, or ignore a required downgrade workflow. |
|
|
@@ -93,6 +94,24 @@ Review checklist:
|
|
|
93
94
|
- `stripe.webhook.missing-critical-event`
|
|
94
95
|
- `stripe.webhook.no-entitlement-path`
|
|
95
96
|
|
|
97
|
+
## Payment Action Required
|
|
98
|
+
|
|
99
|
+
```bash
|
|
100
|
+
stripe trigger invoice.payment_action_required
|
|
101
|
+
```
|
|
102
|
+
|
|
103
|
+
Review checklist:
|
|
104
|
+
|
|
105
|
+
- The app records a pending, past-due, or limited-access state instead of leaving unrestricted paid access indefinitely.
|
|
106
|
+
- Customer recovery messaging or billing portal access is queued if that is part of the product policy.
|
|
107
|
+
- The handler can later reconcile a successful payment or subscription update without duplicate access grants.
|
|
108
|
+
- Access checks use local entitlement state, not only the last successful checkout redirect.
|
|
109
|
+
|
|
110
|
+
`ai-saas-guard` findings this can validate:
|
|
111
|
+
|
|
112
|
+
- `stripe.webhook.missing-critical-event`
|
|
113
|
+
- `stripe.webhook.no-entitlement-path`
|
|
114
|
+
|
|
96
115
|
## Subscription Update
|
|
97
116
|
|
|
98
117
|
```bash
|
|
@@ -178,6 +197,7 @@ Use these questions during review:
|
|
|
178
197
|
|
|
179
198
|
- What happens if `customer.subscription.updated` arrives before the app processed `checkout.session.completed`?
|
|
180
199
|
- What happens if `invoice.payment_failed` arrives after a manual admin upgrade?
|
|
200
|
+
- What happens if `invoice.payment_action_required` arrives after the user has already recovered payment?
|
|
181
201
|
- What happens if `customer.subscription.deleted` arrives after a refund workflow already changed local access?
|
|
182
202
|
- Does the handler fetch or derive current subscription/customer state before writing final entitlement state?
|
|
183
203
|
- Is the local write guarded by Stripe customer, subscription, and tenant ownership, not just by event type?
|
|
@@ -193,6 +213,7 @@ Before launch or merge, a reviewer should be able to answer yes to each item:
|
|
|
193
213
|
- Every handled event stores or dedupes `event.id`.
|
|
194
214
|
- `checkout.session.completed` grants access through webhook reconciliation, not only redirect success.
|
|
195
215
|
- `invoice.payment_failed` has an explicit past-due or grace behavior.
|
|
216
|
+
- `invoice.payment_action_required` has an explicit recovery or limited-access behavior.
|
|
196
217
|
- `customer.subscription.updated` updates plan, quantity, period, cancellation, and status fields used by access checks.
|
|
197
218
|
- `customer.subscription.deleted` revokes or downgrades access for the correct user or tenant.
|
|
198
219
|
- `charge.refunded` has an explicit record, downgrade, or manual review path.
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "ai-saas-guard",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.9.0",
|
|
4
4
|
"description": "Repo-local launch-readiness scanner for AI-built SaaS apps.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"homepage": "https://github.com/zr9959/ai-saas-guard#readme",
|
|
@@ -29,6 +29,10 @@
|
|
|
29
29
|
".": {
|
|
30
30
|
"types": "./dist/index.d.ts",
|
|
31
31
|
"default": "./dist/index.js"
|
|
32
|
+
},
|
|
33
|
+
"./hosted/contracts": {
|
|
34
|
+
"types": "./dist/hosted/contracts.d.ts",
|
|
35
|
+
"default": "./dist/hosted/contracts.js"
|
|
32
36
|
}
|
|
33
37
|
},
|
|
34
38
|
"bin": {
|