Skip to content

Commit e678421

Browse files
authored
feat(security-questionnaire): add browser extension (#3064)
* feat(security-questionnaire): add browser extension * fix(security-questionnaire-extension): address review findings - Use a closed shadow root for the domain confirmation dialog so the host page cannot reach its controls. - Escape leading formula characters when building Sheets paste TSV, so answers starting with "-" stay text instead of being evaluated. - Restrict the wildcard trusted-origin match to HTTPS; explicit localhost entries are unaffected. - Stop generate-all from overwriting approved or manually edited answers. - Hold the inline preview locally so concurrent generates cannot render a result into another field's preview. - Finalize queue item status when single generation fails, instead of leaving the item stuck in "generating". - Implement the Google Docs footer copy action, which had no handler, and label/enable the button to match. Adds coverage for the paste plan, batch candidate selection, docs footer, clipboard text, and the origin policy. * fix(security-questionnaire-extension): make Google OAuth optional in release, add docs The release workflow required a Google OAuth client id and failed the build when the manifest had none, so the extension could not ship without direct Google Sheets API access. Treat it as optional: verify the manifest only when the secret is set, and drop it from the required-secret check. Adds the extension documentation page and links it from the docs nav. * fix(security-questionnaire-extension): resolve remaining review findings Detection and data correctness: - Stop dropping questionnaire rows whose text looks like a column label. Any cell under 48 characters containing "control", "requirement" or "question" was discarded, which silently lost common questions such as "Are security controls documented?". - Ignore answer-style statements in the keyword fallback so filled-in answers are not re-detected as new questions. - Reject row/column 0 when parsing sheet targets; A1 notation is 1-based. - Reject negative sheet ids when building formatting requests. Concurrency and data loss: - Apply batch results to the stored queue rather than a snapshot, and skip items the user edited or approved while generation was in flight. - Serialize storage read-merge-writes so concurrent updates are not lost. - Keep the sheet mapping dialog populated when a save fails. - Surface autosave failures instead of dropping them. Robustness: - Guard JSON parsing in the API client, GViz table parsing and gid decoding. - Let one Sheets endpoint failure fall through to the next. - Fall back to local detection when the background message fails. - Add Vary headers to CORS responses, including rejected origins. - Trim organization ids and reject empty question ids and whitespace-only approvals. - Tighten isItemResponse to validate the queue it claims to carry. Cleanup: drop the unused ReviewPanel and the unhandled comp:generate-visible-page message, dedupe getHost and surface validation. * fix(api): keep HEAD in the CORS allowed methods The replacement CORS middleware dropped HEAD, which app.enableCors allowed by default, so cross-origin HEAD preflights would have started failing. * fix(security-questionnaire-extension): publish via service account on the working API Switches Chrome Web Store auth from a personal refresh token to a service account JWT, which does not expire and is not tied to an individual account. Verified against the live API: the token exchange succeeds and the service account can read the store item. Every v2 endpoint returns 404 for this publisher while v1.1 works with the same token, so the upload, status and publish calls now use v1.1. That API is supported until 2026-10-15. Adds a publish_target choice so a manual run can ship to trusted testers before going public. * fix(security-questionnaire-extension): isolate failed queue writes in batch generation A rejected save left the serialized write chain permanently rejected, so every answer generated afterwards was computed and then silently dropped. Keep the chain alive, collect per-item write failures instead of throwing from a pool worker (which abandoned the questions queued behind it), and report them once the batch has persisted everything it could. * fix(security-questionnaire-extension): repair the manifest version check The check embedded a JS template literal inside a double-quoted shell string, so bash tried to expand ${process.env.VERSION} and aborted the step with 'bad substitution'. The release workflow has never run, so this would have failed on the first publish. Use string concatenation instead. * fix(security-questionnaire-extension): drop the unused activeTab permission Every activeTab occurrence in the source is a local variable name; the permission itself is never relied on, and the <all_urls> host permission already covers the same access. The store rejects versions that request permissions they do not need. Also refuses to publish a package whose manifest still points at localhost, which is what the build produces when the public URL env vars are missing. * fix(security-questionnaire-extension): keep failed questions retryable When a queue write failed the question stayed marked `generating`, and that status is excluded from batch candidates — so every later Generate All skipped it and the answer could never be recovered. Reset the item to pending after a failed write. * fix(security-questionnaire-extension): restore prior status after a failed write Resetting a failed question to pending discarded whatever state it had before the batch, so a transient storage error downgraded a question that already held a generated or flagged answer. Restore the status captured before the batch marked it generating.
1 parent 539fdba commit e678421

125 files changed

Lines changed: 11712 additions & 197 deletions

File tree

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

‎.env.example‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -31,3 +31,4 @@ FIRECRAWL_API_KEY="" # For research, self-host or use cloud-version @ https://fi
3131
TRUST_APP_URL="http://localhost:3008" # Trust portal public site for NDA signing and access requests
3232

3333
AUTH_TRUSTED_ORIGINS=http://localhost:3000,https://*.trycomp.ai,http://localhost:3002
34+
COMP_EXTENSION_TRUSTED_ORIGINS=chrome-extension://<extension-id>
Lines changed: 328 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,328 @@
1+
name: Security Questionnaire Extension Release
2+
3+
on:
4+
workflow_dispatch:
5+
inputs:
6+
publish_target:
7+
description: 'Who receives this release'
8+
type: choice
9+
default: trustedTesters
10+
options:
11+
- trustedTesters
12+
- default
13+
push:
14+
branches:
15+
- release
16+
paths:
17+
- '.github/workflows/security-questionnaire-extension-release.yml'
18+
- 'apps/browser-extension/security-questionnaire-ext/**'
19+
20+
permissions:
21+
contents: write
22+
23+
concurrency:
24+
group: security-questionnaire-extension-release
25+
cancel-in-progress: false
26+
27+
env:
28+
EXTENSION_DIR: apps/browser-extension/security-questionnaire-ext
29+
EXTENSION_TAG_PREFIX: security-questionnaire-ext-v
30+
WXT_PUBLIC_API_BASE_URL: https://api.trycomp.ai
31+
WXT_PUBLIC_APP_BASE_URL: https://app.trycomp.ai
32+
33+
jobs:
34+
release:
35+
name: Publish Chrome extension
36+
if: ${{ github.ref == 'refs/heads/release' }}
37+
runs-on: warp-ubuntu-latest-arm64-4x
38+
steps:
39+
- name: Checkout
40+
uses: actions/checkout@v4
41+
with:
42+
fetch-depth: 0
43+
fetch-tags: true
44+
45+
- name: Setup Node.js
46+
uses: actions/setup-node@v4
47+
with:
48+
node-version: '22'
49+
50+
- name: Setup Bun
51+
uses: oven-sh/setup-bun@v2
52+
53+
- name: Install dependencies
54+
run: bun install --frozen-lockfile --ignore-scripts
55+
56+
- name: Compute next extension version
57+
id: version
58+
run: |
59+
LATEST_TAG=$(git tag -l "${EXTENSION_TAG_PREFIX}*" --sort=-v:refname | grep -E "^${EXTENSION_TAG_PREFIX}[0-9]+\.[0-9]+\.[0-9]+$" | head -1)
60+
61+
if [ -z "$LATEST_TAG" ]; then
62+
NEXT_VERSION="0.1.0"
63+
else
64+
CURRENT_VERSION="${LATEST_TAG#${EXTENSION_TAG_PREFIX}}"
65+
IFS='.' read -r MAJOR MINOR PATCH <<< "$CURRENT_VERSION"
66+
NEXT_VERSION="$MAJOR.$MINOR.$((PATCH + 1))"
67+
fi
68+
69+
TAG_NAME="${EXTENSION_TAG_PREFIX}${NEXT_VERSION}"
70+
echo "version=$NEXT_VERSION" >> "$GITHUB_OUTPUT"
71+
echo "tag_name=$TAG_NAME" >> "$GITHUB_OUTPUT"
72+
echo "zip_name=security-questionnaire-extension-${NEXT_VERSION}.zip" >> "$GITHUB_OUTPUT"
73+
74+
echo "--- Extension version ---"
75+
echo "Latest tag: ${LATEST_TAG:-none}"
76+
echo "Next version: $NEXT_VERSION"
77+
echo "Tag name: $TAG_NAME"
78+
79+
- name: Set extension package version
80+
working-directory: ${{ env.EXTENSION_DIR }}
81+
env:
82+
VERSION: ${{ steps.version.outputs.version }}
83+
run: |
84+
node -e "
85+
const fs = require('fs');
86+
const path = 'package.json';
87+
const pkg = JSON.parse(fs.readFileSync(path, 'utf8'));
88+
pkg.version = process.env.VERSION;
89+
fs.writeFileSync(path, JSON.stringify(pkg, null, 2) + '\n');
90+
"
91+
92+
- name: Typecheck extension
93+
run: bun run --filter '@trycompai/security-questionnaire-extension' typecheck
94+
95+
- name: Test extension
96+
run: bun run --filter '@trycompai/security-questionnaire-extension' test
97+
98+
- name: Build production extension
99+
env:
100+
WXT_GOOGLE_OAUTH_CLIENT_ID: ${{ secrets.SECURITY_QUESTIONNAIRE_EXTENSION_GOOGLE_OAUTH_CLIENT_ID }}
101+
run: bun run --filter '@trycompai/security-questionnaire-extension' build
102+
103+
- name: Verify manifest version
104+
env:
105+
EXTENSION_DIR: ${{ env.EXTENSION_DIR }}
106+
VERSION: ${{ steps.version.outputs.version }}
107+
OAUTH_CLIENT_ID: ${{ secrets.SECURITY_QUESTIONNAIRE_EXTENSION_GOOGLE_OAUTH_CLIENT_ID }}
108+
run: |
109+
node -e "
110+
const fs = require('fs');
111+
const manifestPath = process.env.EXTENSION_DIR + '/dist/chrome-mv3/manifest.json';
112+
const manifest = JSON.parse(fs.readFileSync(manifestPath, 'utf8'));
113+
if (manifest.version !== process.env.VERSION) {
114+
throw new Error(
115+
'Expected manifest version ' + process.env.VERSION +
116+
', got ' + manifest.version,
117+
);
118+
}
119+
// Google OAuth is optional. When the client id secret is set the
120+
// manifest must carry it; without it the extension ships with the
121+
// guided Sheets paste flow instead of direct Sheets API writes.
122+
const wantsOAuth = Boolean(process.env.OAUTH_CLIENT_ID);
123+
if (wantsOAuth && !manifest.oauth2?.client_id) {
124+
throw new Error('OAuth client id was provided but is missing from the built manifest');
125+
}
126+
console.log('Google Sheets API insertion:', wantsOAuth ? 'enabled' : 'disabled (guided paste)');
127+
128+
// The build silently falls back to localhost when the public URLs
129+
// are unset, so refuse to publish a package pointed at a dev host.
130+
const hosts = manifest.host_permissions || [];
131+
const localhost = hosts.filter((host) => host.includes('localhost'));
132+
if (localhost.length > 0) {
133+
throw new Error(
134+
'Refusing to publish: manifest still points at ' + localhost.join(', '),
135+
);
136+
}
137+
if (!hosts.some((host) => host.startsWith('https://api.trycomp.ai'))) {
138+
throw new Error(
139+
'Refusing to publish: manifest is missing the production API host',
140+
);
141+
}
142+
"
143+
144+
- name: Package extension
145+
working-directory: ${{ env.EXTENSION_DIR }}/dist/chrome-mv3
146+
env:
147+
ZIP_NAME: ${{ steps.version.outputs.zip_name }}
148+
run: zip -r "../$ZIP_NAME" .
149+
150+
- name: Upload build artifact
151+
uses: actions/upload-artifact@v4
152+
with:
153+
name: security-questionnaire-extension-${{ steps.version.outputs.version }}
154+
path: ${{ env.EXTENSION_DIR }}/dist/${{ steps.version.outputs.zip_name }}
155+
if-no-files-found: error
156+
157+
- name: Validate Chrome Web Store secrets
158+
env:
159+
SERVICE_ACCOUNT_JSON: ${{ secrets.CHROME_WEB_STORE_SERVICE_ACCOUNT_JSON }}
160+
EXTENSION_ID: ${{ secrets.SECURITY_QUESTIONNAIRE_EXTENSION_ID }}
161+
run: |
162+
# SECURITY_QUESTIONNAIRE_EXTENSION_GOOGLE_OAUTH_CLIENT_ID is intentionally
163+
# not required — it only enables direct Google Sheets API insertion.
164+
# CHROME_WEB_STORE_PUBLISHER_ID is unused by the v1.1 API but kept as a
165+
# secret for the eventual v2 migration.
166+
missing=0
167+
for name in SERVICE_ACCOUNT_JSON EXTENSION_ID; do
168+
if [ -z "${!name}" ]; then
169+
echo "::error::Missing required secret: $name"
170+
missing=1
171+
fi
172+
done
173+
if [ "$missing" -ne 0 ]; then
174+
exit 1
175+
fi
176+
177+
- name: Get Chrome Web Store access token
178+
id: token
179+
env:
180+
SERVICE_ACCOUNT_JSON: ${{ secrets.CHROME_WEB_STORE_SERVICE_ACCOUNT_JSON }}
181+
run: |
182+
# Service account JWT bearer flow. Unlike a refresh token this does not
183+
# expire and is not tied to an individual Google account.
184+
ASSERTION=$(node -e "
185+
const crypto = require('crypto');
186+
const key = JSON.parse(process.env.SERVICE_ACCOUNT_JSON);
187+
const encode = (value) =>
188+
Buffer.from(JSON.stringify(value))
189+
.toString('base64url');
190+
const issuedAt = Math.floor(Date.now() / 1000);
191+
const payload = {
192+
iss: key.client_email,
193+
scope: 'https://www.googleapis.com/auth/chromewebstore',
194+
aud: 'https://oauth2.googleapis.com/token',
195+
iat: issuedAt,
196+
exp: issuedAt + 3600,
197+
};
198+
const signingInput =
199+
encode({ alg: 'RS256', typ: 'JWT' }) + '.' + encode(payload);
200+
const signature = crypto
201+
.createSign('RSA-SHA256')
202+
.update(signingInput)
203+
.sign(key.private_key)
204+
.toString('base64url');
205+
process.stdout.write(signingInput + '.' + signature);
206+
")
207+
208+
RESPONSE=$(curl -fsS "https://oauth2.googleapis.com/token" \
209+
--data-urlencode "grant_type=urn:ietf:params:oauth:grant-type:jwt-bearer" \
210+
--data-urlencode "assertion=$ASSERTION")
211+
212+
ACCESS_TOKEN=$(RESPONSE="$RESPONSE" node -e "
213+
const response = JSON.parse(process.env.RESPONSE);
214+
if (!response.access_token) throw new Error('No access_token returned');
215+
process.stdout.write(response.access_token);
216+
")
217+
218+
echo "::add-mask::$ACCESS_TOKEN"
219+
echo "access_token=$ACCESS_TOKEN" >> "$GITHUB_OUTPUT"
220+
221+
# The v2 API is not yet served for this publisher — every v2 route returns
222+
# 404 while the v1.1 API works with the same service account token. v1.1
223+
# is supported until 2026-10-15; revisit before then.
224+
- name: Upload package to Chrome Web Store
225+
id: upload
226+
env:
227+
ACCESS_TOKEN: ${{ steps.token.outputs.access_token }}
228+
EXTENSION_ID: ${{ secrets.SECURITY_QUESTIONNAIRE_EXTENSION_ID }}
229+
ZIP_PATH: ${{ env.EXTENSION_DIR }}/dist/${{ steps.version.outputs.zip_name }}
230+
run: |
231+
RESPONSE=$(curl -fsS \
232+
-H "Authorization: Bearer $ACCESS_TOKEN" \
233+
-H "x-goog-api-version: 2" \
234+
-X PUT \
235+
-T "$ZIP_PATH" \
236+
"https://www.googleapis.com/upload/chromewebstore/v1.1/items/$EXTENSION_ID")
237+
238+
echo "$RESPONSE"
239+
UPLOAD_STATE=$(RESPONSE="$RESPONSE" node -e "
240+
const response = JSON.parse(process.env.RESPONSE);
241+
const state = response.uploadState;
242+
if (state !== 'SUCCESS' && state !== 'IN_PROGRESS') {
243+
const details = (response.itemError || [])
244+
.map((entry) => entry.error_detail)
245+
.join('; ');
246+
throw new Error(
247+
\`Chrome upload failed with state \${state || 'unknown'}\${details ? ': ' + details : ''}\`,
248+
);
249+
}
250+
process.stdout.write(state);
251+
")
252+
253+
echo "upload_state=$UPLOAD_STATE" >> "$GITHUB_OUTPUT"
254+
255+
- name: Wait for async Chrome Web Store upload
256+
if: ${{ steps.upload.outputs.upload_state == 'IN_PROGRESS' }}
257+
env:
258+
ACCESS_TOKEN: ${{ steps.token.outputs.access_token }}
259+
EXTENSION_ID: ${{ secrets.SECURITY_QUESTIONNAIRE_EXTENSION_ID }}
260+
run: |
261+
for attempt in $(seq 1 30); do
262+
RESPONSE=$(curl -fsS \
263+
-H "Authorization: Bearer $ACCESS_TOKEN" \
264+
-H "x-goog-api-version: 2" \
265+
"https://www.googleapis.com/chromewebstore/v1.1/items/$EXTENSION_ID?projection=DRAFT")
266+
267+
STATE=$(RESPONSE="$RESPONSE" node -e "
268+
const response = JSON.parse(process.env.RESPONSE);
269+
process.stdout.write(response.uploadState || 'UNKNOWN');
270+
")
271+
echo "Upload state: $STATE"
272+
273+
if [ "$STATE" = "SUCCESS" ]; then
274+
exit 0
275+
fi
276+
if [ "$STATE" = "FAILURE" ]; then
277+
echo "::error::Chrome Web Store async upload failed"
278+
echo "$RESPONSE"
279+
exit 1
280+
fi
281+
282+
echo "Still $STATE; waiting before retry $attempt"
283+
sleep 10
284+
done
285+
286+
echo "::error::Timed out waiting for Chrome Web Store async upload"
287+
exit 1
288+
289+
- name: Publish Chrome Web Store item
290+
env:
291+
ACCESS_TOKEN: ${{ steps.token.outputs.access_token }}
292+
EXTENSION_ID: ${{ secrets.SECURITY_QUESTIONNAIRE_EXTENSION_ID }}
293+
PUBLISH_TARGET: ${{ inputs.publish_target || 'trustedTesters' }}
294+
run: |
295+
echo "Publishing to: $PUBLISH_TARGET"
296+
RESPONSE=$(curl -fsS \
297+
-H "Authorization: Bearer $ACCESS_TOKEN" \
298+
-H "x-goog-api-version: 2" \
299+
-H "Content-Length: 0" \
300+
-X POST \
301+
"https://www.googleapis.com/chromewebstore/v1.1/items/$EXTENSION_ID/publish?publishTarget=$PUBLISH_TARGET")
302+
303+
echo "$RESPONSE"
304+
RESPONSE="$RESPONSE" node -e "
305+
const response = JSON.parse(process.env.RESPONSE);
306+
const statuses = response.status || [];
307+
const failed = statuses.filter(
308+
(entry) => entry !== 'OK' && entry !== 'PUBLISHED_WITH_FRICTION_WARNING',
309+
);
310+
if (failed.length > 0) {
311+
throw new Error(
312+
'Publish rejected: ' +
313+
failed.join(', ') +
314+
' — ' +
315+
(response.statusDetail || []).join('; '),
316+
);
317+
}
318+
"
319+
320+
- name: Tag published extension version
321+
env:
322+
TAG_NAME: ${{ steps.version.outputs.tag_name }}
323+
VERSION: ${{ steps.version.outputs.version }}
324+
run: |
325+
git config user.name "github-actions[bot]"
326+
git config user.email "github-actions[bot]@users.noreply.github.com"
327+
git tag -a "$TAG_NAME" -m "Security questionnaire extension v$VERSION"
328+
git push origin "$TAG_NAME"

‎.gitignore‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -85,6 +85,8 @@ playwright/.cache/
8585
debug-setup-page.png
8686

8787
packages/*/dist
88+
apps/browser-extension/*/.wxt
89+
apps/browser-extension/*/dist
8890

8991
# Generated Prisma Client
9092
**/src/db/generated/

0 commit comments

Comments
 (0)