diff --git a/action.yml b/action.yml index f05b5fe..70ebb0f 100644 --- a/action.yml +++ b/action.yml @@ -59,6 +59,38 @@ inputs: description: "Automatically run the review flow for pull request contexts without requiring an explicit @droid review command. Only supported for PR-related events." required: false default: "false" + automatic_security_review: + description: "Automatically run the security review flow for pull request contexts without requiring an explicit @droid security command. Only supported for PR-related events." + required: false + default: "false" + security_model: + description: "Override the model used for security review (e.g., 'claude-sonnet-4-5-20250929', 'gpt-5.1-codex'). Only applies to security review flows." + required: false + default: "" + security_severity_threshold: + description: "Minimum severity to report in security reviews (critical, high, medium, low). Findings below this threshold will be filtered out." + required: false + default: "medium" + security_block_on_critical: + description: "Submit REQUEST_CHANGES review when critical severity findings are detected." + required: false + default: "true" + security_block_on_high: + description: "Submit REQUEST_CHANGES review when high severity findings are detected." + required: false + default: "false" + security_notify_team: + description: "GitHub team to @mention on critical findings (e.g., '@org/security-team')." + required: false + default: "" + security_scan_schedule: + description: "Enable scheduled security scans. Set to 'true' for schedule events to trigger full repository scans." + required: false + default: "false" + security_scan_days: + description: "Number of days of commits to scan for scheduled security scans. Only applies when security_scan_schedule is enabled." + required: false + default: "7" review_model: description: "Override the model used for code review (e.g., 'claude-sonnet-4-5-20250929', 'gpt-5.1-codex'). Only applies to review flows." required: false @@ -135,6 +167,14 @@ runs: DEFAULT_WORKFLOW_TOKEN: ${{ github.token }} TRACK_PROGRESS: ${{ inputs.track_progress }} AUTOMATIC_REVIEW: ${{ inputs.automatic_review }} + AUTOMATIC_SECURITY_REVIEW: ${{ inputs.automatic_security_review }} + SECURITY_MODEL: ${{ inputs.security_model }} + SECURITY_SEVERITY_THRESHOLD: ${{ inputs.security_severity_threshold }} + SECURITY_BLOCK_ON_CRITICAL: ${{ inputs.security_block_on_critical }} + SECURITY_BLOCK_ON_HIGH: ${{ inputs.security_block_on_high }} + SECURITY_NOTIFY_TEAM: ${{ inputs.security_notify_team }} + SECURITY_SCAN_SCHEDULE: ${{ inputs.security_scan_schedule }} + SECURITY_SCAN_DAYS: ${{ inputs.security_scan_days }} REVIEW_MODEL: ${{ inputs.review_model }} REASONING_EFFORT: ${{ inputs.reasoning_effort }} FILL_MODEL: ${{ inputs.fill_model }} diff --git a/src/create-prompt/index.ts b/src/create-prompt/index.ts index 511fd34..b2ea5af 100644 --- a/src/create-prompt/index.ts +++ b/src/create-prompt/index.ts @@ -66,7 +66,7 @@ export function buildDisallowedToolsString( export function prepareContext( context: ParsedGitHubContext, - droidCommentId: string, + droidCommentId?: string, baseBranch?: string, droidBranch?: string, prBranchData?: { headRefName: string; headRefOid: string }, @@ -288,7 +288,7 @@ export type PromptGenerator = ( export type PromptCreationOptions = { githubContext: ParsedGitHubContext; - commentId: number; + commentId?: number; baseBranch?: string; droidBranch?: string; prBranchData?: { headRefName: string; headRefOid: string }; @@ -310,7 +310,7 @@ export async function createPrompt({ includeActionsTools = false, }: PromptCreationOptions) { try { - const droidCommentId = commentId.toString(); + const droidCommentId = commentId?.toString(); const preparedContext = prepareContext( githubContext, droidCommentId, diff --git a/src/create-prompt/templates/fill-prompt.ts b/src/create-prompt/templates/fill-prompt.ts index cc105d3..86bad58 100644 --- a/src/create-prompt/templates/fill-prompt.ts +++ b/src/create-prompt/templates/fill-prompt.ts @@ -1,8 +1,6 @@ import type { PreparedContext } from "../types"; -export function generateFillPrompt( - context: PreparedContext, -): string { +export function generateFillPrompt(context: PreparedContext): string { const prNumber = context.eventData.isPR ? context.eventData.prNumber : context.githubContext && "entityNumber" in context.githubContext diff --git a/src/create-prompt/templates/security-report-prompt.ts b/src/create-prompt/templates/security-report-prompt.ts new file mode 100644 index 0000000..7a1a0be --- /dev/null +++ b/src/create-prompt/templates/security-report-prompt.ts @@ -0,0 +1,210 @@ +import type { PreparedContext } from "../types"; +import type { ScanScope } from "../../tag/commands/security-scan"; + +export function generateSecurityReportPrompt( + context: PreparedContext, + scanScope: ScanScope, + branchName: string, +): string { + const date = new Date().toISOString().split("T")[0]; + const repoFullName = context.repository; + + const scopeDescription = + scanScope.type === "full" + ? "Entire repository" + : `Last ${scanScope.days} days of commits`; + + const scanTypeLabel = + scanScope.type === "full" ? "Full Repository" : "Weekly Scheduled"; + + const scanInstructions = + scanScope.type === "full" + ? `- Scan all source files in the repository +- Focus on: TypeScript, JavaScript, Python, Go, Java, Ruby, PHP files +- Use: \`find . -type f \\( -name "*.ts" -o -name "*.js" -o -name "*.py" -o -name "*.go" -o -name "*.java" -o -name "*.rb" -o -name "*.php" \\) -not -path "./node_modules/*" -not -path "./.git/*"\`` + : `- Get commits from the last ${scanScope.days} days: \`git log --since="${scanScope.days} days ago" --name-only --pretty=format:""\` +- Focus analysis on files changed in recent commits +- Scan each changed file for security vulnerabilities`; + + // Extract security configuration from context + const securityConfig = context.githubContext?.inputs; + const severityThreshold = + securityConfig?.securitySeverityThreshold ?? "medium"; + const notifyTeam = securityConfig?.securityNotifyTeam ?? ""; + + return `You are performing a ${scanTypeLabel.toLowerCase()} security scan for ${repoFullName}. +The gh CLI is installed and authenticated via GH_TOKEN. + +## Scan Configuration +- Scope: ${scopeDescription} +- Severity Threshold: ${severityThreshold} (only report findings at or above this level) +- Output Branch: ${branchName} +- Report Path: .factory/security/reports/security-report-${date}.md + +## Security Skills Available + +You have access to these Factory security skills (installed in ~/.factory/skills/): + +1. **threat-model-generation** - Generate STRIDE-based threat model for the repository +2. **commit-security-scan** - Scan code for security vulnerabilities +3. **vulnerability-validation** - Validate findings, assess exploitability, filter false positives +4. **security-review** - Comprehensive security review and patch generation + +## Workflow + +### Step 1: Create Branch +\`\`\`bash +git checkout -b ${branchName} +\`\`\` + +### Step 2: Threat Model Check +- Check if \`.factory/threat-model.md\` exists in the repository +- If missing: Invoke the **threat-model-generation** skill to create one +- If exists: Check the file's last modified date + - If >90 days old: Regenerate and update the threat model + - If current: Use it as context for the security scan + +### Step 3: Security Scan +${scanInstructions} + +For each file: +- Invoke the **commit-security-scan** skill +- Look for STRIDE vulnerabilities (Spoofing, Tampering, Repudiation, Information Disclosure, Denial of Service, Elevation of Privilege) + +### Step 4: Validate Findings +- For each finding from Step 3, invoke the **vulnerability-validation** skill +- Assess: + - Reachability: Is the vulnerable code reachable from user input? + - Exploitability: How easy is it to exploit? + - Impact: What's the potential damage? +- Filter out false positives and findings below the severity threshold (${severityThreshold}) + +### Step 5: Generate Patches +- For each confirmed finding that can be auto-fixed: + - Invoke **security-review** skill to generate patches + - Apply the patch to the codebase + - Commit the fix with message: \`fix(security): [VULN-XXX] Brief description\` + +### Step 6: Generate Report +- Create directory: \`mkdir -p .factory/security/reports\` +- Write report to: \`.factory/security/reports/security-report-${date}.md\` + +### Step 7: Create PR +\`\`\`bash +git add . +git commit -m "fix(security): Security scan report - ${date}" +git push origin ${branchName} +gh pr create --title "fix(security): Security scan report - ${date} (N findings)" \\ + --body "## Security Scan Report + +See \`.factory/security/reports/security-report-${date}.md\` for details. + +### Summary +| Severity | Count | Auto-fixed | Manual Required | +|----------|-------|------------|-----------------| +| CRITICAL | X | X | X | +| HIGH | X | X | X | +| MEDIUM | X | X | X | +| LOW | X | X | X | + +${notifyTeam ? `cc ${notifyTeam}` : ""}" +\`\`\` + +## Report Format + +The report file should follow this structure: + +\`\`\`markdown +# Security Scan Report + +**Generated:** ${date} +**Scan Type:** ${scanTypeLabel} +**Repository:** ${repoFullName} +**Severity Threshold:** ${severityThreshold} + +## Executive Summary + +| Severity | Count | Auto-fixed | Manual Required | +|----------|-------|------------|-----------------| +| CRITICAL | X | X | X | +| HIGH | X | X | X | +| MEDIUM | X | X | X | +| LOW | X | X | X | + +**Total Findings:** X +**Auto-fixed:** X +**Manual Review Required:** X + +## Critical Findings + +### VULN-001: [Vulnerability Title] + +| Attribute | Value | +|-----------|-------| +| **Severity** | CRITICAL | +| **STRIDE Category** | [Tampering/Spoofing/etc] | +| **CWE** | CWE-XXX | +| **File** | path/to/file.ts:line | +| **Status** | Patched / Manual fix required | + +**Description:** +[Clear explanation of the vulnerability] + +**Evidence:** +\\\`\\\`\\\` +[Code snippet showing the vulnerability] +\\\`\\\`\\\` + +**Fix Applied:** (if auto-patched) +\\\`\\\`\\\`diff +- vulnerable code ++ secure code +\\\`\\\`\\\` + +**Recommended Fix:** (if manual) +[Step-by-step remediation guidance] + +--- + +## High Findings +[Same format as Critical] + +## Medium Findings +[Same format as Critical] + +## Low Findings +[Same format as Critical] + +## Appendix + +### Threat Model +- Version: [date or "newly generated"] +- Location: .factory/threat-model.md + +### Scan Metadata +- ${scanScope.type === "full" ? "Files" : "Commits"} Scanned: N +- Scan Duration: Xm Ys +- Skills Used: threat-model-generation, commit-security-scan, vulnerability-validation, security-review + +### References +- [CWE Database](https://cwe.mitre.org/) +- [STRIDE Threat Model](https://docs.microsoft.com/en-us/azure/security/develop/threat-modeling-tool-threats) +\`\`\` + +## Severity Definitions + +| Severity | Criteria | Examples | +|----------|----------|----------| +| **CRITICAL** | Immediately exploitable, high impact, no auth required | RCE, hardcoded secrets, auth bypass | +| **HIGH** | Exploitable with conditions, significant impact | SQL injection, stored XSS, IDOR | +| **MEDIUM** | Requires specific conditions, moderate impact | CSRF, info disclosure, missing rate limits | +| **LOW** | Difficult to exploit, low impact | Verbose errors, missing security headers | + +## Important Notes + +1. **Accuracy**: Only report high-confidence findings. False positives waste developer time. +2. **Patches**: Test all generated patches before committing. Ensure they don't break functionality. +3. **PR Description**: Update the PR body with actual finding counts before creating. +4. **Commit Messages**: Use semantic commit format: \`fix(security): [VULN-XXX] Description\` +`; +} diff --git a/src/create-prompt/templates/security-review-prompt.ts b/src/create-prompt/templates/security-review-prompt.ts new file mode 100644 index 0000000..f71e3dc --- /dev/null +++ b/src/create-prompt/templates/security-review-prompt.ts @@ -0,0 +1,168 @@ +import type { PreparedContext } from "../types"; + +export function generateSecurityReviewPrompt(context: PreparedContext): string { + const prNumber = context.eventData.isPR + ? context.eventData.prNumber + : context.githubContext && "entityNumber" in context.githubContext + ? String(context.githubContext.entityNumber) + : "unknown"; + + const repoFullName = context.repository; + const headRefName = context.prBranchData?.headRefName ?? "unknown"; + const headSha = context.prBranchData?.headRefOid ?? "unknown"; + const baseRefName = context.eventData.baseBranch ?? "unknown"; + + // Extract security configuration from context + const securityConfig = context.githubContext?.inputs; + const severityThreshold = + securityConfig?.securitySeverityThreshold ?? "medium"; + const blockOnCritical = securityConfig?.securityBlockOnCritical ?? true; + const blockOnHigh = securityConfig?.securityBlockOnHigh ?? false; + const notifyTeam = securityConfig?.securityNotifyTeam ?? ""; + + return `You are performing a security-focused code review for PR #${prNumber} in ${repoFullName}. +The gh CLI is installed and authenticated via GH_TOKEN. + +## Context +- Repo: ${repoFullName} +- PR Number: ${prNumber} +- PR Head Ref: ${headRefName} +- PR Head SHA: ${headSha} +- PR Base Ref: ${baseRefName} + +## Security Configuration +- Severity Threshold: ${severityThreshold} (only report findings at or above this level) +- Block on Critical: ${blockOnCritical} +- Block on High: ${blockOnHigh} +${notifyTeam ? `- Notify Team: ${notifyTeam} (mention on critical findings)` : ""} + +## Security Skills Available + +You have access to these Factory security skills (installed in ~/.factory/skills/): + +1. **threat-model-generation** - Generate STRIDE-based threat model for the repository +2. **commit-security-scan** - Scan code changes for security vulnerabilities +3. **vulnerability-validation** - Validate findings, assess exploitability, filter false positives +4. **security-review** - Comprehensive security review and patch generation + +## Review Workflow + +### Step 1: Threat Model Check +- Check if \`.factory/threat-model.md\` exists in the repository +- If missing: Note this in the summary (threat model generation is done separately, not during PR review) +- If exists: Use it as context for the security scan + +### Step 2: Security Scan +- Invoke the **commit-security-scan** skill on the PR diff +- Gather the PR diff using: \`gh pr diff ${prNumber} --repo ${repoFullName}\` +- Get file changes: \`gh api repos/${repoFullName}/pulls/${prNumber}/files --paginate\` +- Focus analysis on: + - New code introduced by this PR + - Modified code that may introduce vulnerabilities + - Changes that expose existing vulnerabilities + +### Step 3: Validate Findings +- For each finding from Step 2, invoke the **vulnerability-validation** skill +- Assess: + - Reachability: Is the vulnerable code reachable from user input? + - Exploitability: How easy is it to exploit? + - Impact: What's the potential damage? +- Filter out false positives and findings below the severity threshold (${severityThreshold}) + +### Step 4: Report Findings +- For each confirmed finding at or above ${severityThreshold} severity: + - Record in the JSON output file + - Include: severity, STRIDE category, CWE ID, clear explanation, suggested fix +- For auto-fixable issues: Include the suggested patch in the finding's suggestion field + +## Security Scope (STRIDE Categories) + +**Spoofing** (S): +- Weak authentication, session hijacking, token exposure + +**Tampering** (T): +- SQL/NoSQL/command injection, XSS, mass assignment, unsafe deserialization + +**Repudiation** (R): +- Missing audit logs, unsigned transactions + +**Information Disclosure** (I): +- IDOR, verbose errors, hardcoded secrets, sensitive data in logs + +**Denial of Service** (D): +- Missing rate limits, resource exhaustion, ReDoS + +**Elevation of Privilege** (E): +- Missing authorization checks, role manipulation, privilege escalation + +## Severity Definitions + +| Severity | Criteria | Examples | +|----------|----------|----------| +| **CRITICAL** | Immediately exploitable, high impact, no auth required | RCE, hardcoded secrets, auth bypass | +| **HIGH** | Exploitable with conditions, significant impact | SQL injection, stored XSS, IDOR | +| **MEDIUM** | Requires specific conditions, moderate impact | CSRF, info disclosure, missing rate limits | +| **LOW** | Difficult to exploit, low impact | Verbose errors, missing security headers | + +## Output Requirements + +IMPORTANT: Do NOT post inline comments directly. Instead, write findings to a JSON file. +The finalize step will post all inline comments to avoid overlapping with code review comments. + +1. Write findings to \`security-review-results.json\` with this structure: +\`\`\`json +{ + "type": "security", + "findings": [ + { + "id": "SEC-001", + "severity": "CRITICAL|HIGH|MEDIUM|LOW", + "type": "SQL Injection", + "stride": "T", + "cwe": "CWE-89", + "file": "path/to/file.ts", + "line": 55, + "side": "RIGHT", + "description": "Brief description of the vulnerability", + "suggestion": "Optional code fix" + } + ], + "summary": "Brief overall summary", + "block_pr": ${blockOnCritical || blockOnHigh ? "true if CRITICAL/HIGH found" : "false"} +} +\`\`\` + +2. Update the tracking comment using \`github_comment___update_droid_comment\` + +## Summary Format (for tracking comment update) + +Use \`github_comment___update_droid_comment\` to update the tracking comment with this format: + +\`\`\`markdown +## 🔐 Security Review Summary + +| Severity | Count | +|----------|-------| +| 🚨 CRITICAL | X | +| 🔴 HIGH | X | +| 🟡 MEDIUM | X | +| 🟢 LOW | X | + +### Findings +| ID | Severity | Type | File | Line | Reference | +|----|----------|------|------|------|-----------| +| SEC-001 | CRITICAL | SQL Injection | auth.ts | 55 | [CWE-89](https://cwe.mitre.org/data/definitions/89.html) | +| SEC-002 | HIGH | XSS | client.ts | 98 | [CWE-79](https://cwe.mitre.org/data/definitions/79.html) | + +${notifyTeam ? `cc ${notifyTeam}` : ""} +\`\`\` + +## Accuracy Requirements + +- Base findings strictly on the current diff and repository context +- False positives are very costly—only report high-confidence findings +- If confidence is low, ask a clarifying question instead of asserting a vulnerability +- Never raise purely stylistic concerns +- Cap at 10 inline comments total +`; +} diff --git a/src/create-prompt/types.ts b/src/create-prompt/types.ts index 75af3a4..cb1d8df 100644 --- a/src/create-prompt/types.ts +++ b/src/create-prompt/types.ts @@ -2,7 +2,7 @@ import type { GitHubContext } from "../github/context"; export type CommonFields = { repository: string; - droidCommentId: string; + droidCommentId?: string; triggerPhrase: string; triggerUsername?: string; droidBranch?: string; diff --git a/src/entrypoints/collect-inputs.ts b/src/entrypoints/collect-inputs.ts index 6eb8b3b..442da29 100644 --- a/src/entrypoints/collect-inputs.ts +++ b/src/entrypoints/collect-inputs.ts @@ -26,6 +26,14 @@ export function collectActionInputsPresence(): void { experimental_allowed_domains: "", track_progress: "false", automatic_review: "false", + automatic_security_review: "false", + security_model: "", + security_severity_threshold: "medium", + security_block_on_critical: "true", + security_block_on_high: "false", + security_notify_team: "", + security_scan_schedule: "false", + security_scan_days: "7", }; const allInputsJson = process.env.ALL_INPUTS; diff --git a/src/github/api/queries/github.ts b/src/github/api/queries/github.ts index 2341a55..36efc22 100644 --- a/src/github/api/queries/github.ts +++ b/src/github/api/queries/github.ts @@ -123,3 +123,13 @@ export const USER_QUERY = ` } } `; + +export const REPO_DEFAULT_BRANCH_QUERY = ` + query($owner: String!, $repo: String!) { + repository(owner: $owner, name: $repo) { + defaultBranchRef { + name + } + } + } +`; diff --git a/src/github/context.ts b/src/github/context.ts index 9b4306d..15592f3 100644 --- a/src/github/context.ts +++ b/src/github/context.ts @@ -90,6 +90,14 @@ type BaseContext = { allowedNonWriteUsers: string; trackProgress: boolean; automaticReview: boolean; + automaticSecurityReview: boolean; + securityModel: string; + securitySeverityThreshold: string; + securityBlockOnCritical: boolean; + securityBlockOnHigh: boolean; + securityNotifyTeam: string; + securityScanSchedule: boolean; + securityScanDays: number; }; }; @@ -140,6 +148,16 @@ export function parseGitHubContext(): GitHubContext { allowedNonWriteUsers: process.env.ALLOWED_NON_WRITE_USERS ?? "", trackProgress: process.env.TRACK_PROGRESS === "true", automaticReview: process.env.AUTOMATIC_REVIEW === "true", + automaticSecurityReview: process.env.AUTOMATIC_SECURITY_REVIEW === "true", + securityModel: process.env.SECURITY_MODEL ?? "", + securitySeverityThreshold: + process.env.SECURITY_SEVERITY_THRESHOLD ?? "medium", + securityBlockOnCritical: + process.env.SECURITY_BLOCK_ON_CRITICAL !== "false", + securityBlockOnHigh: process.env.SECURITY_BLOCK_ON_HIGH === "true", + securityNotifyTeam: process.env.SECURITY_NOTIFY_TEAM ?? "", + securityScanSchedule: process.env.SECURITY_SCAN_SCHEDULE === "true", + securityScanDays: Math.max(1, parseInt(process.env.SECURITY_SCAN_DAYS ?? "7", 10) || 7), }, }; diff --git a/src/github/data/pr-fetcher.ts b/src/github/data/pr-fetcher.ts index 6cc04fb..28e7188 100644 --- a/src/github/data/pr-fetcher.ts +++ b/src/github/data/pr-fetcher.ts @@ -1,5 +1,5 @@ import type { Octokits } from "../api/client"; -import { PR_QUERY } from "../api/queries/github"; +import { PR_QUERY, REPO_DEFAULT_BRANCH_QUERY } from "../api/queries/github"; import type { GitHubPullRequest } from "../types"; /** @@ -58,3 +58,46 @@ export async function fetchPRBranchData({ throw new Error(`Failed to fetch PR branch data for PR #${prNumber}`); } } + +type RepoDefaultBranchQueryResponse = { + repository: { + defaultBranchRef: { + name: string; + } | null; + }; +}; + +/** + * Fetches the repository's default branch name. + * Used by security-scan which operates without a PR context. + */ +export async function fetchRepoDefaultBranch({ + octokits, + repository, +}: { + octokits: Octokits; + repository: { owner: string; repo: string }; +}): Promise { + try { + const result = await octokits.graphql( + REPO_DEFAULT_BRANCH_QUERY, + { + owner: repository.owner, + repo: repository.repo, + }, + ); + + if (!result.repository.defaultBranchRef) { + throw new Error( + `Default branch not found for ${repository.owner}/${repository.repo}`, + ); + } + + return result.repository.defaultBranchRef.name; + } catch (error) { + console.error(`Failed to fetch default branch:`, error); + throw new Error( + `Failed to fetch default branch for ${repository.owner}/${repository.repo}`, + ); + } +} diff --git a/src/github/operations/comments/common.ts b/src/github/operations/comments/common.ts index 0ff5429..d3ab2be 100644 --- a/src/github/operations/comments/common.ts +++ b/src/github/operations/comments/common.ts @@ -18,11 +18,19 @@ export function createBranchLink( return `\n[View branch](${branchUrl})`; } +export type CommentType = "default" | "security"; + export function createCommentBody( jobRunLink: string, branchLink: string = "", + type: CommentType = "default", ): string { - return `Droid is working… + const message = + type === "security" + ? "Droid is running a security check…" + : "Droid is working…"; + + return `${message} ${jobRunLink}${branchLink}`; } diff --git a/src/github/operations/comments/create-initial.ts b/src/github/operations/comments/create-initial.ts index 46f2047..2292fb8 100644 --- a/src/github/operations/comments/create-initial.ts +++ b/src/github/operations/comments/create-initial.ts @@ -6,7 +6,11 @@ */ import { appendFileSync } from "fs"; -import { createJobRunLink, createCommentBody } from "./common"; +import { + createJobRunLink, + createCommentBody, + type CommentType, +} from "./common"; import { isPullRequestReviewCommentEvent, isPullRequestEvent, @@ -19,11 +23,12 @@ const DROID_APP_BOT_ID = 209825114; export async function createInitialComment( octokit: Octokit, context: ParsedGitHubContext, + commentType: CommentType = "default", ) { const { owner, repo } = context.repository; const jobRunLink = createJobRunLink(owner, repo, context.runId); - const initialBody = createCommentBody(jobRunLink); + const initialBody = createCommentBody(jobRunLink, "", commentType); try { let response; diff --git a/src/github/utils/command-parser.test.ts b/src/github/utils/command-parser.test.ts index b0be23f..602a13b 100644 --- a/src/github/utils/command-parser.test.ts +++ b/src/github/utils/command-parser.test.ts @@ -27,6 +27,14 @@ const baseContext: Omit = { allowedNonWriteUsers: "", trackProgress: false, automaticReview: false, + automaticSecurityReview: false, + securityModel: "", + securitySeverityThreshold: "medium", + securityBlockOnCritical: true, + securityBlockOnHigh: false, + securityNotifyTeam: "", + securityScanSchedule: false, + securityScanDays: 7, }, entityNumber: 1, isPR: true, @@ -71,6 +79,61 @@ describe("Command Parser", () => { expect(result?.raw).toBe("@droid review"); }); + it("should detect @droid review security (combined)", () => { + const result = parseDroidCommand("@droid review security"); + expect(result?.command).toBe("review-security"); + expect(result?.raw).toBe("@droid review security"); + }); + + it("should detect @droid security review (combined)", () => { + const result = parseDroidCommand("@droid security review"); + expect(result?.command).toBe("review-security"); + expect(result?.raw).toBe("@droid security review"); + }); + + it("should be case insensitive for combined commands", () => { + const result = parseDroidCommand("@DROID REVIEW SECURITY"); + expect(result?.command).toBe("review-security"); + }); + + it("should prioritize combined over individual review", () => { + const result = parseDroidCommand("@droid review security please"); + expect(result?.command).toBe("review-security"); + }); + + it("should detect @droid security", () => { + const result = parseDroidCommand("Please @droid security this PR"); + expect(result?.command).toBe("security"); + expect(result?.raw).toBe("@droid security"); + }); + + it("should detect @droid security at end of text", () => { + const result = parseDroidCommand("Please run @droid security"); + expect(result?.command).toBe("security"); + expect(result?.raw).toBe("@droid security"); + }); + + it("should be case insensitive for @droid security", () => { + const result = parseDroidCommand("@DROID SECURITY"); + expect(result?.command).toBe("security"); + }); + + it("should detect @droid security --full", () => { + const result = parseDroidCommand("@droid security --full"); + expect(result?.command).toBe("security-full"); + expect(result?.raw).toBe("@droid security --full"); + }); + + it("should be case insensitive for @droid security --full", () => { + const result = parseDroidCommand("@DROID SECURITY --FULL"); + expect(result?.command).toBe("security-full"); + }); + + it("should prioritize security-full over security", () => { + const result = parseDroidCommand("@droid security --full this repo"); + expect(result?.command).toBe("security-full"); + }); + it("should prioritize specific commands over default", () => { // If text has both @droid fill and just @droid, should detect fill const result = parseDroidCommand("@droid please @droid fill this"); @@ -125,17 +188,14 @@ describe("Command Parser", () => { describe("extractCommandFromContext", () => { it("should extract from PR body", () => { - const context = createContext( - "pull_request", - { - action: "opened", - pull_request: { - body: "PR description\n\n@droid fill", - number: 1, - title: "PR", - }, - } as unknown as PullRequestEvent, - ); + const context = createContext("pull_request", { + action: "opened", + pull_request: { + body: "PR description\n\n@droid fill", + number: 1, + title: "PR", + }, + } as unknown as PullRequestEvent); const result = extractCommandFromContext(context); expect(result?.command).toBe("fill"); expect(result?.location).toBe("body"); @@ -160,20 +220,17 @@ describe("Command Parser", () => { }); it("should extract from issue comment", () => { - const context = createContext( - "issue_comment", - { - action: "created", - comment: { - body: "@droid fill please", - created_at: "2024-01-01T00:00:00Z", - }, - issue: { - number: 1, - pull_request: { url: "" }, - }, - } as unknown as IssueCommentEvent, - ); + const context = createContext("issue_comment", { + action: "created", + comment: { + body: "@droid fill please", + created_at: "2024-01-01T00:00:00Z", + }, + issue: { + number: 1, + pull_request: { url: "" }, + }, + } as unknown as IssueCommentEvent); const result = extractCommandFromContext(context); expect(result?.command).toBe("fill"); expect(result?.location).toBe("comment"); @@ -181,19 +238,16 @@ describe("Command Parser", () => { }); it("should extract from PR review comment", () => { - const context = createContext( - "pull_request_review_comment", - { - action: "created", - comment: { - body: "Can you @droid review this section?", - created_at: "2024-01-01T00:00:00Z", - }, - pull_request: { - number: 1, - }, - } as unknown as PullRequestReviewCommentEvent, - ); + const context = createContext("pull_request_review_comment", { + action: "created", + comment: { + body: "Can you @droid review this section?", + created_at: "2024-01-01T00:00:00Z", + }, + pull_request: { + number: 1, + }, + } as unknown as PullRequestReviewCommentEvent); const result = extractCommandFromContext(context); expect(result?.command).toBe("review"); expect(result?.location).toBe("comment"); @@ -201,37 +255,62 @@ describe("Command Parser", () => { }); it("should extract from PR review body", () => { - const context = createContext( - "pull_request_review", - { - action: "submitted", - review: { - body: "LGTM but @droid fill the description", - submitted_at: "2024-01-01T00:00:00Z", - }, - pull_request: { - number: 1, - }, - } as unknown as PullRequestReviewEvent, - ); + const context = createContext("pull_request_review", { + action: "submitted", + review: { + body: "LGTM but @droid fill the description", + submitted_at: "2024-01-01T00:00:00Z", + }, + pull_request: { + number: 1, + }, + } as unknown as PullRequestReviewEvent); const result = extractCommandFromContext(context); expect(result?.command).toBe("fill"); expect(result?.location).toBe("comment"); expect(result?.timestamp).toBe("2024-01-01T00:00:00Z"); }); + it("should extract security from PR body", () => { + const context = createContext("pull_request", { + action: "opened", + pull_request: { + body: "PR description\n\n@droid security", + number: 1, + title: "PR", + }, + } as unknown as PullRequestEvent); + const result = extractCommandFromContext(context); + expect(result?.command).toBe("security"); + expect(result?.location).toBe("body"); + }); + + it("should extract security-full from issue comment", () => { + const context = createContext("issue_comment", { + action: "created", + comment: { + body: "@droid security --full", + created_at: "2024-01-01T00:00:00Z", + }, + issue: { + number: 1, + pull_request: { url: "" }, + }, + } as unknown as IssueCommentEvent); + const result = extractCommandFromContext(context); + expect(result?.command).toBe("security-full"); + expect(result?.location).toBe("comment"); + }); + it("should return null for events without commands", () => { - const context = createContext( - "pull_request", - { - action: "opened", - pull_request: { - body: "Regular PR description", - number: 1, - title: "PR", - }, - } as unknown as PullRequestEvent, - ); + const context = createContext("pull_request", { + action: "opened", + pull_request: { + body: "Regular PR description", + number: 1, + title: "PR", + }, + } as unknown as PullRequestEvent); const result = extractCommandFromContext(context); expect(result).toBeNull(); }); @@ -247,17 +326,14 @@ describe("Command Parser", () => { }); it("should handle missing body gracefully", () => { - const context = createContext( - "pull_request", - { - action: "opened", - pull_request: { - body: null, - number: 1, - title: "PR", - }, - } as unknown as PullRequestEvent, - ); + const context = createContext("pull_request", { + action: "opened", + pull_request: { + body: null, + number: 1, + title: "PR", + }, + } as unknown as PullRequestEvent); const result = extractCommandFromContext(context); expect(result).toBeNull(); }); @@ -273,20 +349,17 @@ describe("Command Parser", () => { }); it("should extract default command when no specific command", () => { - const context = createContext( - "issue_comment", - { - action: "created", - comment: { - body: "@droid can you help with this?", - created_at: "2024-01-01T00:00:00Z", - }, - issue: { - number: 1, - pull_request: { url: "" }, - }, - } as unknown as IssueCommentEvent, - ); + const context = createContext("issue_comment", { + action: "created", + comment: { + body: "@droid can you help with this?", + created_at: "2024-01-01T00:00:00Z", + }, + issue: { + number: 1, + pull_request: { url: "" }, + }, + } as unknown as IssueCommentEvent); const result = extractCommandFromContext(context); expect(result?.command).toBe("default"); expect(result?.location).toBe("comment"); diff --git a/src/github/utils/command-parser.ts b/src/github/utils/command-parser.ts index a1250ca..182cac5 100644 --- a/src/github/utils/command-parser.ts +++ b/src/github/utils/command-parser.ts @@ -4,12 +4,18 @@ import type { GitHubContext } from "../context"; -export type DroidCommand = 'fill' | 'review' | 'default'; +export type DroidCommand = + | "fill" + | "review" + | "security" + | "review-security" + | "security-full" + | "default"; export interface ParsedCommand { command: DroidCommand; raw: string; - location: 'body' | 'comment'; + location: "body" | "comment"; timestamp?: string | null; } @@ -27,9 +33,22 @@ export function parseDroidCommand(text: string): ParsedCommand | null { const fillMatch = text.match(/@droid\s+fill/i); if (fillMatch) { return { - command: 'fill', + command: "fill", raw: fillMatch[0], - location: 'body', // Will be set by caller + location: "body", // Will be set by caller + }; + } + + // Check for @droid review security OR @droid security review (both reviews) + // Must check before individual review/security to avoid false matches + const combinedMatch = text.match( + /@droid\s+(?:review\s+security|security\s+review)/i, + ); + if (combinedMatch) { + return { + command: "review-security", + raw: combinedMatch[0], + location: "body", // Will be set by caller }; } @@ -37,9 +56,30 @@ export function parseDroidCommand(text: string): ParsedCommand | null { const reviewMatch = text.match(/@droid\s+review/i); if (reviewMatch) { return { - command: 'review', + command: "review", raw: reviewMatch[0], - location: 'body', // Will be set by caller + location: "body", // Will be set by caller + }; + } + + // Check for @droid security --full command (case insensitive) + const securityFullMatch = text.match(/@droid\s+security\s+--full/i); + if (securityFullMatch) { + return { + command: "security-full", + raw: securityFullMatch[0], + location: "body", // Will be set by caller + }; + } + + // Check for @droid security command (case insensitive) + // Must check after security-full to avoid false matches + const securityMatch = text.match(/@droid\s+security(?:\s|$|[^-\w])/i); + if (securityMatch) { + return { + command: "security", + raw: securityMatch[0].trim(), + location: "body", // Will be set by caller }; } @@ -47,9 +87,9 @@ export function parseDroidCommand(text: string): ParsedCommand | null { const droidMatch = text.match(/@droid/i); if (droidMatch) { return { - command: 'default', + command: "default", raw: droidMatch[0], - location: 'body', // Will be set by caller + location: "body", // Will be set by caller }; } @@ -61,43 +101,48 @@ export function parseDroidCommand(text: string): ParsedCommand | null { * @param context The GitHub context from the event * @returns ParsedCommand with location info, or null if no command found */ -export function extractCommandFromContext(context: GitHubContext): ParsedCommand | null { +export function extractCommandFromContext( + context: GitHubContext, +): ParsedCommand | null { // Handle missing payload if (!context.payload) { return null; } // Check PR body for commands (pull_request events) - if (context.eventName === 'pull_request' && 'pull_request' in context.payload) { + if ( + context.eventName === "pull_request" && + "pull_request" in context.payload + ) { const body = context.payload.pull_request.body; if (body) { const command = parseDroidCommand(body); if (command) { - return { ...command, location: 'body' }; + return { ...command, location: "body" }; } } } // Check issue body for commands (issues events) - if (context.eventName === 'issues' && 'issue' in context.payload) { + if (context.eventName === "issues" && "issue" in context.payload) { const body = context.payload.issue.body; if (body) { const command = parseDroidCommand(body); if (command) { - return { ...command, location: 'body' }; + return { ...command, location: "body" }; } } } // Check comment body for commands (issue_comment events) - if (context.eventName === 'issue_comment' && 'comment' in context.payload) { + if (context.eventName === "issue_comment" && "comment" in context.payload) { const comment = context.payload.comment; if (comment.body) { const command = parseDroidCommand(comment.body); if (command) { return { ...command, - location: 'comment', + location: "comment", timestamp: comment.created_at, }; } @@ -105,14 +150,17 @@ export function extractCommandFromContext(context: GitHubContext): ParsedCommand } // Check review comment body (pull_request_review_comment events) - if (context.eventName === 'pull_request_review_comment' && 'comment' in context.payload) { + if ( + context.eventName === "pull_request_review_comment" && + "comment" in context.payload + ) { const comment = context.payload.comment; if (comment.body) { const command = parseDroidCommand(comment.body); if (command) { return { ...command, - location: 'comment', + location: "comment", timestamp: comment.created_at, }; } @@ -120,14 +168,17 @@ export function extractCommandFromContext(context: GitHubContext): ParsedCommand } // Check review body (pull_request_review events) - if (context.eventName === 'pull_request_review' && 'review' in context.payload) { + if ( + context.eventName === "pull_request_review" && + "review" in context.payload + ) { const review = context.payload.review; if (review.body) { const command = parseDroidCommand(review.body); if (command) { return { ...command, - location: 'comment', + location: "comment", timestamp: review.submitted_at, }; } diff --git a/src/github/validation/trigger-commands.test.ts b/src/github/validation/trigger-commands.test.ts index 1d44a46..aa0197d 100644 --- a/src/github/validation/trigger-commands.test.ts +++ b/src/github/validation/trigger-commands.test.ts @@ -9,8 +9,9 @@ import type { PullRequestReviewEvent, } from "@octokit/webhooks-types"; -type ContextOverrides = - Partial> & { payload?: unknown }; +type ContextOverrides = Partial> & { + payload?: unknown; +}; const defaultPayload = { action: "created", @@ -27,7 +28,9 @@ const defaultPayload = { } as unknown as IssueCommentEvent; // Helper function to create a mock context -function createMockContext(overrides: ContextOverrides = {}): ParsedGitHubContext { +function createMockContext( + overrides: ContextOverrides = {}, +): ParsedGitHubContext { return { runId: "run-1", eventName: "issue_comment", @@ -50,6 +53,7 @@ function createMockContext(overrides: ContextOverrides = {}): ParsedGitHubContex botName: "droid[bot]", trackProgress: false, automaticReview: false, + automaticSecurityReview: false, useStickyComment: false, }, payload: defaultPayload, @@ -70,7 +74,7 @@ describe("checkContainsTrigger with commands", () => { }, } as unknown as PullRequestEvent, }); - + expect(checkContainsTrigger(context)).toBe(true); }); @@ -84,7 +88,7 @@ describe("checkContainsTrigger with commands", () => { }, } as unknown as IssueCommentEvent, }); - + expect(checkContainsTrigger(context)).toBe(true); }); @@ -101,7 +105,7 @@ describe("checkContainsTrigger with commands", () => { }, } as unknown as PullRequestReviewCommentEvent, }); - + expect(checkContainsTrigger(context)).toBe(true); }); @@ -118,7 +122,21 @@ describe("checkContainsTrigger with commands", () => { }, } as unknown as PullRequestReviewEvent, }); - + + expect(checkContainsTrigger(context)).toBe(true); + }); + + it("should trigger on @droid security in issue comment", () => { + const context = createMockContext({ + eventName: "issue_comment", + eventAction: "created", + payload: { + comment: { + body: "Can you @droid security this PR?", + }, + } as unknown as IssueCommentEvent, + }); + expect(checkContainsTrigger(context)).toBe(true); }); @@ -132,7 +150,7 @@ describe("checkContainsTrigger with commands", () => { }, } as unknown as IssueCommentEvent, }); - + // This should still trigger because of the existing trigger phrase logic // but now it will be handled as default command expect(checkContainsTrigger(context)).toBe(true); @@ -148,7 +166,7 @@ describe("checkContainsTrigger with commands", () => { }, } as unknown as IssueCommentEvent, }); - + expect(checkContainsTrigger(context)).toBe(true); }); @@ -162,7 +180,7 @@ describe("checkContainsTrigger with commands", () => { }, } as unknown as IssueCommentEvent, }); - + expect(checkContainsTrigger(context)).toBe(false); }); @@ -179,7 +197,7 @@ describe("checkContainsTrigger with commands", () => { }, } as unknown as IssuesEvent, }); - + expect(checkContainsTrigger(context)).toBe(true); }); }); diff --git a/src/github/validation/trigger.ts b/src/github/validation/trigger.ts index 51e8d25..a39ce0d 100644 --- a/src/github/validation/trigger.ts +++ b/src/github/validation/trigger.ts @@ -18,8 +18,10 @@ export function checkContainsTrigger(context: ParsedGitHubContext): boolean { // Check for specific @droid commands (fill, review) const command = extractCommandFromContext(context); - if (command && ['fill', 'review'].includes(command.command)) { - console.log(`Detected @droid ${command.command} command, triggering action`); + if (command && ["fill", "review", "security"].includes(command.command)) { + console.log( + `Detected @droid ${command.command} command, triggering action`, + ); return true; } diff --git a/src/tag/commands/security-review.ts b/src/tag/commands/security-review.ts new file mode 100644 index 0000000..4033217 --- /dev/null +++ b/src/tag/commands/security-review.ts @@ -0,0 +1,128 @@ +import * as core from "@actions/core"; +import type { GitHubContext } from "../../github/context"; +import { fetchPRBranchData } from "../../github/data/pr-fetcher"; +import { createPrompt } from "../../create-prompt"; +import { prepareMcpTools } from "../../mcp/install-mcp-server"; +import { createInitialComment } from "../../github/operations/comments/create-initial"; +import { normalizeDroidArgs, parseAllowedTools } from "../../utils/parse-tools"; +import { isEntityContext } from "../../github/context"; +import { generateSecurityReviewPrompt } from "../../create-prompt/templates/security-review-prompt"; +import type { Octokits } from "../../github/api/client"; +import type { PrepareResult } from "../../prepare/types"; + +type SecurityReviewCommandOptions = { + context: GitHubContext; + octokit: Octokits; + githubToken: string; + trackingCommentId?: number; +}; + +export async function prepareSecurityReviewMode({ + context, + octokit, + githubToken, + trackingCommentId, +}: SecurityReviewCommandOptions): Promise { + if (!isEntityContext(context)) { + throw new Error("Security review command requires an entity event context"); + } + + if (!context.isPR) { + throw new Error( + "Security review command is only supported on pull requests", + ); + } + + const commentId = + trackingCommentId ?? (await createInitialComment(octokit.rest, context)).id; + + const prData = await fetchPRBranchData({ + octokits: octokit, + repository: context.repository, + prNumber: context.entityNumber, + }); + + const branchInfo = { + baseBranch: prData.baseRefName, + droidBranch: undefined, + currentBranch: prData.headRefName, + }; + + await createPrompt({ + githubContext: context, + commentId, + baseBranch: branchInfo.baseBranch, + droidBranch: branchInfo.droidBranch, + prBranchData: { + headRefName: prData.headRefName, + headRefOid: prData.headRefOid, + }, + generatePrompt: generateSecurityReviewPrompt, + }); + core.exportVariable("DROID_EXEC_RUN_TYPE", "droid-security-review"); + + // Signal that security skills should be installed + core.setOutput("install_security_skills", "true"); + + const rawUserArgs = process.env.DROID_ARGS || ""; + const normalizedUserArgs = normalizeDroidArgs(rawUserArgs); + const userAllowedMCPTools = parseAllowedTools(normalizedUserArgs).filter( + (tool) => tool.startsWith("github_") && tool.includes("___"), + ); + + const baseTools = [ + "Read", + "Grep", + "Glob", + "LS", + "Execute", + "github_comment___update_droid_comment", + "github_inline_comment___create_inline_comment", + ]; + + const reviewTools = [ + "github_pr___list_review_comments", + "github_pr___submit_review", + "github_pr___delete_comment", + "github_pr___minimize_comment", + "github_pr___reply_to_comment", + "github_pr___resolve_review_thread", + ]; + + const allowedTools = Array.from( + new Set([...baseTools, ...reviewTools, ...userAllowedMCPTools]), + ); + + const mcpTools = await prepareMcpTools({ + githubToken, + owner: context.repository.owner, + repo: context.repository.repo, + droidCommentId: commentId.toString(), + allowedTools, + mode: "tag", + context, + }); + + const droidArgParts: string[] = []; + droidArgParts.push(`--enabled-tools "${allowedTools.join(",")}"`); + + // Add model override if specified (prefer SECURITY_MODEL, fallback to REVIEW_MODEL) + const securityModel = + process.env.SECURITY_MODEL?.trim() || process.env.REVIEW_MODEL?.trim(); + if (securityModel) { + droidArgParts.push(`--model "${securityModel}"`); + } + + if (normalizedUserArgs) { + droidArgParts.push(normalizedUserArgs); + } + + core.setOutput("droid_args", droidArgParts.join(" ").trim()); + core.setOutput("mcp_tools", mcpTools); + + return { + commentId, + branchInfo, + mcpTools, + }; +} diff --git a/src/tag/commands/security-scan.ts b/src/tag/commands/security-scan.ts new file mode 100644 index 0000000..5f474fc --- /dev/null +++ b/src/tag/commands/security-scan.ts @@ -0,0 +1,107 @@ +import * as core from "@actions/core"; +import type { GitHubContext } from "../../github/context"; +import { fetchRepoDefaultBranch } from "../../github/data/pr-fetcher"; +import { createPrompt } from "../../create-prompt"; +import { prepareMcpTools } from "../../mcp/install-mcp-server"; +import { normalizeDroidArgs, parseAllowedTools } from "../../utils/parse-tools"; +import { isEntityContext } from "../../github/context"; +import { generateSecurityReportPrompt } from "../../create-prompt/templates/security-report-prompt"; +import type { Octokits } from "../../github/api/client"; +import type { PrepareResult } from "../../prepare/types"; + +export type ScanScope = { type: "full" } | { type: "scheduled"; days: number }; + +type SecurityScanCommandOptions = { + context: GitHubContext; + octokit: Octokits; + githubToken: string; + scanScope: ScanScope; +}; + +export async function prepareSecurityScanMode({ + context, + octokit, + githubToken, + scanScope, +}: SecurityScanCommandOptions): Promise { + if (!isEntityContext(context)) { + throw new Error("Security scan command requires an entity event context"); + } + + // Fetch the repository's default branch (could be main, master, develop, etc.) + const defaultBranch = await fetchRepoDefaultBranch({ + octokits: octokit, + repository: context.repository, + }); + + const date = new Date().toISOString().split("T")[0]; + const branchName = `droid/security-report-${date}`; + + const branchInfo = { + baseBranch: defaultBranch, + droidBranch: branchName, + currentBranch: branchName, + }; + + await createPrompt({ + githubContext: context, + baseBranch: branchInfo.baseBranch, + droidBranch: branchInfo.droidBranch, + generatePrompt: (ctx) => + generateSecurityReportPrompt(ctx, scanScope, branchName), + }); + core.exportVariable("DROID_EXEC_RUN_TYPE", "droid-security-scan"); + + // Signal that security skills should be installed + core.setOutput("install_security_skills", "true"); + + const rawUserArgs = process.env.DROID_ARGS || ""; + const normalizedUserArgs = normalizeDroidArgs(rawUserArgs); + const userAllowedMCPTools = parseAllowedTools(normalizedUserArgs).filter( + (tool) => tool.startsWith("github_") && tool.includes("___"), + ); + + const baseTools = [ + "Read", + "Grep", + "Glob", + "LS", + "Execute", + "github_comment___update_droid_comment", + ]; + + const allowedTools = Array.from( + new Set([...baseTools, ...userAllowedMCPTools]), + ); + + const mcpTools = await prepareMcpTools({ + githubToken, + owner: context.repository.owner, + repo: context.repository.repo, + allowedTools, + mode: "tag", + context, + }); + + const droidArgParts: string[] = []; + droidArgParts.push(`--enabled-tools "${allowedTools.join(",")}"`); + + // Add model override if specified (prefer SECURITY_MODEL, fallback to REVIEW_MODEL) + const securityModel = + process.env.SECURITY_MODEL?.trim() || process.env.REVIEW_MODEL?.trim(); + if (securityModel) { + droidArgParts.push(`--model "${securityModel}"`); + } + + if (normalizedUserArgs) { + droidArgParts.push(normalizedUserArgs); + } + + core.setOutput("droid_args", droidArgParts.join(" ").trim()); + core.setOutput("mcp_tools", mcpTools); + + return { + branchInfo, + mcpTools, + }; +} diff --git a/src/tag/index.ts b/src/tag/index.ts index 7f036c1..a231476 100644 --- a/src/tag/index.ts +++ b/src/tag/index.ts @@ -5,6 +5,8 @@ import { isEntityContext } from "../github/context"; import { extractCommandFromContext } from "../github/utils/command-parser"; import { prepareFillMode } from "./commands/fill"; import { prepareReviewMode } from "./commands/review"; +import { prepareSecurityReviewMode } from "./commands/security-review"; +import { prepareSecurityScanMode } from "./commands/security-scan"; import type { GitHubContext } from "../github/context"; import type { PrepareResult } from "../prepare/types"; import type { Octokits } from "../github/api/client"; @@ -13,7 +15,10 @@ export function shouldTriggerTag(context: GitHubContext): boolean { if (!isEntityContext(context)) { return false; } - if (context.inputs.automaticReview) { + if ( + context.inputs.automaticReview || + context.inputs.automaticSecurityReview + ) { return context.isPR; } return checkContainsTrigger(context); @@ -36,15 +41,31 @@ export async function prepareTagExecution({ await checkHumanActor(octokit.rest, context); - const commentData = await createInitialComment(octokit.rest, context); - const commentId = commentData.id; - if (context.inputs.automaticReview && !context.isPR) { throw new Error("automatic_review requires a pull request context"); } + if (context.inputs.automaticSecurityReview && !context.isPR) { + throw new Error( + "automatic_security_review requires a pull request context", + ); + } + const commandContext = extractCommandFromContext(context); + // Determine if this is a security-related command for the initial comment + const isSecurityCommand = + context.inputs.automaticSecurityReview || + commandContext?.command === "security" || + commandContext?.command === "security-full"; + + const commentData = await createInitialComment( + octokit.rest, + context, + isSecurityCommand ? "security" : "default", + ); + const commentId = commentData.id; + if (context.inputs.automaticReview) { return prepareReviewMode({ context, @@ -54,6 +75,15 @@ export async function prepareTagExecution({ }); } + if (context.inputs.automaticSecurityReview) { + return prepareSecurityReviewMode({ + context, + octokit, + githubToken, + trackingCommentId: commentId, + }); + } + if (commandContext?.command === "fill") { return prepareFillMode({ context, @@ -63,6 +93,24 @@ export async function prepareTagExecution({ }); } + if (commandContext?.command === "security") { + return prepareSecurityReviewMode({ + context, + octokit, + githubToken, + trackingCommentId: commentId, + }); + } + + if (commandContext?.command === "security-full") { + return prepareSecurityScanMode({ + context, + octokit, + githubToken, + scanScope: { type: "full" }, + }); + } + if ( commandContext?.command === "review" || !commandContext || diff --git a/test/create-prompt.test.ts b/test/create-prompt.test.ts index 4d9543b..f4176ff 100644 --- a/test/create-prompt.test.ts +++ b/test/create-prompt.test.ts @@ -26,6 +26,14 @@ describe("prepareContext", () => { allowedNonWriteUsers: "", trackProgress: false, automaticReview: false, + automaticSecurityReview: false, + securityModel: "", + securitySeverityThreshold: "medium", + securityBlockOnCritical: true, + securityBlockOnHigh: false, + securityNotifyTeam: "", + securityScanSchedule: false, + securityScanDays: 7, }, } as const; diff --git a/test/create-prompt/templates/fill-prompt.test.ts b/test/create-prompt/templates/fill-prompt.test.ts index 5d42bf4..baff84d 100644 --- a/test/create-prompt/templates/fill-prompt.test.ts +++ b/test/create-prompt/templates/fill-prompt.test.ts @@ -2,7 +2,9 @@ import { describe, expect, it } from "bun:test"; import { generateFillPrompt } from "../../../src/create-prompt/templates/fill-prompt"; import type { PreparedContext } from "../../../src/create-prompt/types"; -function createBaseContext(overrides: Partial = {}): PreparedContext { +function createBaseContext( + overrides: Partial = {}, +): PreparedContext { return { repository: "test-owner/test-repo", droidCommentId: "123", @@ -26,7 +28,9 @@ describe("generateFillPrompt", () => { const prompt = generateFillPrompt(context); expect(prompt).toContain("Procedure:"); - expect(prompt).toContain("gh pr view 42 --repo test-owner/test-repo --json title,body"); + expect(prompt).toContain( + "gh pr view 42 --repo test-owner/test-repo --json title,body", + ); expect(prompt).toContain("gh pr diff 42 --repo test-owner/test-repo"); expect(prompt).toContain("github_pr___update_pr_description"); expect(prompt).toContain("Do not proceed if required commands fail"); diff --git a/test/create-prompt/templates/security-report-prompt.test.ts b/test/create-prompt/templates/security-report-prompt.test.ts new file mode 100644 index 0000000..f2698d3 --- /dev/null +++ b/test/create-prompt/templates/security-report-prompt.test.ts @@ -0,0 +1,156 @@ +import { describe, expect, test } from "bun:test"; +import { generateSecurityReportPrompt } from "../../../src/create-prompt/templates/security-report-prompt"; +import type { PreparedContext } from "../../../src/create-prompt/types"; + +describe("generateSecurityReportPrompt", () => { + const baseContext: PreparedContext = { + repository: "test-owner/test-repo", + triggerPhrase: "@droid", + eventData: { + eventName: "issue_comment", + commentId: "123", + issueNumber: "1", + isPR: false, + baseBranch: "main", + droidBranch: "droid/security-report-2024-01-15", + commentBody: "@droid security --full", + }, + githubContext: { + runId: "1234567890", + eventName: "issue_comment", + eventAction: "created", + repository: { + owner: "test-owner", + repo: "test-repo", + full_name: "test-owner/test-repo", + }, + actor: "test-user", + payload: {} as any, + entityNumber: 1, + isPR: false, + inputs: { + triggerPhrase: "@droid", + assigneeTrigger: "", + labelTrigger: "", + useStickyComment: false, + allowedBots: "", + allowedNonWriteUsers: "", + trackProgress: false, + automaticReview: false, + automaticSecurityReview: false, + securityModel: "", + securitySeverityThreshold: "high", + securityBlockOnCritical: true, + securityBlockOnHigh: false, + securityNotifyTeam: "@org/security-team", + securityScanSchedule: false, + securityScanDays: 7, + }, + }, + }; + + test("includes scan configuration for full repository scan", () => { + const prompt = generateSecurityReportPrompt( + baseContext, + { type: "full" }, + "droid/security-report-2024-01-15", + ); + + expect(prompt).toContain("full repository"); + expect(prompt).toContain("Entire repository"); + expect(prompt).toContain("droid/security-report-2024-01-15"); + expect(prompt).toContain(".factory/security/reports/security-report-"); + }); + + test("includes scan configuration for scheduled scan", () => { + const prompt = generateSecurityReportPrompt( + baseContext, + { type: "scheduled", days: 7 }, + "droid/security-report-2024-01-15", + ); + + expect(prompt).toContain("weekly scheduled"); + expect(prompt).toContain("Last 7 days of commits"); + expect(prompt).toContain("git log --since="); + }); + + test("includes security skills workflow", () => { + const prompt = generateSecurityReportPrompt( + baseContext, + { type: "full" }, + "droid/security-report-2024-01-15", + ); + + expect(prompt).toContain("threat-model-generation"); + expect(prompt).toContain("commit-security-scan"); + expect(prompt).toContain("vulnerability-validation"); + expect(prompt).toContain("security-review"); + }); + + test("includes PR creation instructions", () => { + const prompt = generateSecurityReportPrompt( + baseContext, + { type: "full" }, + "droid/security-report-2024-01-15", + ); + + expect(prompt).toContain("git checkout -b"); + expect(prompt).toContain("git push origin"); + expect(prompt).toContain("gh pr create"); + expect(prompt).toContain("fix(security):"); + }); + + test("includes report format template", () => { + const prompt = generateSecurityReportPrompt( + baseContext, + { type: "full" }, + "droid/security-report-2024-01-15", + ); + + expect(prompt).toContain("# Security Scan Report"); + expect(prompt).toContain("Executive Summary"); + expect(prompt).toContain( + "| Severity | Count | Auto-fixed | Manual Required |", + ); + expect(prompt).toContain("VULN-001"); + expect(prompt).toContain("STRIDE"); + expect(prompt).toContain("CWE"); + }); + + test("includes severity definitions", () => { + const prompt = generateSecurityReportPrompt( + baseContext, + { type: "full" }, + "droid/security-report-2024-01-15", + ); + + expect(prompt).toContain("CRITICAL"); + expect(prompt).toContain("HIGH"); + expect(prompt).toContain("MEDIUM"); + expect(prompt).toContain("LOW"); + expect(prompt).toContain("Immediately exploitable"); + }); + + test("includes security configuration from context", () => { + const prompt = generateSecurityReportPrompt( + baseContext, + { type: "full" }, + "droid/security-report-2024-01-15", + ); + + expect(prompt).toContain("Severity Threshold: high"); + expect(prompt).toContain("@org/security-team"); + }); + + test("includes threat model check instructions", () => { + const prompt = generateSecurityReportPrompt( + baseContext, + { type: "full" }, + "droid/security-report-2024-01-15", + ); + + expect(prompt).toContain(".factory/threat-model.md"); + expect(prompt).toContain("Threat Model Check"); + expect(prompt).toContain("90 days old"); + }); +}); diff --git a/test/create-prompt/templates/security-review-prompt.test.ts b/test/create-prompt/templates/security-review-prompt.test.ts new file mode 100644 index 0000000..5014fcd --- /dev/null +++ b/test/create-prompt/templates/security-review-prompt.test.ts @@ -0,0 +1,82 @@ +import { describe, expect, it } from "bun:test"; +import { generateSecurityReviewPrompt } from "../../../src/create-prompt/templates/security-review-prompt"; +import type { PreparedContext } from "../../../src/create-prompt/types"; + +function createBaseContext( + overrides: Partial = {}, +): PreparedContext { + return { + repository: "test-owner/test-repo", + droidCommentId: "123", + triggerPhrase: "@droid", + eventData: { + eventName: "issue_comment", + commentId: "456", + prNumber: "42", + isPR: true, + commentBody: "@droid security-review", + }, + githubContext: undefined, + ...overrides, + } as PreparedContext; +} + +describe("generateSecurityReviewPrompt", () => { + it("includes security context and skill workflow", () => { + const prompt = generateSecurityReviewPrompt(createBaseContext()); + + expect(prompt).toContain("security-focused code review"); + expect(prompt).toContain("## Security Skills Available"); + expect(prompt).toContain("threat-model-generation"); + expect(prompt).toContain("commit-security-scan"); + expect(prompt).toContain("vulnerability-validation"); + expect(prompt).toContain("security-review"); + expect(prompt).toContain("## Review Workflow"); + expect(prompt).toContain("gh pr diff 42 --repo test-owner/test-repo"); + expect(prompt).toContain( + "gh api repos/test-owner/test-repo/pulls/42/files", + ); + expect(prompt).toContain("security-review-results.json"); + expect(prompt).toContain("Do NOT post inline comments"); + }); + + it("lists STRIDE security categories", () => { + const prompt = generateSecurityReviewPrompt(createBaseContext()); + + expect(prompt).toContain("Spoofing"); + expect(prompt).toContain("Tampering"); + expect(prompt).toContain("Repudiation"); + expect(prompt).toContain("Information Disclosure"); + expect(prompt).toContain("Denial of Service"); + expect(prompt).toContain("Elevation of Privilege"); + }); + + it("includes severity definitions", () => { + const prompt = generateSecurityReviewPrompt(createBaseContext()); + + expect(prompt).toContain("CRITICAL"); + expect(prompt).toContain("HIGH"); + expect(prompt).toContain("MEDIUM"); + expect(prompt).toContain("LOW"); + }); + + it("includes security configuration from context", () => { + const contextWithConfig = createBaseContext({ + githubContext: { + inputs: { + securitySeverityThreshold: "high", + securityBlockOnCritical: true, + securityBlockOnHigh: true, + securityNotifyTeam: "@org/security-team", + }, + } as any, + }); + + const prompt = generateSecurityReviewPrompt(contextWithConfig); + + expect(prompt).toContain("Severity Threshold: high"); + expect(prompt).toContain("Block on Critical: true"); + expect(prompt).toContain("Block on High: true"); + expect(prompt).toContain("@org/security-team"); + }); +}); diff --git a/test/mockContext.ts b/test/mockContext.ts index 657b118..4c197a7 100644 --- a/test/mockContext.ts +++ b/test/mockContext.ts @@ -19,6 +19,14 @@ const defaultInputs = { allowedNonWriteUsers: "", trackProgress: false, automaticReview: false, + automaticSecurityReview: false, + securityModel: "", + securitySeverityThreshold: "medium", + securityBlockOnCritical: true, + securityBlockOnHigh: false, + securityNotifyTeam: "", + securityScanSchedule: false, + securityScanDays: 7, }; const defaultRepository = { diff --git a/test/modes/tag.test.ts b/test/modes/tag.test.ts index 1cd012f..6a4ff72 100644 --- a/test/modes/tag.test.ts +++ b/test/modes/tag.test.ts @@ -52,4 +52,17 @@ describe("shouldTriggerTag", () => { expect(shouldTriggerTag(contextWithAutomaticReview)).toBe(true); }); + + test("returns true for PR contexts when automaticSecurityReview is enabled", () => { + const contextWithAutomaticSecurityReview = createMockContext({ + eventName: "issue_comment", + isPR: true, + inputs: { + ...createMockContext().inputs, + automaticSecurityReview: true, + }, + }); + + expect(shouldTriggerTag(contextWithAutomaticSecurityReview)).toBe(true); + }); }); diff --git a/test/modes/tag/security-review-command.test.ts b/test/modes/tag/security-review-command.test.ts new file mode 100644 index 0000000..66a2b88 --- /dev/null +++ b/test/modes/tag/security-review-command.test.ts @@ -0,0 +1,335 @@ +import { afterEach, beforeEach, describe, expect, it, spyOn } from "bun:test"; +import * as core from "@actions/core"; +import { prepareSecurityReviewMode } from "../../../src/tag/commands/security-review"; +import { createMockContext } from "../../mockContext"; + +import * as prFetcher from "../../../src/github/data/pr-fetcher"; +import * as promptModule from "../../../src/create-prompt"; +import * as mcpInstaller from "../../../src/mcp/install-mcp-server"; +import * as comments from "../../../src/github/operations/comments/create-initial"; + +const MOCK_PR_DATA = { + baseRefName: "main", + headRefName: "feature/security-review", + headRefOid: "123abc", +} as const; + +describe("prepareSecurityReviewMode", () => { + const originalArgs = process.env.DROID_ARGS; + const originalReviewModel = process.env.REVIEW_MODEL; + const originalSecurityModel = process.env.SECURITY_MODEL; + let fetchPRSpy: ReturnType; + let promptSpy: ReturnType; + let mcpSpy: ReturnType; + let setOutputSpy: ReturnType; + let createInitialSpy: ReturnType; + let exportVariableSpy: ReturnType; + + beforeEach(() => { + process.env.DROID_ARGS = ""; + delete process.env.REVIEW_MODEL; + delete process.env.SECURITY_MODEL; + + fetchPRSpy = spyOn(prFetcher, "fetchPRBranchData").mockResolvedValue({ + baseRefName: MOCK_PR_DATA.baseRefName, + headRefName: MOCK_PR_DATA.headRefName, + headRefOid: MOCK_PR_DATA.headRefOid, + }); + + promptSpy = spyOn(promptModule, "createPrompt").mockResolvedValue(); + mcpSpy = spyOn(mcpInstaller, "prepareMcpTools").mockResolvedValue( + "mock-config", + ); + setOutputSpy = spyOn(core, "setOutput").mockImplementation(() => {}); + createInitialSpy = spyOn( + comments, + "createInitialComment", + ).mockResolvedValue({ id: 777 } as any); + exportVariableSpy = spyOn(core, "exportVariable").mockImplementation( + () => {}, + ); + }); + + afterEach(() => { + fetchPRSpy.mockRestore(); + promptSpy.mockRestore(); + mcpSpy.mockRestore(); + setOutputSpy.mockRestore(); + createInitialSpy.mockRestore(); + exportVariableSpy.mockRestore(); + + process.env.DROID_ARGS = originalArgs; + if (originalReviewModel !== undefined) { + process.env.REVIEW_MODEL = originalReviewModel; + } else { + delete process.env.REVIEW_MODEL; + } + if (originalSecurityModel !== undefined) { + process.env.SECURITY_MODEL = originalSecurityModel; + } else { + delete process.env.SECURITY_MODEL; + } + }); + + it("prepares security review flow with limited toolset when tracking comment exists", async () => { + const context = createMockContext({ + eventName: "issue_comment", + isPR: true, + payload: { + comment: { + id: 101, + body: "@droid security-review", + }, + } as any, + entityNumber: 24, + }); + + const octokit = { rest: {}, graphql: () => {} } as any; + + const result = await prepareSecurityReviewMode({ + context, + octokit, + githubToken: "token", + trackingCommentId: 555, + }); + + expect(fetchPRSpy).toHaveBeenCalledWith({ + octokits: octokit, + repository: context.repository, + prNumber: 24, + }); + expect(promptSpy).toHaveBeenCalled(); + expect(mcpSpy).toHaveBeenCalledWith( + expect.objectContaining({ + allowedTools: expect.arrayContaining([ + "Execute", + "github_comment___update_droid_comment", + "github_inline_comment___create_inline_comment", + "github_pr___list_review_comments", + "github_pr___submit_review", + "github_pr___resolve_review_thread", + ]), + }), + ); + expect(createInitialSpy).not.toHaveBeenCalled(); + expect(result.commentId).toBe(555); + expect(result.branchInfo.baseBranch).toBe("main"); + expect(result.branchInfo.currentBranch).toBe("feature/security-review"); + expect(result.branchInfo.droidBranch).toBeUndefined(); + + const droidArgsCall = setOutputSpy.mock.calls.find( + (call: unknown[]) => call[0] === "droid_args", + ) as [string, string] | undefined; + expect(droidArgsCall?.[1]).toContain("Execute"); + [ + "github_comment___update_droid_comment", + "github_inline_comment___create_inline_comment", + "github_pr___list_review_comments", + "github_pr___submit_review", + "github_pr___delete_comment", + "github_pr___minimize_comment", + "github_pr___reply_to_comment", + "github_pr___resolve_review_thread", + ].forEach((tool) => { + expect(droidArgsCall?.[1]).toContain(tool); + }); + + expect(exportVariableSpy).toHaveBeenCalledWith( + "DROID_EXEC_RUN_TYPE", + "droid-security-review", + ); + }); + + it("creates tracking comment when not provided", async () => { + const context = createMockContext({ + eventName: "issue_comment", + isPR: true, + payload: { + comment: { + id: 102, + body: "@droid security-review", + }, + } as any, + entityNumber: 25, + }); + + const octokit = { rest: {}, graphql: () => {} } as any; + + const result = await prepareSecurityReviewMode({ + context, + octokit, + githubToken: "token", + }); + + expect(createInitialSpy).toHaveBeenCalled(); + expect(result.commentId).toBe(777); + }); + + it("throws when invoked on non-PR context", async () => { + const context = createMockContext({ isPR: false }); + + await expect( + prepareSecurityReviewMode({ + context, + octokit: { rest: {}, graphql: () => {} } as any, + githubToken: "token", + }), + ).rejects.toThrow( + "Security review command is only supported on pull requests", + ); + }); + + it("adds --model flag when REVIEW_MODEL is set", async () => { + process.env.REVIEW_MODEL = "claude-sonnet-4-5-20250929"; + + const context = createMockContext({ + eventName: "issue_comment", + isPR: true, + payload: { + comment: { + id: 103, + body: "@droid security-review", + }, + } as any, + entityNumber: 26, + }); + + const octokit = { rest: {}, graphql: () => {} } as any; + + await prepareSecurityReviewMode({ + context, + octokit, + githubToken: "token", + trackingCommentId: 556, + }); + + const droidArgsCall = setOutputSpy.mock.calls.find( + (call: unknown[]) => call[0] === "droid_args", + ) as [string, string] | undefined; + expect(droidArgsCall?.[1]).toContain( + '--model "claude-sonnet-4-5-20250929"', + ); + }); + + it("does not add --model flag when REVIEW_MODEL is empty", async () => { + process.env.REVIEW_MODEL = ""; + + const context = createMockContext({ + eventName: "issue_comment", + isPR: true, + payload: { + comment: { + id: 104, + body: "@droid security-review", + }, + } as any, + entityNumber: 27, + }); + + const octokit = { rest: {}, graphql: () => {} } as any; + + await prepareSecurityReviewMode({ + context, + octokit, + githubToken: "token", + trackingCommentId: 557, + }); + + const droidArgsCall = setOutputSpy.mock.calls.find( + (call: unknown[]) => call[0] === "droid_args", + ) as [string, string] | undefined; + expect(droidArgsCall?.[1]).not.toContain("--model"); + }); + + it("outputs install_security_skills flag", async () => { + const context = createMockContext({ + eventName: "issue_comment", + isPR: true, + payload: { + comment: { + id: 105, + body: "@droid security-review", + }, + } as any, + entityNumber: 28, + }); + + const octokit = { rest: {}, graphql: () => {} } as any; + + await prepareSecurityReviewMode({ + context, + octokit, + githubToken: "token", + trackingCommentId: 558, + }); + + const installSkillsCall = setOutputSpy.mock.calls.find( + (call: unknown[]) => call[0] === "install_security_skills", + ) as [string, string] | undefined; + expect(installSkillsCall?.[1]).toBe("true"); + }); + + it("prefers SECURITY_MODEL over REVIEW_MODEL", async () => { + process.env.SECURITY_MODEL = "gpt-5.1-codex"; + process.env.REVIEW_MODEL = "claude-sonnet-4-5-20250929"; + + const context = createMockContext({ + eventName: "issue_comment", + isPR: true, + payload: { + comment: { + id: 106, + body: "@droid security-review", + }, + } as any, + entityNumber: 29, + }); + + const octokit = { rest: {}, graphql: () => {} } as any; + + await prepareSecurityReviewMode({ + context, + octokit, + githubToken: "token", + trackingCommentId: 559, + }); + + const droidArgsCall = setOutputSpy.mock.calls.find( + (call: unknown[]) => call[0] === "droid_args", + ) as [string, string] | undefined; + expect(droidArgsCall?.[1]).toContain('--model "gpt-5.1-codex"'); + expect(droidArgsCall?.[1]).not.toContain("claude-sonnet"); + }); + + it("falls back to REVIEW_MODEL when SECURITY_MODEL is not set", async () => { + process.env.REVIEW_MODEL = "claude-sonnet-4-5-20250929"; + + const context = createMockContext({ + eventName: "issue_comment", + isPR: true, + payload: { + comment: { + id: 107, + body: "@droid security-review", + }, + } as any, + entityNumber: 30, + }); + + const octokit = { rest: {}, graphql: () => {} } as any; + + await prepareSecurityReviewMode({ + context, + octokit, + githubToken: "token", + trackingCommentId: 560, + }); + + const droidArgsCall = setOutputSpy.mock.calls.find( + (call: unknown[]) => call[0] === "droid_args", + ) as [string, string] | undefined; + expect(droidArgsCall?.[1]).toContain( + '--model "claude-sonnet-4-5-20250929"', + ); + }); +}); diff --git a/test/permissions.test.ts b/test/permissions.test.ts index c7c0b3c..4db439f 100644 --- a/test/permissions.test.ts +++ b/test/permissions.test.ts @@ -68,6 +68,14 @@ describe("checkWritePermissions", () => { allowedNonWriteUsers: "", trackProgress: false, automaticReview: false, + automaticSecurityReview: false, + securityModel: "", + securitySeverityThreshold: "medium", + securityBlockOnCritical: true, + securityBlockOnHigh: false, + securityNotifyTeam: "", + securityScanSchedule: false, + securityScanDays: 7, }, });