# gdocs-audit-report

Write and format security-audit reports in Google Docs via the Docs API - findings, tables, and severity styling. Use to build or fix a report.

- **Kind:** skill
- **Source:** https://github.com/forefy/.context
- **Page:** https://forefy.com/skills/60345221-119f-4f96-ac50-5b5bf0151693
- **API (JSON + files):** https://forefy.com/api/asr/60345221-119f-4f96-ac50-5b5bf0151693

---

## SKILL.md

---
name: gdocs-audit-report
description: Write and format security-audit reports in Google Docs via the Docs API - findings, tables, and severity styling. Use to build or fix a report.
---

# Google Docs Security Audit Report

## Overview

This skill provides patterns, constants, and gotcha-avoidance for building and maintaining security audit reports in Google Docs using the Docs API (service account auth). It encodes hard-won lessons around index drift, code styling multi-pass, paragraph vs text backgrounds, heading anchor URLs, and cross-reference linking.

Always read `references/api-patterns.md` and `references/formatting-standards.md` before writing any script.

## Workflow

```
1. Auth          → make_token() from scripts/gdocs_auth.py
2. get_doc()     → fresh snapshot before EVERY batch of edits
3. Build ops     → sort ALL ops highest-index-first
4. do_batch()    → send in chunks of ≤50
5. Verify        → get_doc() again and spot-check changed ranges
```

## Critical Gotchas (read these first)

### 1. Index drift
Any insert/delete shifts every index above it. **Always sort ops high→low. Always get_doc() fresh.**

### 2. Text bg ≠ paragraph bg
`updateTextStyle.backgroundColor` only covers character width - leaves white gaps between lines in code blocks. Use `updateParagraphStyle.shading.backgroundColor` + `spaceAbove/Below: 0` for full-width code block backgrounds.

### 3. headingId already has `h.` prefix
Anchor URL = `#heading={headingId}` - **not** `#heading=h.{headingId}`.

### 4. Code styling needs multiple passes
After `updateTextStyle` splits a run, new unstyled sub-runs appear. Always do a second full-doc re-scan after the first styling pass.

### 5. Sort code terms longest-first
Prevents `maxRelayFeeBPS` matching before `assetConfig.maxRelayFeeBPS` and leaving a partial un-styled prefix.

### 6. Backtick removal order (per segment, high→low)
Delete closing backtick → style inner → delete opening backtick - all in one batch.

### 7. Bullet conversion
`createParagraphBullets` does not change indices. Then `deleteContentRange` the `- ` prefix high→low in a second batch.

### 8. Cross-reference links: skip only already-linked runs
Scan ALL runs for `X-NN` regex - **only skip** runs where `textStyle.link` is already set. Do NOT also filter by font (Courier runs can contain xrefs too).

### 9. Table cell content replace
Delete to `elements[-1].endIndex - 1` (keep the required trailing `\n`), then insert. Sort delete (higher) before insert (lower) in same batch.

### 10. replaceAllText for bulk renames
Use `replaceAllText` for ID renames (e.g. renumbering findings). Same-length replacements are index-safe. Do in one batch, longest/most-specific strings first.

## Formatting Standards

See `references/formatting-standards.md` for:
- Severity RGB colors (Critical, High, Medium, Low, Unmitigated)
- Heading purple, code purple, code bg gray
- Finding structure template
- Summary table column definitions
- Finding ID scheme (`C-01`, `H-01`, etc.)

## API Patterns

See `references/api-patterns.md` for:
- Auth boilerplate
- All common op templates (style, delete, insert, hyperlink, bullets, paragraph shading)
- Heading anchor URL construction
- Cross-reference linking pattern
- Inline code multi-pass approach

## Resources

- `scripts/gdocs_auth.py` - reusable auth + get_doc + do_batch helpers
- `references/api-patterns.md` - op templates and patterns
- `references/formatting-standards.md` - colors, typography, structure constants
- `references/how-to-create-google-service-account.md` - one-time setup: instruct the user to create a service account, advise the user to restrict it to the AI-only Drive folder/file (via google share feature), and provide the JSON key path

## references

```

```

## references/api-patterns.md

# Google Docs API Patterns - Audit Report

## Table of Contents

- [Auth](#auth)
- [Index Drift - The Golden Rule](#index-drift--the-golden-rule)
- [Common Op Templates](#common-op-templates)
  - [Style a text range](#style-a-text-range)
  - [Delete a range](#delete-a-range)
  - [Insert text](#insert-text)
  - [Replace table cell content](#replace-table-cell-content)
  - [Apply hyperlink](#apply-hyperlink)
  - [Create paragraph bullets](#create-paragraph-bullets)
  - [Full-width paragraph background (code blocks)](#full-width-paragraph-background-code-blocks)
- [Heading Anchor URLs](#heading-anchor-urls)
- [Inline Code Styling (multi-pass approach)](#inline-code-styling-multi-pass-approach)
- [Removing Backtick-Wrapped Code](#removing-backtick-wrapped-code)
- [Cross-Reference Hyperlinks](#cross-reference-hyperlinks)

---

## Auth

Service account key (JSON) → RS256 JWT → OAuth2 Bearer token.
Always use `ssl._create_unverified_context()`. Token expires in 1h.
See `scripts/gdocs_auth.py` for the reusable helper.

```python
from gdocs_auth import make_token, get_doc, do_batch
# Don't blindly run, ask the user for the actual service account file path
TOKEN = make_token("/path/to/service-account-key.json")
doc   = get_doc(DOC_ID, TOKEN)
```

---

## Index Drift - The Golden Rule

**Every insert or delete shifts all indices above it.**

- Always call `get_doc()` fresh before building ops.
- Sort all ops **highest index first** before sending.
- Never reuse stale indices after any mutation.
- Combine independent same-direction ops into one batch when safe.

---

## Common Op Templates

### Style a text range

```python
{"updateTextStyle": {
    "range": {"startIndex": si, "endIndex": ei},
    "textStyle": { ... },
    "fields": "bold,weightedFontFamily,foregroundColor,backgroundColor"
}}
```

### Delete a range

```python
{"deleteContentRange": {"range": {"startIndex": si, "endIndex": ei}}}
```

### Insert text

```python
{"insertText": {"location": {"index": si}, "text": "new text"}}
```

### Replace table cell content

```python
# 1. Get cell content range:
first_el = cell["content"][0]["paragraph"]["elements"][0]
last_el  = cell["content"][-1]["paragraph"]["elements"][-1]
text_si  = first_el["startIndex"]
text_ei  = last_el["endIndex"] - 1   # -1 preserves required trailing \n

# 2. Delete then insert (high→low: delete first since it's higher if appending)
ops = [
    {"deleteContentRange": {"range": {"startIndex": text_si, "endIndex": text_ei}}},
    {"insertText": {"location": {"index": text_si}, "text": "new value"}},
]
```

### Apply hyperlink

```python
{"updateTextStyle": {
    "range": {"startIndex": si, "endIndex": ei},
    "textStyle": {"link": {"url": url}},
    "fields": "link"
}}
```

### Create paragraph bullets

```python
{"createParagraphBullets": {
    "range": {"startIndex": si, "endIndex": ei - 1},
    "bulletPreset": "BULLET_DISC_CIRCLE_SQUARE"
}}
# Then delete leading "- " (2 chars) from each converted paragraph, high→low
```

### Full-width paragraph background (code blocks)

```python
{"updateParagraphStyle": {
    "range": {"startIndex": si, "endIndex": ei},
    "paragraphStyle": {
        "shading": {"backgroundColor": {"color": {"rgbColor": LIGHT_GRAY}}},
        "spaceAbove": {"magnitude": 0, "unit": "PT"},
        "spaceBelow": {"magnitude": 0, "unit": "PT"},
    },
    "fields": "shading,spaceAbove,spaceBelow"
}}
```

> **Note:** `updateTextStyle.backgroundColor` only highlights text characters (not full line width).
> Use `updateParagraphStyle.shading` for full-width code block backgrounds.

---

## Heading Anchor URLs

`headingId` in `paragraphStyle` **already includes the `h.` prefix**.

```python
heading_id = para["paragraphStyle"]["headingId"]  # e.g. "h.vbzwjo2n54rj"
url = f"https://docs.google.com/document/d/{DOC_ID}/edit#heading={heading_id}"
# NOT: #heading=h.{heading_id}  ← double-prefix bug
```

---

## Inline Code Styling (multi-pass approach)

**Problem:** After styling a sub-range in a run, the run splits. Re-scanning finds new unstyled runs.

**Solution:** Always do at least 2 passes:

1. First pass - style all known terms using `updateTextStyle` on specific ranges
2. Second pass - re-scan doc for any remaining plain runs containing missed terms

**Term matching:** Sort terms **longest first** to avoid partial overlaps.

```python
CODE_TERMS = sorted([...], key=len, reverse=True)
```

**Word boundary check:** Skip for terms containing `.()[]{}/:'"#@* ` - only check boundaries for plain identifiers.

---

## Removing Backtick-Wrapped Code

Process each `` `inner` `` segment high→low within one batch:

1. `deleteContentRange` closing backtick (highest index)
2. `updateTextStyle` inner text
3. `deleteContentRange` opening backtick (lowest index)

---

## Cross-Reference Hyperlinks

Find `X-NN` patterns in any run that is **not already a hyperlink** (regardless of font):

```python
import re
XREF_RE = re.compile(r'\b[A-Z]-\d{2}\b')
# Map headingId -> title by scanning HEADING_1 paragraphs first
# Only skip runs where ts.get('link') is already set
# Do NOT restrict to plain/non-Courier runs - the ref can appear in any styled text
# Apply updateTextStyle: link + bold to each match
```

> **Common mistake:** Filtering out Courier New runs causes misses when a finding ID appears
> inside a code-styled sentence (e.g. "path confirmed in C-03").

## references/formatting-standards.md

# Formatting Standards - Security Audit Report

## Severity Colors (RGB 0-1 scale)

| Severity    | Red   | Green | Blue  | Hex       |
|-------------|-------|-------|-------|-----------|
| Critical    | 0.820 | 0.157 | 0.157 | #D12828   |
| High        | 0.918 | 0.545 | 0.196 | #EA8B32   |
| Medium      | 0.965 | 0.761 | 0.259 | #F6C242   |
| Low         | 0.278 | 0.651 | 0.455 | #47A674   |
| Unmitigated | 0.918 | 0.545 | 0.196 | same as High |

## Typography

| Element         | Font       | Size  | Style       | Color              |
|----------------|------------|-------|-------------|---------------------|
| Finding heading | Roboto     | -     | HEADING_1   | Red `#e06666` `(0.439, 0.188, 0.627)` |
| Body labels     | -          | -     | Bold        | (same as severity)  |
| Inline code     | Courier New| -     | Bold        | Red `#e06666` `(0.447, 0.353, 0.675)` |
| Code background | -          | -     | -           | Gray `#EDEDED` `(0.929, 0.929, 0.929)` |

## Finding Structure (per finding)

```
[HEADING_1 red bold]  X-NN: Title

Severity:   [severity color bold]  Critical / High / Medium / Low
Status:     [orange if Unmitigated, else normal]  Unmitigated / Pending Retest / Mitigated

Code:
  [hyperlink to GitHub]  repo/path/to/file.ts#L10-L20

Description:
  [body text, 1.5× line spacing]

Attack Flow:
  [body text]

Recommendations:
  [bullet list]
```

## Summary Table Columns

| # | Column      | Notes                                           |
|---|-------------|--------------------------------------------------|
| 0 | Finding     | Full title, hyperlinked to heading anchor        |
| 1 | Repo        | shieldflow-website / shieldflow-core / shieldflow-asp |
| 2 | Component   | e.g. Relayer Proxy API, SDK, CI/CD, AWS IAM      |
| 3 | Risk Level  | Severity color applied to cell text             |
| 4 | Status      | Unmitigated = High orange; others = normal       |

Table line spacing: 1.15×

## Finding ID Scheme

Per-severity sequential reset: `C-01..C-NN`, `H-01..H-NN`, `M-01..M-NN`, `L-01..L-NN`

Order in doc: Criticals → Highs → Mediums → Lows

## Bullet Style

Use Google Docs native list bullets (`BULLET_DISC_CIRCLE_SQUARE`), never plain `- ` dashes.
18pt left indent for bullet paragraphs.

## Code Block (multi-line)

Consecutive code-line paragraphs should have:
- `updateParagraphStyle.shading.backgroundColor` = `#EDEDED`
- `spaceAbove` = `spaceBelow` = 0 PT
- All text runs: Courier New bold, red `#e06666`, bg `#EDEDED`

## keepWithNext

Apply `keepWithNext: true` to label paragraphs (Description:, Attack Flow:, Recommendations:, Code:)
so they don't orphan at the bottom of a page.

## references/how-to-create-google-service-account.md

# Human instructions on setup required for this skill to be able to see google drive files

For security isolation, we do not want to give agents free access to roam around our google drive, instead - we can create a service account, and give it access to only what it needs to work on and nothing else.

1. Go to APIs & Services → Library and enable Google Drive API and Google Docs API
2. Go to IAM & Admin → Service Accounts → + Create Service Account
3. Give it a name (e.g. chainer-drive), skip optional role/user steps, click Done
4. Click the service account → Keys tab → Add Key → Create new key → JSON → Download → PLACE SOMEWHERE THIS SKILL CAN READ
5. Share an AI-ONLY Google Drive folder with the service account's email address (shown after connecting)

## scripts

```

```

## scripts/gdocs_auth.py

```python
#!/usr/bin/env python3
"""
Reusable Google Docs API auth helper.
Usage: from gdocs_auth import make_token, get_doc, do_batch
"""
import json, time, ssl, urllib.request, urllib.parse, base64

ssl_ctx = ssl._create_unverified_context()


def make_token(key_path: str) -> str:
    with open(key_path) as f:
        key_data = json.load(f)
    from cryptography.hazmat.primitives import serialization, hashes
    from cryptography.hazmat.primitives.asymmetric import padding
    from cryptography.hazmat.backends import default_backend

    now = int(time.time())
    h = (
        base64.urlsafe_b64encode(json.dumps({"alg": "RS256", "typ": "JWT"}).encode())
        .rstrip(b"=")
        .decode()
    )
    p = (
        base64.urlsafe_b64encode(
            json.dumps(
                {
                    "iss": key_data["client_email"],
                    "scope": "https://www.googleapis.com/auth/drive",
                    "aud": "https://oauth2.googleapis.com/token",
                    "iat": now,
                    "exp": now + 3600,
                }
            ).encode()
        )
        .rstrip(b"=")
        .decode()
    )
    msg = f"{h}.{p}".encode()
    pk = serialization.load_pem_private_key(
        key_data["private_key"].encode(), password=None, backend=default_backend()
    )
    sig = (
        base64.urlsafe_b64encode(pk.sign(msg, padding.PKCS1v15(), hashes.SHA256()))
        .rstrip(b"=")
        .decode()
    )
    jwt = f"{h}.{p}.{sig}"
    data = urllib.parse.urlencode(
        {"grant_type": "urn:ietf:params:oauth:grant-type:jwt-bearer", "assertion": jwt}
    ).encode()
    req = urllib.request.Request("https://oauth2.googleapis.com/token", data=data)
    return json.loads(urllib.request.urlopen(req, context=ssl_ctx).read())[
        "access_token"
    ]


def get_doc(doc_id: str, token: str) -> dict:
    req = urllib.request.Request(
        f"https://docs.googleapis.com/v1/documents/{doc_id}",
        headers={"Authorization": f"Bearer {token}"},
    )
    return json.loads(urllib.request.urlopen(req, context=ssl_ctx).read())


def do_batch(doc_id: str, token: str, ops: list, label: str = "") -> dict:
    """Apply a list of batchUpdate requests. Always sort ops high→low index before calling."""
    body = json.dumps({"requests": ops}).encode()
    req = urllib.request.Request(
        f"https://docs.googleapis.com/v1/documents/{doc_id}:batchUpdate",
        data=body,
        method="POST",
        headers={
            "Authorization": f"Bearer {token}",
            "Content-Type": "application/json",
        },
    )
    resp = json.loads(urllib.request.urlopen(req, context=ssl_ctx).read())
    if label:
        print(f"  [{label}] -> OK ({len(ops)} ops)")
    return resp
```

