# Security

> Claude-powered SAST and SCA security scan skill. Invoked automatically as the Security Scan step (Step 7) of /aod.build (after Design Quality Gate, before Code Simplification) or standalone via /security. Analyzes all code files and dependency manifests changed on the feature branch relative to main for OWASP Top 10 vulnerability patterns and known CVE findings. Produces a human-readable security-scan.md report and machine-readable .security/ compliance artifacts (scan-log.jsonl, vulnerabilities.jsonl, SARIF 2.1.0, CycloneDX 1.5 SBOM). Blocks build progression on CRITICAL/HIGH findings with an explicit acknowledgment gate. Use --no-security in /aod.build to skip. Invoke /security directly for standalone analysis outside the build pipeline.

- Skill: `davidmatousek/security` (Agent Skill)
- Install (CLI): `npx skillmds@latest add davidmatousek/security`
- Raw SKILL.md: https://api.skillmd.com/api/skills/davidmatousek/security/raw
- Safety review: pending (external: skill-scanner WARNING, skillspector PASS)
- Works with: Claude Code, Claude.ai, OpenAI Codex
- Category: DevOps & Infra
- Author: davidmatousek (https://skillmd.com/u/davidmatousek)
- Updated: 2026-09-10
- Page: https://skillmd.com/skills/davidmatousek/security

---


# /security — SAST/SCA Security Scan

**Purpose**: Analyze code files and dependency manifests changed on the feature branch for OWASP Top 10 vulnerabilities and known CVE patterns. Write permanent audit artifacts. Block on CRITICAL/HIGH findings until acknowledged or fixed.

**When invoked from /aod.build**: Called as the Security Scan step (Step 7) via the Skill tool. `--no-security` flag in `/aod.build` bypasses this skill entirely.

**When invoked standalone**: Run directly as `/security` from any project directory. `security-scan.md` is written to `specs/{NNN}-*/` if inside an AOD project, or to the current working directory otherwise.

---

## Step 0: Parse Arguments

If invoked standalone, parse optional flags from arguments:

1. **`--no-security`**: If present (standalone invocation context), output "Security scan skipped (--no-security)" and exit cleanly. This flag is normally handled by `/aod.build` Step 0 before invoking this skill; if present here in standalone mode, honor it.

2. **Feature number extraction**: Detect current feature number from branch name (`git branch --show-current` → extract leading digits) or from `specs/*/` directory listing. Used for output path resolution.

3. **Output path resolution**:
   - If inside an AOD project (`specs/{NNN}-*/` exists): write `security-scan.md` to `specs/{NNN}-*/security-scan.md`
   - Otherwise: write `security-scan.md` to current working directory

---

## Step 1: File Detection

Detect all files changed on the feature branch relative to `main`.

### 1a: SAST File Detection (FR-004)

Run: `git diff --name-only main...HEAD`

Filter the output to **code files** by including only these extensions:
`.py .js .ts .jsx .tsx .sh .go .rs .java .rb .swift .kt .php .cs .cpp .c .h`

Exclude these patterns from the result:
- Generated files: `*.lock`, `*.min.js`, `*.min.css`, `*.map`, `*.generated.*`
- Directories: `vendor/`, `dist/`, `build/`, `node_modules/`, `.aod/`, `docs/`

Store result as `code_files` list. Count as `code_file_count`.

**Zero-file path**: If `code_file_count` is 0, set `sast_status = "SKIPPED"` with note `"SAST: No code files changed — skipping static analysis"`. Proceed to Step 1b.

### 1b: SCA Manifest Detection (FR-005)

From the same `git diff --name-only main...HEAD` output, filter to **dependency manifest filenames**:

`requirements.txt`, `pyproject.toml`, `setup.py`, `package.json`, `package-lock.json`, `yarn.lock`, `Gemfile`, `Gemfile.lock`, `go.mod`, `go.sum`, `pom.xml`, `build.gradle`, `Cargo.toml`, `Cargo.lock`

Store result as `manifest_files` list. Count as `manifest_count`.

**Zero-manifest path**: If `manifest_count` is 0, set `sca_status = "SKIPPED"` with note `"SCA: No dependency manifests changed — skipping dependency audit"`.

---

## Step 2: Pre-Scan Checks

### 2a: Large-Diff Warning (FR-006)

If `code_file_count` > 50:

```
Warning: {code_file_count} code files changed on this branch.
SAST analysis of large diffs may take several minutes.

(A) Continue with SAST analysis
(B) Skip SAST for this run
```

Use AskUserQuestion with these options. If user selects (B): set `sast_status = "SKIPPED"` with reason `"user-skip-large-diff"`.

### 2b: .security/ Directory Initialization (FR-020)

Check if `.security/` exists at repo root:

```bash
if [ ! -d ".security" ]; then
  mkdir -p .security/reports
  # Write README.md (see content below)
fi
```

Auto-generate `.security/README.md` with content:

```markdown
# .security/ — Compliance Evidence Store

This directory contains machine-readable security audit artifacts generated by the `/security` skill
(invoked automatically from `/aod.build` Security Scan step).

**Do NOT gitignore this directory.** These files serve as compliance evidence for security reviews,
PCI-DSS 4.0 requirements, and PR audit trails.

## Contents

| File | Format | Purpose |
|------|--------|---------|
| `scan-log.jsonl` | JSONL (append-only) | Immutable execution history with chain_hash integrity |
| `vulnerabilities.jsonl` | JSONL (append-only) | Vulnerability lifecycle events (DETECTED, RISK_ACCEPTED, EXPIRY_REOPENED, REMEDIATED) |
| `exceptions.jsonl` | JSONL (append-only) | Risk acceptance decisions with expiry dates |
| `reports/{commit-sha}.sarif` | SARIF 2.1.0 JSON | Machine-readable findings (importable to GitHub Security tab, Defect Dojo, SonarQube) |
| `reports/sca-{YYYY-MM-DD}.cdx.json` | CycloneDX 1.5 JSON | Software Bill of Materials for dependency scans |

## Verification

Chain hash integrity check (scan-log.jsonl):
Re-compute SHA256(entry_json_without_chain_hash) for the first entry.
For subsequent entries: SHA256(previous_chain_hash + current_entry_json_without_chain_hash).
All JSON keys sorted alphabetically, no trailing whitespace.
```

Check `.gitignore` for `.security/` exclusion:

```bash
if grep -q "\.security" .gitignore 2>/dev/null; then
  echo "WARNING: .security/ appears to be gitignored. This directory must NOT be excluded from version control — it contains compliance evidence. Please remove the .security/ entry from .gitignore."
fi
```

### 2c: Expired Acceptance Check (FR-018)

If `.security/exceptions.jsonl` exists:

Read all entries. For each entry, check whether `acceptance_expiry` has passed using cross-platform epoch comparison (see utility functions below).

Collect all expired entries as `reopened_exceptions` list.

If `reopened_exceptions` is non-empty:

1. Display **"REOPENED EXCEPTIONS"** section (before any new findings):

```
REOPENED EXCEPTIONS
===================

The following previously acknowledged findings have expired and require re-acknowledgment:

{for each expired entry:}
  vuln_id:    {vuln_id}
  Severity:   {finding_severity}
  Summary:    {finding_summary}
  File:       {file from original finding — if available}
  Accepted:   {ts_accepted} by {accepted_by}
  Expired:    {acceptance_expiry}
  Justification: {original justification}
```

2. Append `EXPIRY_REOPENED` event to `.security/vulnerabilities.jsonl` for each expired entry (see Step 6 for format). This MUST happen before the acknowledgment prompt.

3. Route each reopened exception through the severity gate in Step 5 (same blocking logic as new CRITICAL/HIGH findings).

---

## Step 3: SAST Analysis

Skip if `sast_status = "SKIPPED"`.

### 3a: File Batching (FR-009)

Group `code_files` into batches of 5–10 files per analysis invocation:

```
batch_1 = code_files[0:8]
batch_2 = code_files[8:16]
... etc.
```

Maintain `all_sast_findings = []` across all batches.

### 3b: OWASP P0 Pattern Detection Per Batch (FR-007)

For each batch, analyze the files against these OWASP P0 patterns:

**A01: Broken Access Control**
- Open Redirect: `redirect(user_input)` or `redirect(request.args.get(...))` without allowlist validation
- Path Traversal: `open(user_input)`, `os.path.join(base, user_input)` without sanitization

**A02: Cryptographic Failures**
- Hardcoded Secrets: credential literals in non-test files (`password = "..."`, `api_key = "..."`, `secret = "..."`, `token = "..."`)
- Weak Crypto: `hashlib.md5(password)`, `sha1(pwd)` used for password hashing (excludes file checksum use)
- Insecure Random: `random.random()`, `Math.random()` used for token generation, session IDs, or nonces

**A03: Injection**
- SQL Injection: string concatenation into SQL/DB query with user input (`"SELECT" + var`, f-string with request param)
- Command Injection: `os.system(var)`, `subprocess.call(f"cmd {var}")`, `exec(user_input)`
- Template Injection: `render_template_string(user_input)` or equivalent

**A05: Security Misconfiguration**
- Debug Mode: `DEBUG = True` in non-test config files, `app.run(debug=True)`
- Permissive CORS: `Access-Control-Allow-Origin: *` with `Allow-Credentials: true`
- Verbose Errors: `traceback.format_exc()` returned in HTTP response body
- Default Credentials: `admin/admin`, `root/root`, `user/password` literals in config files

**A07: Identification & Authentication Failures (partial)**
- Insecure Cookie: `set_cookie(...)` without `secure=True, httponly=True`
- Plaintext Credentials: password stored without hashing, credentials written to log

For each finding detected, collect:

```json
{
  "type": "SAST",
  "owasp_category": "A03: Injection",
  "file": "relative/path/from/repo/root.py",
  "line": 42,
  "description": "SQL injection via string concatenation with user input",
  "recommendation": "Use parameterized queries or an ORM. Never concatenate user input directly into SQL strings."
}
```

Append to `all_sast_findings`.

### 3c: CVSS Category Rubric (FR-008)

After collecting raw findings, assign CVSS scores using this fixed lookup table. Label ALL SAST scores **"estimated"**. Set `kev_flag: "N/A"` for all SAST findings.

| OWASP Category | Default Score | Default Severity | Escalation | Reduction |
|---|---|---|---|---|
| A03: SQL Injection | 8.8 | HIGH | No auth required → 9.8 CRITICAL | Server-side validation present → 6.5 MEDIUM |
| A03: Command Injection | 9.0 | CRITICAL | Remote exec confirmed → 9.8 CRITICAL | — |
| A02: Hardcoded Secret | 7.5 | HIGH | Production credential → 9.1 CRITICAL | Dev-only env file → 5.5 MEDIUM |
| A02: Weak Crypto (passwords) | 7.4 | HIGH | — | Read-only checksum use → 3.7 LOW |
| A02: Insecure Random | 6.5 | MEDIUM | Auth/session use → 8.0 HIGH | — |
| A01: Open Redirect | 6.1 | MEDIUM | Authenticated user targeted → 7.4 HIGH | — |
| A01: Path Traversal | 7.5 | HIGH | Unauthenticated endpoint → 9.1 CRITICAL | — |
| A05: Debug Mode | 5.3 | MEDIUM | Production config → 7.5 HIGH | — |
| A05: Permissive CORS * | 7.5 | HIGH | — | Non-credentialed endpoint → 5.4 MEDIUM |
| A05: Default Credentials | 9.8 | CRITICAL | — | — |
| A07: Missing secure/httponly cookie | 5.4 | MEDIUM | Session/auth cookie → 7.1 HIGH | — |
| A07: Plaintext credential storage | 8.0 | HIGH | — | — |

Apply escalation/reduction conditions based on code context detected during analysis.

### 3d: EPSS Qualitative Context (FR-010)

Assign qualitative EPSS context per vulnerability class (not a per-finding numeric score):

| Vulnerability Class | EPSS Context |
|---|---|
| SQL Injection | "SQL injection vulnerabilities are actively exploited in 8–15% of exposed systems within 30 days of disclosure" |
| Command Injection | "Command injection vulnerabilities are among the most exploited — exploitation rates exceed 20% within 30 days in exposed systems" |
| Hardcoded Secrets | "Hardcoded credentials are exploited in nearly all cases once discovered via source code access or repository scanning" |
| Weak Crypto (passwords) | "Weak password hashing enables offline cracking attacks; exploitation probability is high once credential databases are obtained" |
| Insecure Random | "Insecure random number generation for security-sensitive functions has a moderate exploitation rate depending on the use case" |
| Open Redirect | "Open redirects are commonly exploited in phishing campaigns; exploitation is straightforward for any attacker with knowledge of the endpoint" |
| Path Traversal | "Path traversal vulnerabilities are actively exploited in 5–12% of exposed systems; automated scanners commonly detect and exploit them" |
| Debug Mode | "Debug mode exposure is commonly exploited in reconnaissance and subsequent attacks; exploitation probability is high if internet-exposed" |
| Permissive CORS | "CORS misconfiguration is exploited in targeted attacks against authenticated users; exploitation requires user interaction" |
| Default Credentials | "Default credentials are exploited immediately upon discovery; automated scanners and credential stuffing tools target these actively" |
| Insecure Cookie | "Missing cookie security flags enable session hijacking; exploitation requires network position or XSS as a prerequisite" |
| Plaintext Credentials | "Plaintext credential storage is exploited whenever storage is accessed; exploitation probability is effectively 100% once storage is read" |

### 3e: Finding Deduplication

After aggregating findings across all batches:

Compute `vuln_id` for each finding using the fingerprint function (see Utility Functions).

If the same `vuln_id` appears multiple times (same vulnerability detected in multiple batches), retain only one instance.

Store deduplicated result as `sast_findings`.

**Error handling**: If any analysis step encounters an error (timeout, context limit, encoding error):

```
Security scan encountered an error: {error_message}

(A) Retry scan
(B) Skip and complete build
(C) Abort build
```

Use AskUserQuestion. If Skip: write `security-scan.md` with error type and timestamp; append `scan-log.jsonl` entry with `status: "ERROR"`. Build continues. If Abort: halt.

---

## Step 4: SCA Analysis

Skip if `sca_status = "SKIPPED"`.

### 4a: Manifest Analysis (FR-011)

For each file in `manifest_files`, analyze:

1. **Known CVE patterns**: Packages with known vulnerability patterns from Claude training data (cutoff: August 2025)
2. **Unsafe version ranges**: `*`, `latest`, `>=0.0.0` in production manifests
3. **Abandoned packages**: Packages where Claude has training knowledge of abandonment or takeover
4. **Supply chain risks**: Known typosquatting targets, known package takeover victims

For each finding, collect raw fields:

```json
{
  "type": "SCA",
  "package_name": "string",
  "package_version": "string (declared version or constraint)",
  "cve_id": "string | null",
  "manifest_source": "requirements.txt",
  "description": "string (with training cutoff note)",
  "recommendation": "string"
}
```

### 4b: SCA Finding Format (FR-012)

For each SCA finding, populate all required fields:

- `severity`: assigned based on CVSS score (CRITICAL ≥9.0, HIGH 7.0–8.9, MEDIUM 4.0–6.9, LOW <4.0)
- `cve_id`: when known from training data; `null` otherwise
- `cvss_score`: NVD v3.1 base score from training data; labeled **"known official CVSS"**
- `epss_context`: qualitative context + note `"(verify current score at first.org/epss)"`
- `kev_flag`: `"YES"` or `"NO"` from training data + note `"(verify at cisa.gov/known-exploited-vulnerabilities)"`
- `kev_verification_url`: `"https://www.cisa.gov/known-exploited-vulnerabilities-catalog"`
- `purl`: ecosystem-appropriate format (see purl formats below)

**purl formats by ecosystem**:
```
Python:     pkg:pypi/{name}@{version}
npm:        pkg:npm/{name}@{version}
npm scoped: pkg:npm/%40{scope}/{name}@{version}
Go:         pkg:golang/{module_path}@{version}
Java:       pkg:maven/{group_id}/{artifact_id}@{version}
Rust:       pkg:cargo/{name}@{version}
Ruby:       pkg:gem/{name}@{version}
```

For `package.json` version ranges (e.g., `^4.17.21`): record the declared constraint in `version` field; add property `aod:version_constraint` noting "resolved version may differ — declare exact version in lock file for definitive audit".

### 4c: KEV=YES Auto-Elevation (FR-015)

Any SCA finding with `kev_flag: "YES"` MUST be elevated to CRITICAL severity regardless of its CVSS base score:

```
if kev_flag == "YES":
    severity = "CRITICAL"
    cvss_note = "(elevated to CRITICAL per KEV rule — actively exploited)"
```

This elevation propagates to the severity gate in Step 5: KEV=YES findings show only (A) Fix now and (C) Abort.

### 4d: CycloneDX SBOM Component Collection

For each package identified in `manifest_files`, create an `SBOMComponent` record:

```json
{
  "type": "library",
  "name": "package-name",
  "version": "declared-version-or-constraint",
  "purl": "pkg:pypi/package-name@1.2.3",
  "properties": [
    {"name": "aod:manifest_source", "value": "requirements.txt"},
    {"name": "aod:version_constraint", "value": "^1.2.3 (resolved version may differ)"}
  ]
}
```

Store as `sbom_components` list. Used by Step 6 (artifact writing) to generate the CycloneDX SBOM.

### 4e: SCA Training-Cutoff Disclaimer (FR-013)

Every SCA section in `security-scan.md` output MUST include verbatim:

> SCA findings are based on Claude training knowledge (cutoff: August 2025). Supplement with real-time CVE scanning (npm audit, pip-audit, Snyk) for production workloads.

**Error handling**: Same retry/skip/abort pattern as Step 3 error handling.

---

## Step 5: Severity Gate

### 5a: Aggregate All Findings

Combine `sast_findings` + `sca_findings` (after KEV elevation) into `all_findings`.

Group:
- `blocking_findings` = findings with `severity` in `["CRITICAL", "HIGH"]`
- `non_blocking_findings` = findings with `severity` in `["MEDIUM", "LOW", "INFO"]`

Also include `reopened_exceptions` (from Step 2c) in `blocking_findings` — they go through the same gate.

### 5b: Display Non-Blocking Findings

For MEDIUM findings, display with note: "Review recommended before deploy"
For LOW/INFO findings, display in report without note.

Non-blocking findings do not halt the build.

### 5c: Clean-Scan Path (US1 AC2, SC-006)

If `blocking_findings` is empty AND there are no `reopened_exceptions`:

Output:
```
Security Scan: PASSED — no issues found (AI-powered analysis; supplement with dedicated SAST tools for production-critical systems)
```

Set `scan_status = "PASSED"`. Auto-proceed to Step 6 (artifact writing).

### 5d: Blocking Acknowledgment Gate (FR-014, FR-015)

For each finding in `blocking_findings` (one at a time):

**Check KEV status**:

- If `kev_flag == "YES"`:
  ```
  {severity} Finding: {owasp_category or cve_id}
    File:     {file}:{line}
    CVSS:     {cvss_score} {severity} ({cvss_type})
    KEV:      YES (actively exploited — verify at cisa.gov/known-exploited-vulnerabilities-catalog)
    EPSS:     {epss_context}
    Summary:  {description}
    Fix:      {recommendation}

  (A) Fix now — halt this session; re-run /aod.build to resume at security scan
  (C) Abort

  Note: Acknowledge option unavailable for KEV=YES findings — actively exploited vulnerability
  ```

- If `kev_flag != "YES"`:
  ```
  {severity} Finding: {owasp_category or cve_id}
    File:     {file}:{line}
    CVSS:     {cvss_score} {severity} ({cvss_type})
    KEV:      {kev_flag}
    EPSS:     {epss_context}
    Summary:  {description}
    Fix:      {recommendation}

  (A) Fix now — halt this session; re-run /aod.build to resume at security scan
  (B) Acknowledge and proceed — requires justification (minimum 20 characters)
  (C) Abort
  ```

Use AskUserQuestion.

**On (A) Fix now**: Halt build session. Developer fixes the issue and re-runs `/aod.build`.

**On (B) Acknowledge and proceed**:
- Prompt: "Enter justification (minimum 20 characters):"
- Validate: `len(justification.strip()) >= 20` — if too short, re-prompt
- Get `accepted_by` via: `git config user.email`
- Compute `acceptance_expiry`:
  - CRITICAL: 30 days from now (ISO 8601 UTC)
  - HIGH: 90 days from now (ISO 8601 UTC)
- Append `RiskAcceptance` entry to `.security/exceptions.jsonl` (see Step 6)
- Record finding as acknowledged; add to `acknowledged_findings` list
- Continue to next blocking finding

**On (C) Abort**: Halt build session entirely.

### 5e: Post-Gate Status

After processing all blocking findings:

- If all were acknowledged: `scan_status = "FINDINGS"` (build proceeds)
- If any were not acknowledged (user selected A or C): build halted

---

## Step 6: Artifact Writing

Write all compliance artifacts. This step executes after the severity gate completes (all blocking findings acknowledged or no blocking findings).

### 6a: security-scan.md (FR-026)

Always write `specs/{NNN}-*/security-scan.md` (or CWD for standalone). Content:

```markdown
# Security Scan Report

**Feature**: {NNN} — {feature_name or branch_name}
**Branch**: {branch}
**Commit**: {commit_sha}
**Scan ID**: {scan_id}
**Timestamp**: {ts_started} UTC
**Status**: {PASSED | FINDINGS | SKIPPED | ERROR}

---

## Summary

| Category | Count |
|---|---|
| Files scanned (SAST) | {code_file_count} |
| Manifests audited (SCA) | {manifest_count} |
| CRITICAL findings | {count} |
| HIGH findings | {count} |
| MEDIUM findings | {count} |
| LOW findings | {count} |
| INFO findings | {count} |

{If --no-security: "Skipped (--no-security) — {timestamp}"}

---

## Findings

{if no findings: "No security findings detected."}

### CRITICAL

{for each CRITICAL finding:}

#### {vuln_id}: {owasp_category or cve_id}

- **Type**: {SAST | SCA}
- **File**: `{file}:{line}`
- **CVSS**: {cvss_score} CRITICAL ({cvss_type})
- **EPSS**: {epss_context}
- **KEV**: {kev_flag}{if YES: " — verify at cisa.gov/known-exploited-vulnerabilities-catalog"}
- **Description**: {description}
- **Recommendation**: {recommendation}
{if acknowledged: "- **Acknowledged**: Yes — Justification: {justification} | Accepted by: {accepted_by} | Expiry: {acceptance_expiry}"}

### HIGH
{same format}

### MEDIUM
{same format, with note: "(Review recommended before deploy)"}

### LOW / INFO
{same format}

---

## Acknowledgment Decisions

{if any acknowledged findings:}

| vuln_id | Severity | Justification | Accepted By | Expiry |
|---|---|---|---|---|
{rows}

{if no acknowledged findings: "No acknowledgment decisions made in this scan."}

---

## Artifacts

- Scan log: `.security/scan-log.jsonl`
- Vulnerability events: `.security/vulnerabilities.jsonl`
- Risk acceptances: `.security/exceptions.jsonl`
- SARIF report: `.security/reports/{commit_sha}.sarif`
{if sca ran: "- CycloneDX SBOM: `.security/reports/sca-{date}.cdx.json`"}

---

*Security Scan: AI-powered analysis; supplement with dedicated SAST tools for production-critical systems.*
{if sca ran: "*SCA findings are based on Claude training knowledge (cutoff: August 2025). Supplement with real-time CVE scanning (npm audit, pip-audit, Snyk) for production workloads.*"}
```

### 6b: scan-log.jsonl (FR-021)

Append exactly one `ScanLogEntry` to `.security/scan-log.jsonl`:

```json
{"scan_id": "{uuid_v4}", "ts": "{ISO_8601_UTC}", "branch": "{branch}", "commit_sha": "{12_char_sha}", "status": "{PASSED|FINDINGS|SKIPPED|ERROR}", "files_scanned": {integer}, "manifests_audited": {integer}, "finding_counts": {"CRITICAL": 0, "HIGH": 0, "MEDIUM": 0, "LOW": 0, "INFO": 0}, "chain_hash": "{sha256_hex}"}
```

Compute `chain_hash` using the chain hash utility function (see Utility Functions).

Append-only. Never modify existing entries.

### 6c: vulnerabilities.jsonl (FR-022)

Append `VulnerabilityEvent` entries to `.security/vulnerabilities.jsonl`:

**DETECTED** (for each new finding where no existing event exists for `vuln_id`, OR most recent event is `REMEDIATED`):

```json
{"vuln_id": "...", "event_type": "DETECTED", "ts": "{ISO_8601_UTC}", "scan_id": "{scan_id}", "detail": {"severity": "{severity}", "file": "{file}", "previous_expiry": null, "original_acceptor": null}}
```

**Deduplication rule**: If the most recent event for a `vuln_id` is `RISK_ACCEPTED`, do NOT append a new `DETECTED` event — surface the existing acceptance status instead.

**RISK_ACCEPTED** (for each acknowledged finding):

```json
{"vuln_id": "...", "event_type": "RISK_ACCEPTED", "ts": "{ISO_8601_UTC}", "scan_id": "{scan_id}", "detail": {"severity": "{severity}", "file": "{file}", "previous_expiry": null, "original_acceptor": null}}
```

**REMEDIATED** (for each `vuln_id` in previous `vulnerabilities.jsonl` that is absent from current scan findings):

```json
{"vuln_id": "...", "event_type": "REMEDIATED", "ts": "{ISO_8601_UTC}", "scan_id": "{scan_id}", "detail": {"severity": "{previous_severity}", "file": "{file}", "previous_expiry": null, "original_acceptor": null}}
```

**EXPIRY_REOPENED** (for each expired exception, written in Step 2c before acknowledgment prompt):

```json
{"vuln_id": "...", "event_type": "EXPIRY_REOPENED", "ts": "{ISO_8601_UTC}", "scan_id": "{scan_id}", "detail": {"severity": "{finding_severity}", "file": "{file_from_exception}", "previous_expiry": "{expired_acceptance_expiry}", "original_acceptor": "{accepted_by_from_exception}"}}
```

Never truncate or rewrite this file.

### 6d: SARIF 2.1.0 Writer (FR-024)

Write `.security/reports/{commit_sha}.sarif`:

```json
{
  "version": "2.1.0",
  "$schema": "https://raw.githubusercontent.com/oasis-tcs/sarif-spec/master/Schemata/sarif-schema-2.1.0.json",
  "runs": [
    {
      "tool": {
        "driver": {
          "name": "aod-security",
          "version": "1.0.0",
          "informationUri": "https://github.com/anthropics/agentic-oriented-development",
          "rules": [
            {
              "id": "{owasp_category_id}",
              "name": "{owasp_category_name}",
              "shortDescription": {"text": "{category description}"},
              "helpUri": "https://owasp.org/Top10/"
            }
          ]
        }
      },
      "results": [
        {
          "ruleId": "{owasp_category_id}",
          "message": {"text": "{description}"},
          "locations": [
            {
              "physicalLocation": {
                "artifactLocation": {"uri": "{relative_file_path}"},
                "region": {"startLine": {line_number}}
              }
            }
          ],
          "partialFingerprints": {
            "primaryLocationLineHash": "{deterministic_hash_of_file_line_description}"
          },
          "level": "{error|warning|note}"
        }
      ]
    }
  ]
}
```

Level mapping: CRITICAL/HIGH → `"error"`, MEDIUM → `"warning"`, LOW/INFO → `"note"`

One `rules[]` entry per distinct OWASP category detected. One `results[]` entry per finding.

**Schema validation**: Before writing, verify the JSON structure is valid SARIF 2.1.0 (check required fields: `version`, `runs[0].tool.driver.name`, `runs[0].results[]`). If validation fails: write `security-scan.md` with validation error note; skip SARIF write.

### 6e: CycloneDX SBOM Writer (FR-025)

Write `.security/reports/sca-{YYYY-MM-DD}.cdx.json` when `manifest_count > 0`:

```json
{
  "bomFormat": "CycloneDX",
  "specVersion": "1.5",
  "serialNumber": "urn:uuid:{uuid_v4}",
  "version": 1,
  "metadata": {
    "timestamp": "{ISO_8601_UTC}",
    "tools": [{"vendor": "aod-security", "name": "aod-security", "version": "1.0.0"}],
    "component": {"type": "application", "name": "{repo_name}", "version": "{commit_sha}"}
  },
  "components": [
    {
      "type": "library",
      "name": "{package_name}",
      "version": "{declared_version_or_constraint}",
      "purl": "{ecosystem_purl}",
      "properties": [
        {"name": "aod:manifest_source", "value": "{manifest_filename}"},
        {"name": "aod:version_constraint", "value": "{constraint_note_or_null}"}
      ]
    }
  ]
}
```

### 6f: exceptions.jsonl Appender (FR-017)

For each acknowledged finding, append one `RiskAcceptance` entry to `.security/exceptions.jsonl`:

```json
{"vuln_id": "...", "finding_severity": "HIGH", "finding_summary": "{one-line description}", "justification": "{user justification}", "accepted_by": "{git_user_email}", "ts_accepted": "{ISO_8601_UTC}", "acceptance_expiry": "{ISO_8601_UTC — 30d CRITICAL / 90d HIGH}"}
```

Append-only. One entry per acknowledgment decision.

---

## Step 7: Commit Strategy (FR-030)

### Commit 1 (always, when scan ran)

Stage: `scan-log.jsonl` + `.security/reports/{commit_sha}.sarif` + `.security/reports/sca-{date}.cdx.json` (if written) + `specs/{NNN}-*/security-scan.md` + `.security/README.md` (if first run)

Commit message:
```
security({NNN}): run security scan [{commit_sha}]
```

### Commit 2 (only when findings acknowledged)

Stage: `.security/vulnerabilities.jsonl` + `.security/exceptions.jsonl`

Commit message:
```
security({NNN}): record risk acceptances
```

**Commit 2 failure handling (FR-031)**:

If Commit 2 fails after Commit 1 succeeds:

1. Catch the error
2. Notify: "Scan evidence committed (Commit 1) but risk acceptances were not recorded (Commit 2 failed: {error})"
3. Retry Commit 2 once
4. If retry fails: display manual recovery steps:
   ```
   Manual recovery required:
     git add .security/vulnerabilities.jsonl .security/exceptions.jsonl
     git commit -m "security({NNN}): record risk acceptances (manual recovery)"
   ```
5. Build MUST NOT proceed silently — the developer must acknowledge the manual recovery requirement

---

## Utility Functions

### vuln_id Fingerprint (FR-023)

**SAST findings**:
```
input   = owasp_category + file + str(line_number) + description[:50]
hash    = SHA256(input)
vuln_id = "{REPO-SLUG}-VULN-" + hash[:12]
```

Where `REPO-SLUG` is the repository directory name (lowercase, hyphens for spaces).

**SCA findings** (when CVE known):
```
input   = cve_id + package_name + package_version
hash    = SHA256(input)
vuln_id = "{REPO-SLUG}-VULN-" + hash[:12]
```

**SCA findings** (no CVE):
```
input   = "SCA:" + package_name + package_version + description[:50]
hash    = SHA256(input)
vuln_id = "{REPO-SLUG}-VULN-" + hash[:12]
```

### chain_hash Computation (FR-021, SC-008)

```
# Serialize entry JSON without the chain_hash field:
# - Sort all keys alphabetically
# - No trailing whitespace or newlines within values

# First entry:
chain_hash = SHA256(serialized_entry_json_without_chain_hash)

# Subsequent entries:
chain_hash = SHA256(previous_entry_chain_hash + serialized_entry_json_without_chain_hash)
```

To compute SHA256 as an AI agent: use Python `hashlib.sha256(input.encode()).hexdigest()` or equivalent. Keys must be sorted alphabetically, JSON compact (no spaces), no trailing newline in the input string.

### Cross-Platform Date Comparison (FR-019)

For expiry checks (bash 3.2 compatible, macOS + Linux):

```bash
OS=$(uname -s)
acceptance_expiry="2026-06-01T12:00:00Z"

if [ "$OS" = "Darwin" ]; then
  expiry_epoch=$(date -j -f "%Y-%m-%dT%H:%M:%SZ" "$acceptance_expiry" +%s)
else
  expiry_epoch=$(date -d "$acceptance_expiry" +%s)
fi

now_epoch=$(date +%s)

if [ "$now_epoch" -gt "$expiry_epoch" ]; then
  echo "EXPIRED"
fi
```

No `declare -A`. No `${var^}`. No `readarray`. Use `case` instead of associative arrays. Use `tr` for case modification. Use `while read` for line-by-line processing.

For computing future expiry dates (ISO 8601 UTC):

```bash
OS=$(uname -s)

if [ "$OS" = "Darwin" ]; then
  # macOS: -v+30d for CRITICAL, -v+90d for HIGH
  expiry_30d=$(date -u -v+30d "+%Y-%m-%dT%H:%M:%SZ")
  expiry_90d=$(date -u -v+90d "+%Y-%m-%dT%H:%M:%SZ")
else
  # Linux:
  expiry_30d=$(date -u -d "+30 days" "+%Y-%m-%dT%H:%M:%SZ")
  expiry_90d=$(date -u -d "+90 days" "+%Y-%m-%dT%H:%M:%SZ")
fi
```

