diff --git a/.clinerules b/.clinerules index 74b1765..dd2786e 100644 --- a/.clinerules +++ b/.clinerules @@ -37,23 +37,23 @@ Every time you write or modify code that touches security-relevant behavior, you ### Current Definitions (REUSE these IDs — do NOT redefine) -**Assets:** #parser (GuardLink,Parser), #cli (GuardLink,CLI), #tui (GuardLink,TUI), #mcp (GuardLink,MCP), #llm-client (GuardLink,LLM_Client), #dashboard (GuardLink,Dashboard), #init (GuardLink,Init), #agent-launcher (GuardLink,Agent_Launcher), #diff (GuardLink,Diff), #report (GuardLink,Report), #sarif (GuardLink,SARIF), #suggest (GuardLink,Suggest) -**Threats:** #path-traversal (Path_Traversal) [high], #cmd-injection (Command_Injection) [critical], #xss (Cross_Site_Scripting) [high], #api-key-exposure (API_Key_Exposure) [high], #ssrf (Server_Side_Request_Forgery) [medium], #redos (ReDoS) [medium], #arbitrary-write (Arbitrary_File_Write) [high], #prompt-injection (Prompt_Injection) [medium], #dos (Denial_of_Service) [medium], #data-exposure (Sensitive_Data_Exposure) [medium], #insecure-deser (Insecure_Deserialization) [medium], #child-proc-injection (Child_Process_Injection) [high], #info-disclosure (Information_Disclosure) [low] +**Assets:** #parser (GuardLink,Parser), #cli (GuardLink,CLI), #tui (GuardLink,TUI), #mcp (GuardLink,MCP), #llm-client (GuardLink,LLM_Client), #dashboard (GuardLink,Dashboard), #init (GuardLink,Init), #agent-launcher (GuardLink,Agent_Launcher), #diff (GuardLink,Diff), #report (GuardLink,Report), #sarif (GuardLink,SARIF), #suggest (GuardLink,Suggest), #workspace-link (Workspace,Link), #merge-engine (Workspace,Merge), #report-metadata (Workspace,Metadata), #workspace-config (Workspace,Config) +**Threats:** #path-traversal (Path_Traversal) [high], #cmd-injection (Command_Injection) [critical], #xss (Cross_Site_Scripting) [high], #api-key-exposure (API_Key_Exposure) [high], #ssrf (Server_Side_Request_Forgery) [medium], #redos (ReDoS) [medium], #arbitrary-write (Arbitrary_File_Write) [high], #prompt-injection (Prompt_Injection) [medium], #dos (Denial_of_Service) [medium], #data-exposure (Sensitive_Data_Exposure) [medium], #insecure-deser (Insecure_Deserialization) [medium], #child-proc-injection (Child_Process_Injection) [high], #info-disclosure (Information_Disclosure) [low], #tag-collision (Tag_Collision) [medium], #config-tamper (Config_Tampering) [medium] **Controls:** #path-validation (Path_Validation), #input-sanitize (Input_Sanitization), #output-encoding (Output_Encoding), #key-redaction (Key_Redaction), #process-sandbox (Process_Sandboxing), #config-validation (Config_Validation), #resource-limits (Resource_Limits), #param-commands (Parameterized_Commands), #glob-filtering (Glob_Pattern_Filtering), #regex-anchoring (Regex_Anchoring) ### Open Exposures (need @mitigates or @audit) +- #sarif exposed to #data-exposure [low] (src/analyzer/sarif.ts:15) +- #llm-client exposed to #data-exposure [low] (src/analyze/index.ts:12) +- #llm-client exposed to #prompt-injection [medium] (src/analyze/llm.ts:17) - #agent-launcher exposed to #prompt-injection [medium] (src/agents/launcher.ts:13) - #agent-launcher exposed to #dos [low] (src/agents/launcher.ts:15) - #agent-launcher exposed to #prompt-injection [high] (src/agents/prompts.ts:6) -- #llm-client exposed to #data-exposure [low] (src/analyze/index.ts:12) -- #llm-client exposed to #prompt-injection [medium] (src/analyze/llm.ts:17) -- #sarif exposed to #data-exposure [low] (src/analyzer/sarif.ts:15) -- #cli exposed to #cmd-injection [critical] (src/cli/index.ts:29) +- #cli exposed to #cmd-injection [critical] (src/cli/index.ts:31) - #init exposed to #data-exposure [low] (src/init/index.ts:12) - #mcp exposed to #cmd-injection [high] (src/mcp/index.ts:4) -- #mcp exposed to #prompt-injection [medium] (src/mcp/server.ts:29) -- #mcp exposed to #data-exposure [medium] (src/mcp/server.ts:33) +- #mcp exposed to #prompt-injection [medium] (src/mcp/server.ts:30) +- #mcp exposed to #data-exposure [medium] (src/mcp/server.ts:34) - #suggest exposed to #dos [low] (src/mcp/suggest.ts:16) - #parser exposed to #arbitrary-write [high] (src/parser/clear.ts:7) - #tui exposed to #cmd-injection [high] (src/tui/commands.ts:11) @@ -61,15 +61,8 @@ Every time you write or modify code that touches security-relevant behavior, you ### Existing Data Flows (extend, don't duplicate) -- EnvVars -> #agent-launcher via process.env -- ConfigFile -> #agent-launcher via readFileSync -- #agent-launcher -> ConfigFile via writeFileSync -- UserPrompt -> #agent-launcher via launchAgent -- #agent-launcher -> AgentProcess via spawn -- AgentProcess -> #agent-launcher via stdout -- UserPrompt -> #agent-launcher via buildAnnotatePrompt -- ThreatModel -> #agent-launcher via model -- #agent-launcher -> AgentPrompt via return +- ThreatModel -> #sarif via generateSarif +- #sarif -> SarifLog via return - ThreatModel -> #llm-client via serializeModel - ProjectFiles -> #llm-client via readFileSync - #llm-client -> ReportFile via writeFileSync @@ -79,13 +72,20 @@ Every time you write or modify code that touches security-relevant behavior, you - LLMToolCall -> #llm-client via createToolExecutor - #llm-client -> NVD via fetch - ProjectFiles -> #llm-client via readFileSync -- ThreatModel -> #sarif via generateSarif -- #sarif -> SarifLog via return -- ... and 42 more +- EnvVars -> #agent-launcher via process.env +- ConfigFile -> #agent-launcher via readFileSync +- #agent-launcher -> ConfigFile via writeFileSync +- UserPrompt -> #agent-launcher via launchAgent +- #agent-launcher -> AgentProcess via spawn +- AgentProcess -> #agent-launcher via stdout +- UserPrompt -> #agent-launcher via buildAnnotatePrompt +- ThreatModel -> #agent-launcher via model +- #agent-launcher -> AgentPrompt via return +- ... and 48 more ### Model Stats -272 annotations, 12 assets, 13 threats, 10 controls, 60 exposures, 42 mitigations, 62 flows +287 annotations, 16 assets, 15 threats, 10 controls, 60 exposures, 44 mitigations, 68 flows @@ -99,3 +99,7 @@ Every time you write or modify code that touches security-relevant behavior, you + + + + diff --git a/.cursor/rules/guardlink.mdc b/.cursor/rules/guardlink.mdc index d26d558..0618bb5 100644 --- a/.cursor/rules/guardlink.mdc +++ b/.cursor/rules/guardlink.mdc @@ -42,23 +42,23 @@ Every time you write or modify code that touches security-relevant behavior, you ### Current Definitions (REUSE these IDs — do NOT redefine) -**Assets:** #parser (GuardLink,Parser), #cli (GuardLink,CLI), #tui (GuardLink,TUI), #mcp (GuardLink,MCP), #llm-client (GuardLink,LLM_Client), #dashboard (GuardLink,Dashboard), #init (GuardLink,Init), #agent-launcher (GuardLink,Agent_Launcher), #diff (GuardLink,Diff), #report (GuardLink,Report), #sarif (GuardLink,SARIF), #suggest (GuardLink,Suggest) -**Threats:** #path-traversal (Path_Traversal) [high], #cmd-injection (Command_Injection) [critical], #xss (Cross_Site_Scripting) [high], #api-key-exposure (API_Key_Exposure) [high], #ssrf (Server_Side_Request_Forgery) [medium], #redos (ReDoS) [medium], #arbitrary-write (Arbitrary_File_Write) [high], #prompt-injection (Prompt_Injection) [medium], #dos (Denial_of_Service) [medium], #data-exposure (Sensitive_Data_Exposure) [medium], #insecure-deser (Insecure_Deserialization) [medium], #child-proc-injection (Child_Process_Injection) [high], #info-disclosure (Information_Disclosure) [low] +**Assets:** #parser (GuardLink,Parser), #cli (GuardLink,CLI), #tui (GuardLink,TUI), #mcp (GuardLink,MCP), #llm-client (GuardLink,LLM_Client), #dashboard (GuardLink,Dashboard), #init (GuardLink,Init), #agent-launcher (GuardLink,Agent_Launcher), #diff (GuardLink,Diff), #report (GuardLink,Report), #sarif (GuardLink,SARIF), #suggest (GuardLink,Suggest), #workspace-link (Workspace,Link), #merge-engine (Workspace,Merge), #report-metadata (Workspace,Metadata), #workspace-config (Workspace,Config) +**Threats:** #path-traversal (Path_Traversal) [high], #cmd-injection (Command_Injection) [critical], #xss (Cross_Site_Scripting) [high], #api-key-exposure (API_Key_Exposure) [high], #ssrf (Server_Side_Request_Forgery) [medium], #redos (ReDoS) [medium], #arbitrary-write (Arbitrary_File_Write) [high], #prompt-injection (Prompt_Injection) [medium], #dos (Denial_of_Service) [medium], #data-exposure (Sensitive_Data_Exposure) [medium], #insecure-deser (Insecure_Deserialization) [medium], #child-proc-injection (Child_Process_Injection) [high], #info-disclosure (Information_Disclosure) [low], #tag-collision (Tag_Collision) [medium], #config-tamper (Config_Tampering) [medium] **Controls:** #path-validation (Path_Validation), #input-sanitize (Input_Sanitization), #output-encoding (Output_Encoding), #key-redaction (Key_Redaction), #process-sandbox (Process_Sandboxing), #config-validation (Config_Validation), #resource-limits (Resource_Limits), #param-commands (Parameterized_Commands), #glob-filtering (Glob_Pattern_Filtering), #regex-anchoring (Regex_Anchoring) ### Open Exposures (need @mitigates or @audit) +- #sarif exposed to #data-exposure [low] (src/analyzer/sarif.ts:15) +- #llm-client exposed to #data-exposure [low] (src/analyze/index.ts:12) +- #llm-client exposed to #prompt-injection [medium] (src/analyze/llm.ts:17) - #agent-launcher exposed to #prompt-injection [medium] (src/agents/launcher.ts:13) - #agent-launcher exposed to #dos [low] (src/agents/launcher.ts:15) - #agent-launcher exposed to #prompt-injection [high] (src/agents/prompts.ts:6) -- #llm-client exposed to #data-exposure [low] (src/analyze/index.ts:12) -- #llm-client exposed to #prompt-injection [medium] (src/analyze/llm.ts:17) -- #sarif exposed to #data-exposure [low] (src/analyzer/sarif.ts:15) -- #cli exposed to #cmd-injection [critical] (src/cli/index.ts:29) +- #cli exposed to #cmd-injection [critical] (src/cli/index.ts:31) - #init exposed to #data-exposure [low] (src/init/index.ts:12) - #mcp exposed to #cmd-injection [high] (src/mcp/index.ts:4) -- #mcp exposed to #prompt-injection [medium] (src/mcp/server.ts:29) -- #mcp exposed to #data-exposure [medium] (src/mcp/server.ts:33) +- #mcp exposed to #prompt-injection [medium] (src/mcp/server.ts:30) +- #mcp exposed to #data-exposure [medium] (src/mcp/server.ts:34) - #suggest exposed to #dos [low] (src/mcp/suggest.ts:16) - #parser exposed to #arbitrary-write [high] (src/parser/clear.ts:7) - #tui exposed to #cmd-injection [high] (src/tui/commands.ts:11) @@ -66,15 +66,8 @@ Every time you write or modify code that touches security-relevant behavior, you ### Existing Data Flows (extend, don't duplicate) -- EnvVars -> #agent-launcher via process.env -- ConfigFile -> #agent-launcher via readFileSync -- #agent-launcher -> ConfigFile via writeFileSync -- UserPrompt -> #agent-launcher via launchAgent -- #agent-launcher -> AgentProcess via spawn -- AgentProcess -> #agent-launcher via stdout -- UserPrompt -> #agent-launcher via buildAnnotatePrompt -- ThreatModel -> #agent-launcher via model -- #agent-launcher -> AgentPrompt via return +- ThreatModel -> #sarif via generateSarif +- #sarif -> SarifLog via return - ThreatModel -> #llm-client via serializeModel - ProjectFiles -> #llm-client via readFileSync - #llm-client -> ReportFile via writeFileSync @@ -84,10 +77,17 @@ Every time you write or modify code that touches security-relevant behavior, you - LLMToolCall -> #llm-client via createToolExecutor - #llm-client -> NVD via fetch - ProjectFiles -> #llm-client via readFileSync -- ThreatModel -> #sarif via generateSarif -- #sarif -> SarifLog via return -- ... and 42 more +- EnvVars -> #agent-launcher via process.env +- ConfigFile -> #agent-launcher via readFileSync +- #agent-launcher -> ConfigFile via writeFileSync +- UserPrompt -> #agent-launcher via launchAgent +- #agent-launcher -> AgentProcess via spawn +- AgentProcess -> #agent-launcher via stdout +- UserPrompt -> #agent-launcher via buildAnnotatePrompt +- ThreatModel -> #agent-launcher via model +- #agent-launcher -> AgentPrompt via return +- ... and 48 more ### Model Stats -272 annotations, 12 assets, 13 threats, 10 controls, 60 exposures, 42 mitigations, 62 flows +287 annotations, 16 assets, 15 threats, 10 controls, 60 exposures, 44 mitigations, 68 flows diff --git a/.gemini/GEMINI.md b/.gemini/GEMINI.md index 0d96456..68da32c 100644 --- a/.gemini/GEMINI.md +++ b/.gemini/GEMINI.md @@ -51,23 +51,23 @@ This project uses [GuardLink](https://guardlink.bugb.io) annotations in source c ### Current Definitions (REUSE these IDs — do NOT redefine) -**Assets:** #parser (GuardLink,Parser), #cli (GuardLink,CLI), #tui (GuardLink,TUI), #mcp (GuardLink,MCP), #llm-client (GuardLink,LLM_Client), #dashboard (GuardLink,Dashboard), #init (GuardLink,Init), #agent-launcher (GuardLink,Agent_Launcher), #diff (GuardLink,Diff), #report (GuardLink,Report), #sarif (GuardLink,SARIF), #suggest (GuardLink,Suggest) -**Threats:** #path-traversal (Path_Traversal) [high], #cmd-injection (Command_Injection) [critical], #xss (Cross_Site_Scripting) [high], #api-key-exposure (API_Key_Exposure) [high], #ssrf (Server_Side_Request_Forgery) [medium], #redos (ReDoS) [medium], #arbitrary-write (Arbitrary_File_Write) [high], #prompt-injection (Prompt_Injection) [medium], #dos (Denial_of_Service) [medium], #data-exposure (Sensitive_Data_Exposure) [medium], #insecure-deser (Insecure_Deserialization) [medium], #child-proc-injection (Child_Process_Injection) [high], #info-disclosure (Information_Disclosure) [low] +**Assets:** #parser (GuardLink,Parser), #cli (GuardLink,CLI), #tui (GuardLink,TUI), #mcp (GuardLink,MCP), #llm-client (GuardLink,LLM_Client), #dashboard (GuardLink,Dashboard), #init (GuardLink,Init), #agent-launcher (GuardLink,Agent_Launcher), #diff (GuardLink,Diff), #report (GuardLink,Report), #sarif (GuardLink,SARIF), #suggest (GuardLink,Suggest), #workspace-link (Workspace,Link), #merge-engine (Workspace,Merge), #report-metadata (Workspace,Metadata), #workspace-config (Workspace,Config) +**Threats:** #path-traversal (Path_Traversal) [high], #cmd-injection (Command_Injection) [critical], #xss (Cross_Site_Scripting) [high], #api-key-exposure (API_Key_Exposure) [high], #ssrf (Server_Side_Request_Forgery) [medium], #redos (ReDoS) [medium], #arbitrary-write (Arbitrary_File_Write) [high], #prompt-injection (Prompt_Injection) [medium], #dos (Denial_of_Service) [medium], #data-exposure (Sensitive_Data_Exposure) [medium], #insecure-deser (Insecure_Deserialization) [medium], #child-proc-injection (Child_Process_Injection) [high], #info-disclosure (Information_Disclosure) [low], #tag-collision (Tag_Collision) [medium], #config-tamper (Config_Tampering) [medium] **Controls:** #path-validation (Path_Validation), #input-sanitize (Input_Sanitization), #output-encoding (Output_Encoding), #key-redaction (Key_Redaction), #process-sandbox (Process_Sandboxing), #config-validation (Config_Validation), #resource-limits (Resource_Limits), #param-commands (Parameterized_Commands), #glob-filtering (Glob_Pattern_Filtering), #regex-anchoring (Regex_Anchoring) ### Open Exposures (need @mitigates or @audit) +- #sarif exposed to #data-exposure [low] (src/analyzer/sarif.ts:15) +- #llm-client exposed to #data-exposure [low] (src/analyze/index.ts:12) +- #llm-client exposed to #prompt-injection [medium] (src/analyze/llm.ts:17) - #agent-launcher exposed to #prompt-injection [medium] (src/agents/launcher.ts:13) - #agent-launcher exposed to #dos [low] (src/agents/launcher.ts:15) - #agent-launcher exposed to #prompt-injection [high] (src/agents/prompts.ts:6) -- #llm-client exposed to #data-exposure [low] (src/analyze/index.ts:12) -- #llm-client exposed to #prompt-injection [medium] (src/analyze/llm.ts:17) -- #sarif exposed to #data-exposure [low] (src/analyzer/sarif.ts:15) -- #cli exposed to #cmd-injection [critical] (src/cli/index.ts:29) +- #cli exposed to #cmd-injection [critical] (src/cli/index.ts:31) - #init exposed to #data-exposure [low] (src/init/index.ts:12) - #mcp exposed to #cmd-injection [high] (src/mcp/index.ts:4) -- #mcp exposed to #prompt-injection [medium] (src/mcp/server.ts:29) -- #mcp exposed to #data-exposure [medium] (src/mcp/server.ts:33) +- #mcp exposed to #prompt-injection [medium] (src/mcp/server.ts:30) +- #mcp exposed to #data-exposure [medium] (src/mcp/server.ts:34) - #suggest exposed to #dos [low] (src/mcp/suggest.ts:16) - #parser exposed to #arbitrary-write [high] (src/parser/clear.ts:7) - #tui exposed to #cmd-injection [high] (src/tui/commands.ts:11) @@ -75,15 +75,8 @@ This project uses [GuardLink](https://guardlink.bugb.io) annotations in source c ### Existing Data Flows (extend, don't duplicate) -- EnvVars -> #agent-launcher via process.env -- ConfigFile -> #agent-launcher via readFileSync -- #agent-launcher -> ConfigFile via writeFileSync -- UserPrompt -> #agent-launcher via launchAgent -- #agent-launcher -> AgentProcess via spawn -- AgentProcess -> #agent-launcher via stdout -- UserPrompt -> #agent-launcher via buildAnnotatePrompt -- ThreatModel -> #agent-launcher via model -- #agent-launcher -> AgentPrompt via return +- ThreatModel -> #sarif via generateSarif +- #sarif -> SarifLog via return - ThreatModel -> #llm-client via serializeModel - ProjectFiles -> #llm-client via readFileSync - #llm-client -> ReportFile via writeFileSync @@ -93,13 +86,20 @@ This project uses [GuardLink](https://guardlink.bugb.io) annotations in source c - LLMToolCall -> #llm-client via createToolExecutor - #llm-client -> NVD via fetch - ProjectFiles -> #llm-client via readFileSync -- ThreatModel -> #sarif via generateSarif -- #sarif -> SarifLog via return -- ... and 42 more +- EnvVars -> #agent-launcher via process.env +- ConfigFile -> #agent-launcher via readFileSync +- #agent-launcher -> ConfigFile via writeFileSync +- UserPrompt -> #agent-launcher via launchAgent +- #agent-launcher -> AgentProcess via spawn +- AgentProcess -> #agent-launcher via stdout +- UserPrompt -> #agent-launcher via buildAnnotatePrompt +- ThreatModel -> #agent-launcher via model +- #agent-launcher -> AgentPrompt via return +- ... and 48 more ### Model Stats -272 annotations, 12 assets, 13 threats, 10 controls, 60 exposures, 42 mitigations, 62 flows +287 annotations, 16 assets, 15 threats, 10 controls, 60 exposures, 44 mitigations, 68 flows > **Note:** This section is auto-generated. Run `guardlink sync` to update after code changes. > Any coding agent (Cursor, Claude, Copilot, Windsurf, etc.) should reference these IDs @@ -117,3 +117,7 @@ This project uses [GuardLink](https://guardlink.bugb.io) annotations in source c + + + + diff --git a/.github/copilot-instructions.md b/.github/copilot-instructions.md index 039528f..2ae6015 100644 --- a/.github/copilot-instructions.md +++ b/.github/copilot-instructions.md @@ -51,23 +51,23 @@ This project uses [GuardLink](https://guardlink.bugb.io) annotations in source c ### Current Definitions (REUSE these IDs — do NOT redefine) -**Assets:** #parser (GuardLink,Parser), #cli (GuardLink,CLI), #tui (GuardLink,TUI), #mcp (GuardLink,MCP), #llm-client (GuardLink,LLM_Client), #dashboard (GuardLink,Dashboard), #init (GuardLink,Init), #agent-launcher (GuardLink,Agent_Launcher), #diff (GuardLink,Diff), #report (GuardLink,Report), #sarif (GuardLink,SARIF), #suggest (GuardLink,Suggest) -**Threats:** #path-traversal (Path_Traversal) [high], #cmd-injection (Command_Injection) [critical], #xss (Cross_Site_Scripting) [high], #api-key-exposure (API_Key_Exposure) [high], #ssrf (Server_Side_Request_Forgery) [medium], #redos (ReDoS) [medium], #arbitrary-write (Arbitrary_File_Write) [high], #prompt-injection (Prompt_Injection) [medium], #dos (Denial_of_Service) [medium], #data-exposure (Sensitive_Data_Exposure) [medium], #insecure-deser (Insecure_Deserialization) [medium], #child-proc-injection (Child_Process_Injection) [high], #info-disclosure (Information_Disclosure) [low] +**Assets:** #parser (GuardLink,Parser), #cli (GuardLink,CLI), #tui (GuardLink,TUI), #mcp (GuardLink,MCP), #llm-client (GuardLink,LLM_Client), #dashboard (GuardLink,Dashboard), #init (GuardLink,Init), #agent-launcher (GuardLink,Agent_Launcher), #diff (GuardLink,Diff), #report (GuardLink,Report), #sarif (GuardLink,SARIF), #suggest (GuardLink,Suggest), #workspace-link (Workspace,Link), #merge-engine (Workspace,Merge), #report-metadata (Workspace,Metadata), #workspace-config (Workspace,Config) +**Threats:** #path-traversal (Path_Traversal) [high], #cmd-injection (Command_Injection) [critical], #xss (Cross_Site_Scripting) [high], #api-key-exposure (API_Key_Exposure) [high], #ssrf (Server_Side_Request_Forgery) [medium], #redos (ReDoS) [medium], #arbitrary-write (Arbitrary_File_Write) [high], #prompt-injection (Prompt_Injection) [medium], #dos (Denial_of_Service) [medium], #data-exposure (Sensitive_Data_Exposure) [medium], #insecure-deser (Insecure_Deserialization) [medium], #child-proc-injection (Child_Process_Injection) [high], #info-disclosure (Information_Disclosure) [low], #tag-collision (Tag_Collision) [medium], #config-tamper (Config_Tampering) [medium] **Controls:** #path-validation (Path_Validation), #input-sanitize (Input_Sanitization), #output-encoding (Output_Encoding), #key-redaction (Key_Redaction), #process-sandbox (Process_Sandboxing), #config-validation (Config_Validation), #resource-limits (Resource_Limits), #param-commands (Parameterized_Commands), #glob-filtering (Glob_Pattern_Filtering), #regex-anchoring (Regex_Anchoring) ### Open Exposures (need @mitigates or @audit) +- #sarif exposed to #data-exposure [low] (src/analyzer/sarif.ts:15) +- #llm-client exposed to #data-exposure [low] (src/analyze/index.ts:12) +- #llm-client exposed to #prompt-injection [medium] (src/analyze/llm.ts:17) - #agent-launcher exposed to #prompt-injection [medium] (src/agents/launcher.ts:13) - #agent-launcher exposed to #dos [low] (src/agents/launcher.ts:15) - #agent-launcher exposed to #prompt-injection [high] (src/agents/prompts.ts:6) -- #llm-client exposed to #data-exposure [low] (src/analyze/index.ts:12) -- #llm-client exposed to #prompt-injection [medium] (src/analyze/llm.ts:17) -- #sarif exposed to #data-exposure [low] (src/analyzer/sarif.ts:15) -- #cli exposed to #cmd-injection [critical] (src/cli/index.ts:29) +- #cli exposed to #cmd-injection [critical] (src/cli/index.ts:31) - #init exposed to #data-exposure [low] (src/init/index.ts:12) - #mcp exposed to #cmd-injection [high] (src/mcp/index.ts:4) -- #mcp exposed to #prompt-injection [medium] (src/mcp/server.ts:29) -- #mcp exposed to #data-exposure [medium] (src/mcp/server.ts:33) +- #mcp exposed to #prompt-injection [medium] (src/mcp/server.ts:30) +- #mcp exposed to #data-exposure [medium] (src/mcp/server.ts:34) - #suggest exposed to #dos [low] (src/mcp/suggest.ts:16) - #parser exposed to #arbitrary-write [high] (src/parser/clear.ts:7) - #tui exposed to #cmd-injection [high] (src/tui/commands.ts:11) @@ -75,15 +75,8 @@ This project uses [GuardLink](https://guardlink.bugb.io) annotations in source c ### Existing Data Flows (extend, don't duplicate) -- EnvVars -> #agent-launcher via process.env -- ConfigFile -> #agent-launcher via readFileSync -- #agent-launcher -> ConfigFile via writeFileSync -- UserPrompt -> #agent-launcher via launchAgent -- #agent-launcher -> AgentProcess via spawn -- AgentProcess -> #agent-launcher via stdout -- UserPrompt -> #agent-launcher via buildAnnotatePrompt -- ThreatModel -> #agent-launcher via model -- #agent-launcher -> AgentPrompt via return +- ThreatModel -> #sarif via generateSarif +- #sarif -> SarifLog via return - ThreatModel -> #llm-client via serializeModel - ProjectFiles -> #llm-client via readFileSync - #llm-client -> ReportFile via writeFileSync @@ -93,13 +86,20 @@ This project uses [GuardLink](https://guardlink.bugb.io) annotations in source c - LLMToolCall -> #llm-client via createToolExecutor - #llm-client -> NVD via fetch - ProjectFiles -> #llm-client via readFileSync -- ThreatModel -> #sarif via generateSarif -- #sarif -> SarifLog via return -- ... and 42 more +- EnvVars -> #agent-launcher via process.env +- ConfigFile -> #agent-launcher via readFileSync +- #agent-launcher -> ConfigFile via writeFileSync +- UserPrompt -> #agent-launcher via launchAgent +- #agent-launcher -> AgentProcess via spawn +- AgentProcess -> #agent-launcher via stdout +- UserPrompt -> #agent-launcher via buildAnnotatePrompt +- ThreatModel -> #agent-launcher via model +- #agent-launcher -> AgentPrompt via return +- ... and 48 more ### Model Stats -272 annotations, 12 assets, 13 threats, 10 controls, 60 exposures, 42 mitigations, 62 flows +287 annotations, 16 assets, 15 threats, 10 controls, 60 exposures, 44 mitigations, 68 flows > **Note:** This section is auto-generated. Run `guardlink sync` to update after code changes. > Any coding agent (Cursor, Claude, Copilot, Windsurf, etc.) should reference these IDs @@ -118,3 +118,7 @@ This project uses [GuardLink](https://guardlink.bugb.io) annotations in source c + + + + diff --git a/.windsurfrules b/.windsurfrules index 74b1765..dd2786e 100644 --- a/.windsurfrules +++ b/.windsurfrules @@ -37,23 +37,23 @@ Every time you write or modify code that touches security-relevant behavior, you ### Current Definitions (REUSE these IDs — do NOT redefine) -**Assets:** #parser (GuardLink,Parser), #cli (GuardLink,CLI), #tui (GuardLink,TUI), #mcp (GuardLink,MCP), #llm-client (GuardLink,LLM_Client), #dashboard (GuardLink,Dashboard), #init (GuardLink,Init), #agent-launcher (GuardLink,Agent_Launcher), #diff (GuardLink,Diff), #report (GuardLink,Report), #sarif (GuardLink,SARIF), #suggest (GuardLink,Suggest) -**Threats:** #path-traversal (Path_Traversal) [high], #cmd-injection (Command_Injection) [critical], #xss (Cross_Site_Scripting) [high], #api-key-exposure (API_Key_Exposure) [high], #ssrf (Server_Side_Request_Forgery) [medium], #redos (ReDoS) [medium], #arbitrary-write (Arbitrary_File_Write) [high], #prompt-injection (Prompt_Injection) [medium], #dos (Denial_of_Service) [medium], #data-exposure (Sensitive_Data_Exposure) [medium], #insecure-deser (Insecure_Deserialization) [medium], #child-proc-injection (Child_Process_Injection) [high], #info-disclosure (Information_Disclosure) [low] +**Assets:** #parser (GuardLink,Parser), #cli (GuardLink,CLI), #tui (GuardLink,TUI), #mcp (GuardLink,MCP), #llm-client (GuardLink,LLM_Client), #dashboard (GuardLink,Dashboard), #init (GuardLink,Init), #agent-launcher (GuardLink,Agent_Launcher), #diff (GuardLink,Diff), #report (GuardLink,Report), #sarif (GuardLink,SARIF), #suggest (GuardLink,Suggest), #workspace-link (Workspace,Link), #merge-engine (Workspace,Merge), #report-metadata (Workspace,Metadata), #workspace-config (Workspace,Config) +**Threats:** #path-traversal (Path_Traversal) [high], #cmd-injection (Command_Injection) [critical], #xss (Cross_Site_Scripting) [high], #api-key-exposure (API_Key_Exposure) [high], #ssrf (Server_Side_Request_Forgery) [medium], #redos (ReDoS) [medium], #arbitrary-write (Arbitrary_File_Write) [high], #prompt-injection (Prompt_Injection) [medium], #dos (Denial_of_Service) [medium], #data-exposure (Sensitive_Data_Exposure) [medium], #insecure-deser (Insecure_Deserialization) [medium], #child-proc-injection (Child_Process_Injection) [high], #info-disclosure (Information_Disclosure) [low], #tag-collision (Tag_Collision) [medium], #config-tamper (Config_Tampering) [medium] **Controls:** #path-validation (Path_Validation), #input-sanitize (Input_Sanitization), #output-encoding (Output_Encoding), #key-redaction (Key_Redaction), #process-sandbox (Process_Sandboxing), #config-validation (Config_Validation), #resource-limits (Resource_Limits), #param-commands (Parameterized_Commands), #glob-filtering (Glob_Pattern_Filtering), #regex-anchoring (Regex_Anchoring) ### Open Exposures (need @mitigates or @audit) +- #sarif exposed to #data-exposure [low] (src/analyzer/sarif.ts:15) +- #llm-client exposed to #data-exposure [low] (src/analyze/index.ts:12) +- #llm-client exposed to #prompt-injection [medium] (src/analyze/llm.ts:17) - #agent-launcher exposed to #prompt-injection [medium] (src/agents/launcher.ts:13) - #agent-launcher exposed to #dos [low] (src/agents/launcher.ts:15) - #agent-launcher exposed to #prompt-injection [high] (src/agents/prompts.ts:6) -- #llm-client exposed to #data-exposure [low] (src/analyze/index.ts:12) -- #llm-client exposed to #prompt-injection [medium] (src/analyze/llm.ts:17) -- #sarif exposed to #data-exposure [low] (src/analyzer/sarif.ts:15) -- #cli exposed to #cmd-injection [critical] (src/cli/index.ts:29) +- #cli exposed to #cmd-injection [critical] (src/cli/index.ts:31) - #init exposed to #data-exposure [low] (src/init/index.ts:12) - #mcp exposed to #cmd-injection [high] (src/mcp/index.ts:4) -- #mcp exposed to #prompt-injection [medium] (src/mcp/server.ts:29) -- #mcp exposed to #data-exposure [medium] (src/mcp/server.ts:33) +- #mcp exposed to #prompt-injection [medium] (src/mcp/server.ts:30) +- #mcp exposed to #data-exposure [medium] (src/mcp/server.ts:34) - #suggest exposed to #dos [low] (src/mcp/suggest.ts:16) - #parser exposed to #arbitrary-write [high] (src/parser/clear.ts:7) - #tui exposed to #cmd-injection [high] (src/tui/commands.ts:11) @@ -61,15 +61,8 @@ Every time you write or modify code that touches security-relevant behavior, you ### Existing Data Flows (extend, don't duplicate) -- EnvVars -> #agent-launcher via process.env -- ConfigFile -> #agent-launcher via readFileSync -- #agent-launcher -> ConfigFile via writeFileSync -- UserPrompt -> #agent-launcher via launchAgent -- #agent-launcher -> AgentProcess via spawn -- AgentProcess -> #agent-launcher via stdout -- UserPrompt -> #agent-launcher via buildAnnotatePrompt -- ThreatModel -> #agent-launcher via model -- #agent-launcher -> AgentPrompt via return +- ThreatModel -> #sarif via generateSarif +- #sarif -> SarifLog via return - ThreatModel -> #llm-client via serializeModel - ProjectFiles -> #llm-client via readFileSync - #llm-client -> ReportFile via writeFileSync @@ -79,13 +72,20 @@ Every time you write or modify code that touches security-relevant behavior, you - LLMToolCall -> #llm-client via createToolExecutor - #llm-client -> NVD via fetch - ProjectFiles -> #llm-client via readFileSync -- ThreatModel -> #sarif via generateSarif -- #sarif -> SarifLog via return -- ... and 42 more +- EnvVars -> #agent-launcher via process.env +- ConfigFile -> #agent-launcher via readFileSync +- #agent-launcher -> ConfigFile via writeFileSync +- UserPrompt -> #agent-launcher via launchAgent +- #agent-launcher -> AgentProcess via spawn +- AgentProcess -> #agent-launcher via stdout +- UserPrompt -> #agent-launcher via buildAnnotatePrompt +- ThreatModel -> #agent-launcher via model +- #agent-launcher -> AgentPrompt via return +- ... and 48 more ### Model Stats -272 annotations, 12 assets, 13 threats, 10 controls, 60 exposures, 42 mitigations, 62 flows +287 annotations, 16 assets, 15 threats, 10 controls, 60 exposures, 44 mitigations, 68 flows @@ -99,3 +99,7 @@ Every time you write or modify code that touches security-relevant behavior, you + + + + diff --git a/AGENTS.md b/AGENTS.md index 0d96456..68da32c 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -51,23 +51,23 @@ This project uses [GuardLink](https://guardlink.bugb.io) annotations in source c ### Current Definitions (REUSE these IDs — do NOT redefine) -**Assets:** #parser (GuardLink,Parser), #cli (GuardLink,CLI), #tui (GuardLink,TUI), #mcp (GuardLink,MCP), #llm-client (GuardLink,LLM_Client), #dashboard (GuardLink,Dashboard), #init (GuardLink,Init), #agent-launcher (GuardLink,Agent_Launcher), #diff (GuardLink,Diff), #report (GuardLink,Report), #sarif (GuardLink,SARIF), #suggest (GuardLink,Suggest) -**Threats:** #path-traversal (Path_Traversal) [high], #cmd-injection (Command_Injection) [critical], #xss (Cross_Site_Scripting) [high], #api-key-exposure (API_Key_Exposure) [high], #ssrf (Server_Side_Request_Forgery) [medium], #redos (ReDoS) [medium], #arbitrary-write (Arbitrary_File_Write) [high], #prompt-injection (Prompt_Injection) [medium], #dos (Denial_of_Service) [medium], #data-exposure (Sensitive_Data_Exposure) [medium], #insecure-deser (Insecure_Deserialization) [medium], #child-proc-injection (Child_Process_Injection) [high], #info-disclosure (Information_Disclosure) [low] +**Assets:** #parser (GuardLink,Parser), #cli (GuardLink,CLI), #tui (GuardLink,TUI), #mcp (GuardLink,MCP), #llm-client (GuardLink,LLM_Client), #dashboard (GuardLink,Dashboard), #init (GuardLink,Init), #agent-launcher (GuardLink,Agent_Launcher), #diff (GuardLink,Diff), #report (GuardLink,Report), #sarif (GuardLink,SARIF), #suggest (GuardLink,Suggest), #workspace-link (Workspace,Link), #merge-engine (Workspace,Merge), #report-metadata (Workspace,Metadata), #workspace-config (Workspace,Config) +**Threats:** #path-traversal (Path_Traversal) [high], #cmd-injection (Command_Injection) [critical], #xss (Cross_Site_Scripting) [high], #api-key-exposure (API_Key_Exposure) [high], #ssrf (Server_Side_Request_Forgery) [medium], #redos (ReDoS) [medium], #arbitrary-write (Arbitrary_File_Write) [high], #prompt-injection (Prompt_Injection) [medium], #dos (Denial_of_Service) [medium], #data-exposure (Sensitive_Data_Exposure) [medium], #insecure-deser (Insecure_Deserialization) [medium], #child-proc-injection (Child_Process_Injection) [high], #info-disclosure (Information_Disclosure) [low], #tag-collision (Tag_Collision) [medium], #config-tamper (Config_Tampering) [medium] **Controls:** #path-validation (Path_Validation), #input-sanitize (Input_Sanitization), #output-encoding (Output_Encoding), #key-redaction (Key_Redaction), #process-sandbox (Process_Sandboxing), #config-validation (Config_Validation), #resource-limits (Resource_Limits), #param-commands (Parameterized_Commands), #glob-filtering (Glob_Pattern_Filtering), #regex-anchoring (Regex_Anchoring) ### Open Exposures (need @mitigates or @audit) +- #sarif exposed to #data-exposure [low] (src/analyzer/sarif.ts:15) +- #llm-client exposed to #data-exposure [low] (src/analyze/index.ts:12) +- #llm-client exposed to #prompt-injection [medium] (src/analyze/llm.ts:17) - #agent-launcher exposed to #prompt-injection [medium] (src/agents/launcher.ts:13) - #agent-launcher exposed to #dos [low] (src/agents/launcher.ts:15) - #agent-launcher exposed to #prompt-injection [high] (src/agents/prompts.ts:6) -- #llm-client exposed to #data-exposure [low] (src/analyze/index.ts:12) -- #llm-client exposed to #prompt-injection [medium] (src/analyze/llm.ts:17) -- #sarif exposed to #data-exposure [low] (src/analyzer/sarif.ts:15) -- #cli exposed to #cmd-injection [critical] (src/cli/index.ts:29) +- #cli exposed to #cmd-injection [critical] (src/cli/index.ts:31) - #init exposed to #data-exposure [low] (src/init/index.ts:12) - #mcp exposed to #cmd-injection [high] (src/mcp/index.ts:4) -- #mcp exposed to #prompt-injection [medium] (src/mcp/server.ts:29) -- #mcp exposed to #data-exposure [medium] (src/mcp/server.ts:33) +- #mcp exposed to #prompt-injection [medium] (src/mcp/server.ts:30) +- #mcp exposed to #data-exposure [medium] (src/mcp/server.ts:34) - #suggest exposed to #dos [low] (src/mcp/suggest.ts:16) - #parser exposed to #arbitrary-write [high] (src/parser/clear.ts:7) - #tui exposed to #cmd-injection [high] (src/tui/commands.ts:11) @@ -75,15 +75,8 @@ This project uses [GuardLink](https://guardlink.bugb.io) annotations in source c ### Existing Data Flows (extend, don't duplicate) -- EnvVars -> #agent-launcher via process.env -- ConfigFile -> #agent-launcher via readFileSync -- #agent-launcher -> ConfigFile via writeFileSync -- UserPrompt -> #agent-launcher via launchAgent -- #agent-launcher -> AgentProcess via spawn -- AgentProcess -> #agent-launcher via stdout -- UserPrompt -> #agent-launcher via buildAnnotatePrompt -- ThreatModel -> #agent-launcher via model -- #agent-launcher -> AgentPrompt via return +- ThreatModel -> #sarif via generateSarif +- #sarif -> SarifLog via return - ThreatModel -> #llm-client via serializeModel - ProjectFiles -> #llm-client via readFileSync - #llm-client -> ReportFile via writeFileSync @@ -93,13 +86,20 @@ This project uses [GuardLink](https://guardlink.bugb.io) annotations in source c - LLMToolCall -> #llm-client via createToolExecutor - #llm-client -> NVD via fetch - ProjectFiles -> #llm-client via readFileSync -- ThreatModel -> #sarif via generateSarif -- #sarif -> SarifLog via return -- ... and 42 more +- EnvVars -> #agent-launcher via process.env +- ConfigFile -> #agent-launcher via readFileSync +- #agent-launcher -> ConfigFile via writeFileSync +- UserPrompt -> #agent-launcher via launchAgent +- #agent-launcher -> AgentProcess via spawn +- AgentProcess -> #agent-launcher via stdout +- UserPrompt -> #agent-launcher via buildAnnotatePrompt +- ThreatModel -> #agent-launcher via model +- #agent-launcher -> AgentPrompt via return +- ... and 48 more ### Model Stats -272 annotations, 12 assets, 13 threats, 10 controls, 60 exposures, 42 mitigations, 62 flows +287 annotations, 16 assets, 15 threats, 10 controls, 60 exposures, 44 mitigations, 68 flows > **Note:** This section is auto-generated. Run `guardlink sync` to update after code changes. > Any coding agent (Cursor, Claude, Copilot, Windsurf, etc.) should reference these IDs @@ -117,3 +117,7 @@ This project uses [GuardLink](https://guardlink.bugb.io) annotations in source c + + + + diff --git a/CHANGELOG.md b/CHANGELOG.md index 1f9bcd1..1eeb8c2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,32 @@ All notable changes to GuardLink CLI will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## [1.4.0] — 2026-02-27 + +### Added + +- **Workspace**: Multi-repo workspace support — link N service repos into a unified threat model with cross-repo tag resolution, weekly diff tracking, and merged dashboards +- **Workspace**: `guardlink link-project --workspace --registry ` — scaffold workspace.yaml in each repo, auto-detect repo names from git/package.json/Cargo.toml, inject cross-repo context into agent instruction files +- **Workspace**: `guardlink link-project --add --from ` — add a repo to an existing workspace with sibling auto-discovery +- **Workspace**: `guardlink link-project --remove --from ` — remove a repo from workspace, update all siblings found on disk +- **Workspace**: `guardlink merge ` — merge N per-repo report JSONs into a unified MergedReport with tag registry, cross-repo reference resolution, stale/schema warnings, and aggregated stats +- **Workspace**: `--diff-against ` flag on merge for week-over-week risk tracking (assets/threats/mitigations/exposures added/removed, risk trend, unresolved ref changes) +- **Workspace**: `-o ` dashboard HTML output + `--json ` merged JSON output + `--summary-only` text mode +- **CLI**: `guardlink report --format json` — JSON report output with metadata (repo, workspace, commit SHA, schema version) +- **TUI**: `/workspace` — show workspace config, sibling repos, registries +- **TUI**: `/link` — link repos with `--add`/`--remove` support +- **TUI**: `/merge` — merge reports with `--json`, `--diff-against`, `-o` flags +- **MCP**: `guardlink_workspace_info` tool — returns workspace name, this_repo identity, sibling tag prefixes, and cross-repo annotation rules for agents +- **Parser**: External reference detection — scans relationship annotations for tags with dot-prefix matching sibling repo names from workspace.yaml, populates `ThreatModel.external_refs` +- **Types**: `ExternalRef` interface, `ThreatModel.external_refs` field, `ReportMetadata` with repo/workspace/commit_sha/schema_version +- **CI**: `examples/ci/per-repo-report.yml` — per-repo workflow: validate on PRs (diff + SARIF + PR comment), generate + upload report JSON on push to main +- **CI**: `examples/ci/workspace-merge.yml` — weekly workspace merge workflow: download all repo artifacts, merge, dashboard, weekly diff, optional GitHub Pages + Slack +- **Docs**: `docs/WORKSPACE.md` — multi-repo setup guide, workspace.yaml spec, cross-repo annotation rules, merge behavior, CI integration, weekly workflow + +### Changed + +- **MCP**: Server version bumped to 1.4.0 + ## [1.3.0] — 2026-02-27 ### Added diff --git a/CLAUDE.md b/CLAUDE.md index 039528f..2ae6015 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -51,23 +51,23 @@ This project uses [GuardLink](https://guardlink.bugb.io) annotations in source c ### Current Definitions (REUSE these IDs — do NOT redefine) -**Assets:** #parser (GuardLink,Parser), #cli (GuardLink,CLI), #tui (GuardLink,TUI), #mcp (GuardLink,MCP), #llm-client (GuardLink,LLM_Client), #dashboard (GuardLink,Dashboard), #init (GuardLink,Init), #agent-launcher (GuardLink,Agent_Launcher), #diff (GuardLink,Diff), #report (GuardLink,Report), #sarif (GuardLink,SARIF), #suggest (GuardLink,Suggest) -**Threats:** #path-traversal (Path_Traversal) [high], #cmd-injection (Command_Injection) [critical], #xss (Cross_Site_Scripting) [high], #api-key-exposure (API_Key_Exposure) [high], #ssrf (Server_Side_Request_Forgery) [medium], #redos (ReDoS) [medium], #arbitrary-write (Arbitrary_File_Write) [high], #prompt-injection (Prompt_Injection) [medium], #dos (Denial_of_Service) [medium], #data-exposure (Sensitive_Data_Exposure) [medium], #insecure-deser (Insecure_Deserialization) [medium], #child-proc-injection (Child_Process_Injection) [high], #info-disclosure (Information_Disclosure) [low] +**Assets:** #parser (GuardLink,Parser), #cli (GuardLink,CLI), #tui (GuardLink,TUI), #mcp (GuardLink,MCP), #llm-client (GuardLink,LLM_Client), #dashboard (GuardLink,Dashboard), #init (GuardLink,Init), #agent-launcher (GuardLink,Agent_Launcher), #diff (GuardLink,Diff), #report (GuardLink,Report), #sarif (GuardLink,SARIF), #suggest (GuardLink,Suggest), #workspace-link (Workspace,Link), #merge-engine (Workspace,Merge), #report-metadata (Workspace,Metadata), #workspace-config (Workspace,Config) +**Threats:** #path-traversal (Path_Traversal) [high], #cmd-injection (Command_Injection) [critical], #xss (Cross_Site_Scripting) [high], #api-key-exposure (API_Key_Exposure) [high], #ssrf (Server_Side_Request_Forgery) [medium], #redos (ReDoS) [medium], #arbitrary-write (Arbitrary_File_Write) [high], #prompt-injection (Prompt_Injection) [medium], #dos (Denial_of_Service) [medium], #data-exposure (Sensitive_Data_Exposure) [medium], #insecure-deser (Insecure_Deserialization) [medium], #child-proc-injection (Child_Process_Injection) [high], #info-disclosure (Information_Disclosure) [low], #tag-collision (Tag_Collision) [medium], #config-tamper (Config_Tampering) [medium] **Controls:** #path-validation (Path_Validation), #input-sanitize (Input_Sanitization), #output-encoding (Output_Encoding), #key-redaction (Key_Redaction), #process-sandbox (Process_Sandboxing), #config-validation (Config_Validation), #resource-limits (Resource_Limits), #param-commands (Parameterized_Commands), #glob-filtering (Glob_Pattern_Filtering), #regex-anchoring (Regex_Anchoring) ### Open Exposures (need @mitigates or @audit) +- #sarif exposed to #data-exposure [low] (src/analyzer/sarif.ts:15) +- #llm-client exposed to #data-exposure [low] (src/analyze/index.ts:12) +- #llm-client exposed to #prompt-injection [medium] (src/analyze/llm.ts:17) - #agent-launcher exposed to #prompt-injection [medium] (src/agents/launcher.ts:13) - #agent-launcher exposed to #dos [low] (src/agents/launcher.ts:15) - #agent-launcher exposed to #prompt-injection [high] (src/agents/prompts.ts:6) -- #llm-client exposed to #data-exposure [low] (src/analyze/index.ts:12) -- #llm-client exposed to #prompt-injection [medium] (src/analyze/llm.ts:17) -- #sarif exposed to #data-exposure [low] (src/analyzer/sarif.ts:15) -- #cli exposed to #cmd-injection [critical] (src/cli/index.ts:29) +- #cli exposed to #cmd-injection [critical] (src/cli/index.ts:31) - #init exposed to #data-exposure [low] (src/init/index.ts:12) - #mcp exposed to #cmd-injection [high] (src/mcp/index.ts:4) -- #mcp exposed to #prompt-injection [medium] (src/mcp/server.ts:29) -- #mcp exposed to #data-exposure [medium] (src/mcp/server.ts:33) +- #mcp exposed to #prompt-injection [medium] (src/mcp/server.ts:30) +- #mcp exposed to #data-exposure [medium] (src/mcp/server.ts:34) - #suggest exposed to #dos [low] (src/mcp/suggest.ts:16) - #parser exposed to #arbitrary-write [high] (src/parser/clear.ts:7) - #tui exposed to #cmd-injection [high] (src/tui/commands.ts:11) @@ -75,15 +75,8 @@ This project uses [GuardLink](https://guardlink.bugb.io) annotations in source c ### Existing Data Flows (extend, don't duplicate) -- EnvVars -> #agent-launcher via process.env -- ConfigFile -> #agent-launcher via readFileSync -- #agent-launcher -> ConfigFile via writeFileSync -- UserPrompt -> #agent-launcher via launchAgent -- #agent-launcher -> AgentProcess via spawn -- AgentProcess -> #agent-launcher via stdout -- UserPrompt -> #agent-launcher via buildAnnotatePrompt -- ThreatModel -> #agent-launcher via model -- #agent-launcher -> AgentPrompt via return +- ThreatModel -> #sarif via generateSarif +- #sarif -> SarifLog via return - ThreatModel -> #llm-client via serializeModel - ProjectFiles -> #llm-client via readFileSync - #llm-client -> ReportFile via writeFileSync @@ -93,13 +86,20 @@ This project uses [GuardLink](https://guardlink.bugb.io) annotations in source c - LLMToolCall -> #llm-client via createToolExecutor - #llm-client -> NVD via fetch - ProjectFiles -> #llm-client via readFileSync -- ThreatModel -> #sarif via generateSarif -- #sarif -> SarifLog via return -- ... and 42 more +- EnvVars -> #agent-launcher via process.env +- ConfigFile -> #agent-launcher via readFileSync +- #agent-launcher -> ConfigFile via writeFileSync +- UserPrompt -> #agent-launcher via launchAgent +- #agent-launcher -> AgentProcess via spawn +- AgentProcess -> #agent-launcher via stdout +- UserPrompt -> #agent-launcher via buildAnnotatePrompt +- ThreatModel -> #agent-launcher via model +- #agent-launcher -> AgentPrompt via return +- ... and 48 more ### Model Stats -272 annotations, 12 assets, 13 threats, 10 controls, 60 exposures, 42 mitigations, 62 flows +287 annotations, 16 assets, 15 threats, 10 controls, 60 exposures, 44 mitigations, 68 flows > **Note:** This section is auto-generated. Run `guardlink sync` to update after code changes. > Any coding agent (Cursor, Claude, Copilot, Windsurf, etc.) should reference these IDs @@ -118,3 +118,7 @@ This project uses [GuardLink](https://guardlink.bugb.io) annotations in source c + + + + diff --git a/README.md b/README.md index 8291d03..6ca8c20 100644 --- a/README.md +++ b/README.md @@ -151,6 +151,7 @@ GuardLink ships an MCP server and behavioral directives for AI coding agents. Af | `guardlink_dashboard` | Generate HTML dashboard | | `guardlink_sarif` | Export SARIF 2.1.0 | | `guardlink_diff` | Compare threat model against a git ref | +| `guardlink_workspace_info` | Workspace config, sibling repos, tag prefixes for cross-repo annotations | **Resources:** `guardlink://model`, `guardlink://definitions`, `guardlink://config` @@ -179,6 +180,11 @@ GuardLink ships an MCP server and behavioral directives for AI coding agents. Af | `guardlink clear [dir]` | Remove all annotations from source files (with `--dry-run` preview) | | `guardlink sync [dir]` | Sync agent instruction files with current threat model | | `guardlink unannotated [dir]` | List source files with no annotations | +| `guardlink link-project ` | Link repos into a shared workspace for cross-repo threat modeling | +| `guardlink link-project --add ` | Add a repo to an existing workspace | +| `guardlink link-project --remove ` | Remove a repo from a workspace | +| `guardlink merge ` | Merge per-repo report JSONs into a unified workspace dashboard | +| `guardlink report --format json` | Generate report JSON with metadata (repo, workspace, commit SHA) | | `guardlink config` | Set AI provider and API key | | `guardlink mcp` | Start MCP server for AI agent integration | @@ -282,6 +288,10 @@ jobs: See [`examples/github-action.yml`](examples/github-action.yml) for a full example with PR comments and SARIF upload. +### Multi-Repo CI + +For workspace setups, GuardLink provides two additional workflow templates: a per-repo workflow that generates report JSON artifacts on every push, and a workspace merge workflow that runs weekly to combine all repos into a unified dashboard. See the [CI setup guide](examples/ci/README.md) for step-by-step instructions. + ### What CI Catches - **New route, no annotations:** `guardlink diff` shows "+1 endpoint, 0 mitigations" — the team sees the gap. @@ -294,6 +304,35 @@ See [`examples/github-action.yml`](examples/github-action.yml) for a full exampl --- +## Multi-Repo Workspaces + +In microservices architectures, a single repo only has part of the security picture. `PaymentService` is defined in `repo-payments`, exposed in `repo-gateway`, mitigated in `repo-auth-lib`. GuardLink workspaces link these repos so the threat model spans service boundaries. + +```bash +# Link three repos into a workspace +guardlink link-project ./payment-svc ./auth-lib ./api-gateway \ + --workspace acme-platform + +# Each repo gets .guardlink/workspace.yaml + agent files updated with cross-repo context +# Agents now know about sibling services and use tag prefixes like #payment-svc.refund + +# Generate per-repo JSON reports (in each repo or in CI) +guardlink report --format json -o guardlink-report.json + +# Merge all reports into a unified dashboard +guardlink merge payment-svc.json auth-lib.json api-gateway.json \ + -o dashboard.html --json merged.json + +# Week-over-week diff for security leads +guardlink merge *.json --diff-against last-week.json --json merged.json +``` + +Annotations reference sibling repos by tag prefix — `@flows #request from #api-gateway.router to #payment-svc.refund` — and these references resolve during merge. `guardlink validate` flags them as external refs locally, but they're expected and won't block CI. + +For automated weekly dashboards, see the [CI setup guide](examples/ci/README.md). Full workspace documentation: [docs/WORKSPACE.md](docs/WORKSPACE.md). + +--- + ## Real-World Results We tested GuardLink + Claude Code on [vuln-node.js-express.js-app](https://github.com/SirAppSec/vuln-node.js-express.js-app), a deliberately vulnerable Express.js application with 37 documented vulnerability types. diff --git a/docs/WORKSPACE.md b/docs/WORKSPACE.md new file mode 100644 index 0000000..ffb62ab --- /dev/null +++ b/docs/WORKSPACE.md @@ -0,0 +1,209 @@ +# Multi-Repo Workspaces + +GuardLink workspaces link multiple service repos into a unified threat model. +Each repo maintains its own annotations, and `guardlink merge` combines them +into a single dashboard with cross-repo tag resolution, risk tracking, and +weekly diff summaries. + +## Quick Start + +```bash +# Link three sibling repos into a workspace +guardlink link-project ./payment-svc ./auth-lib ./api-gateway \ + --workspace acme-platform \ + --registry github.com/acme + +# Each repo now has .guardlink/workspace.yaml +# Agent instruction files updated with cross-repo context + +# Generate per-repo reports +cd payment-svc && guardlink report --format json -o payment-svc.json +cd ../auth-lib && guardlink report --format json -o auth-lib.json +cd ../api-gateway && guardlink report --format json -o api-gateway.json + +# Merge into unified dashboard +guardlink merge payment-svc.json auth-lib.json api-gateway.json \ + --workspace acme-platform \ + --json merged.json \ + -o dashboard.html +``` + +## Concepts + +**Workspace**: A named collection of repos that share a threat model boundary. +Each repo has its own annotations, but tags can reference definitions in sibling +repos using dot-prefix notation. + +**Tag prefixes**: In a workspace, tag IDs use the repo name as a prefix: +`#payment-svc.refund-handler`, `#auth-lib.token-verify`. This prevents +collisions and makes ownership clear. + +**External refs**: When an annotation in repo A references a tag defined in +repo B (e.g. `@mitigates #auth-lib.token-verify against ...`), that's an +external ref. These are detected during parsing and show as "external refs" +in `guardlink validate`. They resolve during `guardlink merge`. + +**Merge**: Combines N per-repo report JSONs into a single `MergedReport` with +deduplicated definitions, a unified tag registry, cross-repo reference +resolution, and aggregated stats. + +## workspace.yaml + +Each linked repo gets `.guardlink/workspace.yaml`: + +```yaml +workspace: acme-platform +this_repo: payment-svc +repos: + - name: payment-svc + registry: github.com/acme/payment-svc + - name: auth-lib + registry: github.com/acme/auth-lib + - name: api-gateway + registry: github.com/acme/api-gateway +``` + +| Field | Required | Description | +|-------|----------|-------------| +| `workspace` | yes | Workspace name, shared across all repos | +| `this_repo` | yes | This repo's name (differs per repo) | +| `repos` | yes | All repos in the workspace | +| `repos[].name` | yes | Short name, used as tag prefix | +| `repos[].registry` | no | Remote URL for linking/discovery | + +## Commands + +### link-project — Fresh setup + +Links multiple repos into a new workspace. Auto-initializes repos that haven't +run `guardlink init` yet. + +```bash +guardlink link-project ./svc-a ./svc-b ./svc-c \ + --workspace my-platform \ + --registry github.com/my-org +``` + +### link-project --add — Add a repo + +Adds a new repo to an existing workspace. Reads workspace config from an +existing member, adds the new repo, and updates all sibling repos it can +find on disk. + +```bash +guardlink link-project --add ./new-service --from ./svc-a +``` + +### link-project --remove — Remove a repo + +Removes a repo from the workspace. Updates all sibling repos found on disk. + +```bash +guardlink link-project --remove old-service --from ./svc-a +``` + +### report --format json — Per-repo report + +Generates a report JSON with metadata (repo name, workspace, commit SHA, +schema version). This is the input for `guardlink merge`. + +```bash +guardlink report --format json -o guardlink-report.json +``` + +### merge — Combine reports + +Merges N report JSONs into a unified threat model. + +```bash +guardlink merge repo-a.json repo-b.json repo-c.json \ + --workspace acme-platform \ + --json merged.json \ + -o dashboard.html \ + --diff-against last-week.json +``` + +| Flag | Description | +|------|-------------| +| `--json ` | Write merged report JSON | +| `-o ` | Write dashboard HTML (default: `workspace-dashboard.html`) | +| `--diff-against ` | Compare against previous merge, write weekly diff | +| `-w, --workspace ` | Workspace name (auto-detected from reports if unset) | +| `--summary-only` | Print text summary only, skip dashboard | + +### TUI commands + +``` +/workspace Show workspace config, sibling repos, registries +/link Link repos (same as CLI link-project) +/link --add Add repo to workspace (uses current dir as --from) +/link --remove Remove repo from workspace +/merge Merge reports (supports --json, --diff-against, -o) +``` + +### MCP tool + +`guardlink_workspace_info` returns workspace context for AI agents: workspace +name, this repo's identity, sibling repos with tag prefixes, and cross-repo +annotation rules. Returns null fields if not in a workspace. + +## Writing Cross-Repo Annotations + +In a workspace, annotations can reference sibling repos by tag prefix: + +```typescript +// In payment-svc: +// @asset PaymentService.RefundHandler (#payment-svc.refund) -- "Processes refund requests" +// @flows #request from #api-gateway.router to #payment-svc.refund via gRPC +// @mitigates #payment-svc.refund against #auth-lib.token-replay using #request-signing +``` + +Rules: +- Use `#.` for all tag IDs +- Reference sibling assets/threats/controls by their full prefixed tag +- Don't redefine assets that belong to another repo — reference by tag +- External refs show as warnings in local `guardlink validate` but resolve + during `guardlink merge` +- `@flows` across repos are encouraged — they document service boundaries + +## Merge Behavior + +**Tag registry**: When the same tag is defined in multiple repos, ownership is +determined by prefix match (`#payment-svc.refund` → owned by `payment-svc`). +If no prefix match, first definition wins. Duplicates produce warnings. + +**Reference resolution**: All tag references in relationship annotations +(mitigates, exposes, flows, etc.) are checked against the unified registry. +Unresolved refs are listed in the merge output with inferred target repo. + +**Deduplication**: Definition annotations (asset, threat, control) are deduped +by tag ID — first definition wins. Relationship annotations are kept from all +repos (the same relationship stated in two repos is meaningful). + +**Stale detection**: Reports older than 7 days (configurable) get a warning. +Missing repos (in workspace but no report file) are flagged. + +**Severity**: The merged report computes unmitigated exposures across the full +workspace, catching cases where repo A exposes a threat that repo B was +supposed to mitigate. + +## CI Integration + +See `examples/ci/` for GitHub Actions workflows: + +- **`per-repo-report.yml`** — runs in each service repo. Validates on PRs, + generates + uploads report JSON on push to main. +- **`workspace-merge.yml`** — runs weekly in a central repo. Downloads all + report artifacts, merges, generates dashboard, computes weekly diff. + +See `examples/ci/README.md` for full setup instructions. + +## Weekly Workflow + +A typical weekly cycle: + +1. Engineers push code with annotations → per-repo CI validates + generates reports +2. Monday: workspace merge workflow runs → unified dashboard + weekly diff +3. Security lead reviews dashboard: new unmitigated exposures, removed mitigations, + unresolved cross-repo refs +4. Team addresses gaps: add missing mitigations, fix dangling refs, update annotations diff --git a/examples/ci/README.md b/examples/ci/README.md new file mode 100644 index 0000000..ccb1f24 --- /dev/null +++ b/examples/ci/README.md @@ -0,0 +1,180 @@ +# Multi-Repo CI Setup + +Automate threat model reports across your workspace with two GitHub Actions workflows. + +**What you get:** Every push to main generates a per-repo report. Every Monday (or on demand), a central workflow merges all reports into a unified dashboard with a week-over-week diff. + +## How It Works + +``` + service repos (each runs per-repo workflow) central repo (runs weekly merge) + ┌──────────────────────────────────────────┐ ┌────────────────────────────────┐ + │ │ │ │ + │ push → main │ │ Monday 9am (cron) │ + │ ↓ │ │ ↓ │ + │ guardlink validate │ │ download all report artifacts │ + │ guardlink report --format json │ │ ↓ │ + │ ↓ │ │ guardlink merge *.json │ + │ upload artifact: guardlink-report.json │ ────► │ ↓ │ + │ │ │ dashboard.html + weekly diff │ + │ (PRs also get: diff comment + SARIF) │ │ + merged.json (for next week) │ + └──────────────────────────────────────────┘ └────────────────────────────────┘ +``` + +## Prerequisites + +Before starting, make sure you've linked your repos locally: + +```bash +guardlink link-project ./svc-a ./svc-b ./svc-c --workspace my-platform +``` + +This creates `.guardlink/workspace.yaml` in each repo. See [docs/WORKSPACE.md](../../docs/WORKSPACE.md) for details. + +--- + +## Step 1: Add the Per-Repo Workflow to Each Service Repo + +Copy [`per-repo-report.yml`](per-repo-report.yml) into each service repo: + +``` +your-service-repo/ +└── .github/ + └── workflows/ + └── guardlink.yml ← copy per-repo-report.yml here +``` + +That's it — no configuration needed. On every push to main, it will: +- Validate annotations +- Generate `guardlink-report.json` +- Upload it as a GitHub artifact (retained 30 days) + +On PRs, it will: +- Run validation +- Post a threat model diff as a PR comment +- Upload SARIF to GitHub's Security tab + +Commit and push to each service repo. + +## Step 2: Create the Central Merge Repo + +Pick or create a repo that will host the merged dashboard. This can be an existing `infra`, `security`, or `platform` repo — or a dedicated `threat-model` repo. + +Copy [`workspace-merge.yml`](workspace-merge.yml) into it: + +``` +central-repo/ +└── .github/ + └── workflows/ + └── guardlink-merge.yml ← copy workspace-merge.yml here +``` + +## Step 3: Configure the Merge Workflow + +Open `guardlink-merge.yml` and edit the `env` block at the top: + +```yaml +env: + REPOS: "your-org/payment-service your-org/auth-service your-org/api-gateway" + WORKSPACE_NAME: "your-workspace" + ARTIFACT_NAME: "guardlink-report" +``` + +- **`REPOS`** — space-separated list of `org/repo` names. These must match the repos where you added the per-repo workflow in Step 1. +- **`WORKSPACE_NAME`** — your workspace name (same as in `guardlink link-project --workspace`). +- **`ARTIFACT_NAME`** — leave as `guardlink-report` unless you changed it in the per-repo workflow. + +## Step 4: Create a GitHub PAT + +The merge workflow needs to download artifacts from your service repos. + +1. Go to **GitHub → Settings → Developer settings → Personal access tokens → Tokens (classic)** +2. Create a new token with the **`actions:read`** scope +3. Make sure it has access to all repos listed in `REPOS` +4. In the central repo, go to **Settings → Secrets → Actions** and add it as **`GUARDLINK_PAT`** + +> **Tip:** For GitHub Enterprise or fine-grained tokens, you need "Actions: Read" permission on each service repo. + +## Step 5: Push and Verify + +1. Push the per-repo workflow to each service repo. Wait for the first push-to-main run to complete — check that a `guardlink-report` artifact appears under Actions → your workflow run → Artifacts. + +2. Push the merge workflow to the central repo. Trigger it manually: **Actions → GuardLink Workspace Merge → Run workflow**. + +3. Check the output: the workflow should download all repo artifacts, run the merge, and upload `workspace-dashboard.html`, `workspace-report.json`, and a weekly diff as artifacts. + +--- + +## Optional: GitHub Pages + +To auto-publish the dashboard to GitHub Pages, uncomment the Pages deployment section in `workspace-merge.yml`. The dashboard will be available at `https://your-org.github.io/central-repo/`. + +Make sure GitHub Pages is enabled in the central repo's settings (Settings → Pages → Source: GitHub Actions). + +## Optional: Slack Notifications + +To get a weekly summary in Slack: + +1. Create a [Slack Incoming Webhook](https://api.slack.com/messaging/webhooks) +2. Add it as `SLACK_WEBHOOK_URL` secret in the central repo +3. The workflow's Slack step is already configured — it sends the weekly diff summary + +## Optional: Adjust the Schedule + +The default schedule is Monday at 9am UTC. Change the cron expression in the merge workflow: + +```yaml +on: + schedule: + - cron: '0 9 * * 1' # Monday 9am UTC + # - cron: '0 14 * * 5' # Friday 2pm UTC (example) +``` + +--- + +## What the Merge Output Looks Like + +Each Monday, the merge produces three files: + +**`workspace-dashboard.html`** — Interactive HTML dashboard showing all assets, threats, mitigations, and exposures across the entire workspace. Cross-repo data flows visible. Open in any browser. + +**`workspace-report.json`** — Machine-readable merged report. This becomes `--diff-against` input for next week's run. Contains the tag registry, unresolved cross-repo refs, per-repo statuses, and aggregated totals. + +**`workspace-merge-weekly-diff.md`** — Human-readable summary of what changed: + +``` +# acme-platform — Weekly Threat Model Changes + +**Period:** 2026-02-17 → 2026-02-24 +**Risk trend:** 🟢 decreased + +## Changes +- +5 new mitigation(s) +- +3 new exposure(s) +- -1 removed mitigation(s) ⚠️ + +## Risk +- 🟢 2 exposure(s) now mitigated + +## Repos +- 📝 payment-service (updated) +- 📝 api-gateway (updated) +``` + +--- + +## Artifact Retention + +- Per-repo reports: **30 days** (configurable via `retention-days` in per-repo workflow) +- Merged results: **90 days** +- The merge workflow commits `previous/workspace-report.json` to enable week-over-week diffs + +## Troubleshooting + +**"No artifacts found for repo X"** — The per-repo workflow hasn't run on that repo yet, or the artifact expired (>30 days). Push a commit to main on that repo to trigger a fresh report. + +**"guardlink-report artifact not found"** — Check that the artifact name in the per-repo workflow matches `ARTIFACT_NAME` in the merge workflow. Default is `guardlink-report`. + +**PAT permission errors** — Make sure the token has `actions:read` scope and access to all listed repos. For fine-grained tokens, each repo needs explicit "Actions: Read" permission. + +**Empty dashboard** — Check the merge workflow logs. Look for "repos loaded" count — if 0, none of the artifact downloads succeeded. Verify repo names in `REPOS` match exactly (case-sensitive, include org prefix). diff --git a/examples/ci/per-repo-report.yml b/examples/ci/per-repo-report.yml new file mode 100644 index 0000000..643ae64 --- /dev/null +++ b/examples/ci/per-repo-report.yml @@ -0,0 +1,95 @@ +# GuardLink CI — Per-Repo Report Generation +# +# Copy to each service repo's .github/workflows/guardlink.yml +# +# What it does: +# 1. Validates annotations (syntax errors, dangling refs, duplicate IDs) +# 2. Generates report JSON for workspace merge +# 3. Shows threat model diff on PRs +# 4. Uploads SARIF to GitHub Security tab +# 5. Uploads report JSON as artifact (consumed by workspace merge workflow) +# +# The report artifact is named "guardlink-report" and retained for 30 days. +# The workspace merge workflow downloads these artifacts from all repos. + +name: GuardLink + +on: + pull_request: + branches: [main, master] + push: + branches: [main, master] + +permissions: + contents: read + pull-requests: write + security-events: write + +jobs: + guardlink: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + with: + fetch-depth: 0 + + - uses: actions/setup-node@v4 + with: + node-version: '20' + + - name: Install GuardLink + run: npm install -g guardlink + + # ── Validate ─────────────────────────────────────────── + - name: Validate annotations + run: guardlink validate . + + # ── Generate report JSON (push to main only) ─────────── + - name: Generate report JSON + if: github.event_name == 'push' + run: guardlink report --format json -o guardlink-report.json + + - name: Upload report artifact + if: github.event_name == 'push' + uses: actions/upload-artifact@v4 + with: + name: guardlink-report + path: guardlink-report.json + retention-days: 30 + + # ── PR diff ──────────────────────────────────────────── + - name: Threat model diff + if: github.event_name == 'pull_request' + id: diff + run: | + DIFF=$(guardlink diff --from origin/${{ github.base_ref }} --to HEAD 2>&1) || true + echo "diff<> $GITHUB_OUTPUT + echo "$DIFF" >> $GITHUB_OUTPUT + echo "EOF" >> $GITHUB_OUTPUT + + - name: Post diff comment + if: github.event_name == 'pull_request' && steps.diff.outputs.diff != '' + uses: actions/github-script@v7 + with: + script: | + const diff = `${{ steps.diff.outputs.diff }}`; + if (!diff.trim()) return; + await github.rest.issues.createComment({ + owner: context.repo.owner, + repo: context.repo.repo, + issue_number: context.issue.number, + body: `## 🛡️ GuardLink Threat Model Diff\n\n\`\`\`\n${diff}\n\`\`\``, + }); + + # ── SARIF ────────────────────────────────────────────── + - name: Export SARIF + if: always() + run: guardlink sarif . -o guardlink.sarif + continue-on-error: true + + - name: Upload SARIF + if: always() + uses: github/codeql-action/upload-sarif@v3 + with: + sarif_file: guardlink.sarif + continue-on-error: true diff --git a/examples/ci/workspace-merge.yml b/examples/ci/workspace-merge.yml new file mode 100644 index 0000000..3f821a7 --- /dev/null +++ b/examples/ci/workspace-merge.yml @@ -0,0 +1,182 @@ +# GuardLink CI — Workspace Merge (Weekly Threat Model Roll-Up) +# +# Place in a central repo (e.g. "infra", "security", or a dedicated +# "threat-model" repo). Downloads per-repo report artifacts from all +# workspace repos and merges them into a unified dashboard. +# +# Prerequisites: +# 1. Each service repo runs per-repo-report.yml (uploads guardlink-report artifact) +# 2. A GitHub PAT with `actions:read` scope on all workspace repos, +# stored as the GUARDLINK_PAT secret in this repo +# 3. Update the REPOS list below with your actual org/repo names +# +# What it does: +# 1. Downloads the latest report JSON artifact from each workspace repo +# 2. Merges all reports into a unified threat model +# 3. Compares against last week's merge (--diff-against) +# 4. Generates a workspace dashboard (HTML) +# 5. Uploads merged JSON + dashboard + weekly diff as artifacts +# 6. (Optional) Deploys dashboard to GitHub Pages +# 7. (Optional) Posts summary to Slack + +name: GuardLink Workspace Merge + +on: + schedule: + - cron: '0 9 * * 1' # Every Monday at 9:00 UTC + workflow_dispatch: + inputs: + skip_diff: + description: 'Skip diff against previous merge' + type: boolean + default: false + +permissions: + contents: write # needed for committing merged report + pages: write # needed for GitHub Pages deployment (optional) + actions: read + +# ── Configure your workspace repos here ────────────────────── +env: + # Space-separated list of org/repo names in your workspace + REPOS: "your-org/payment-service your-org/auth-service your-org/api-gateway" + # Workspace name (shown in dashboard header) + WORKSPACE_NAME: "your-workspace" + # Artifact name uploaded by per-repo-report.yml + ARTIFACT_NAME: "guardlink-report" + +jobs: + merge: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + with: + fetch-depth: 2 # need previous commit for diff + + - uses: actions/setup-node@v4 + with: + node-version: '20' + + - name: Install GuardLink + run: npm install -g guardlink + + # ── Download report artifacts from all repos ─────────── + - name: Download reports + env: + GH_TOKEN: ${{ secrets.GUARDLINK_PAT }} + run: | + mkdir -p reports + for REPO in $REPOS; do + REPO_NAME=$(echo "$REPO" | cut -d'/' -f2) + echo "⏳ Fetching report from $REPO..." + + # Get the latest successful workflow run on main/master + RUN_ID=$(gh api \ + "repos/$REPO/actions/runs?branch=main&status=success&per_page=5" \ + --jq '.workflow_runs[0].id' 2>/dev/null) + + if [ -z "$RUN_ID" ] || [ "$RUN_ID" = "null" ]; then + # Try master branch + RUN_ID=$(gh api \ + "repos/$REPO/actions/runs?branch=master&status=success&per_page=5" \ + --jq '.workflow_runs[0].id' 2>/dev/null) + fi + + if [ -z "$RUN_ID" ] || [ "$RUN_ID" = "null" ]; then + echo " ⚠ No successful run found for $REPO — skipping" + continue + fi + + # Find the report artifact in that run + ARTIFACT_ID=$(gh api \ + "repos/$REPO/actions/runs/$RUN_ID/artifacts" \ + --jq ".artifacts[] | select(.name==\"$ARTIFACT_NAME\") | .id" 2>/dev/null) + + if [ -z "$ARTIFACT_ID" ] || [ "$ARTIFACT_ID" = "null" ]; then + echo " ⚠ No '$ARTIFACT_NAME' artifact in run $RUN_ID for $REPO — skipping" + continue + fi + + # Download and extract + gh api "repos/$REPO/actions/artifacts/$ARTIFACT_ID/zip" > "/tmp/$REPO_NAME.zip" + unzip -o "/tmp/$REPO_NAME.zip" -d "/tmp/$REPO_NAME" + cp /tmp/$REPO_NAME/guardlink-report.json "reports/$REPO_NAME.json" + echo " ✓ Downloaded report for $REPO_NAME" + done + + REPORT_COUNT=$(ls reports/*.json 2>/dev/null | wc -l | tr -d ' ') + echo "📦 Downloaded $REPORT_COUNT report(s)" + + if [ "$REPORT_COUNT" -eq 0 ]; then + echo "::error::No reports downloaded. Check GUARDLINK_PAT permissions and repo names." + exit 1 + fi + + # ── Merge reports ──────────────────────────────────────── + - name: Merge workspace reports + run: | + MERGE_ARGS="reports/*.json --workspace $WORKSPACE_NAME" + MERGE_ARGS="$MERGE_ARGS --json merged/workspace-report.json" + MERGE_ARGS="$MERGE_ARGS -o merged/workspace-dashboard.html" + + # Diff against previous merge if available + if [ "${{ inputs.skip_diff }}" != "true" ] && [ -f previous/workspace-report.json ]; then + MERGE_ARGS="$MERGE_ARGS --diff-against previous/workspace-report.json" + fi + + mkdir -p merged + guardlink merge $MERGE_ARGS + + # ── Preserve current merge for next week's diff ──────── + - name: Archive current merge as baseline + run: | + mkdir -p previous + cp merged/workspace-report.json previous/workspace-report.json + + - name: Commit baseline for next run + run: | + git config user.name "guardlink-bot" + git config user.email "guardlink-bot@users.noreply.github.com" + git add previous/workspace-report.json + git diff --cached --quiet || git commit -m "chore: update workspace merge baseline [skip ci]" + git push || echo "⚠ Could not push baseline — next diff will be skipped" + + # ── Upload results ───────────────────────────────────── + - name: Upload merged artifacts + uses: actions/upload-artifact@v4 + with: + name: guardlink-workspace-merge + path: | + merged/workspace-report.json + merged/workspace-dashboard.html + merged/*-weekly-diff.md + retention-days: 90 + + # ── (Optional) Deploy dashboard to GitHub Pages ──────── + # Uncomment the block below to publish the dashboard. + # Requires: Settings → Pages → Source = "GitHub Actions" + # + # - name: Setup Pages + # uses: actions/configure-pages@v4 + # + # - name: Upload Pages artifact + # uses: actions/upload-pages-artifact@v3 + # with: + # path: merged/ + # + # - name: Deploy to GitHub Pages + # uses: actions/deploy-pages@v4 + + # ── (Optional) Post summary to Slack ─────────────────── + # Uncomment and set SLACK_WEBHOOK_URL secret. + # + # - name: Post to Slack + # if: always() + # env: + # SLACK_WEBHOOK_URL: ${{ secrets.SLACK_WEBHOOK_URL }} + # run: | + # if [ -z "$SLACK_WEBHOOK_URL" ]; then exit 0; fi + # SUMMARY=$(cat merged/*-weekly-diff.md 2>/dev/null || echo "Workspace merge completed — see artifacts for details.") + # curl -X POST "$SLACK_WEBHOOK_URL" \ + # -H 'Content-Type: application/json' \ + # -d "{\"text\": \"🛡️ *GuardLink Weekly Report*\n\n\`\`\`${SUMMARY:0:2000}\`\`\`\"}" diff --git a/examples/github-action.yml b/examples/github-action.yml index ad0a255..dc70ecf 100644 --- a/examples/github-action.yml +++ b/examples/github-action.yml @@ -5,6 +5,9 @@ # 2. Shows threat model diff on PRs (what security posture changed) # 3. Uploads unmitigated exposures to GitHub Security tab via SARIF # 4. Posts a coverage summary as a PR comment +# +# For multi-repo workspaces, see examples/ci/ which adds report JSON +# generation and a weekly workspace merge workflow. name: GuardLink diff --git a/package.json b/package.json index a63cc36..fe73d4f 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "guardlink", - "version": "1.3.0", + "version": "1.4.0", "description": "GuardLink — Security annotations for code. Threat modeling that lives in your codebase.", "type": "module", "bin": { diff --git a/src/cli/index.ts b/src/cli/index.ts index b3cbe5c..a737642 100644 --- a/src/cli/index.ts +++ b/src/cli/index.ts @@ -19,6 +19,8 @@ * guardlink mcp Start MCP server (stdio) for Claude Code, Cursor, etc. * guardlink tui [dir] Interactive TUI with slash commands + AI chat * guardlink gal Display GAL annotation language quick reference + * guardlink link-project Link repos into a shared workspace + * guardlink merge Merge repo reports into unified dashboard * * @exposes #cli to #path-traversal [high] cwe:CWE-22 -- "User-supplied dir argument resolved via path.resolve" * @mitigates #cli against #path-traversal using #path-validation -- "resolve() canonicalizes paths; cwd-relative by design" @@ -48,6 +50,8 @@ import { generateDashboardHTML } from '../dashboard/index.js'; import { AGENTS, agentFromOpts, launchAgent, launchAgentInline, buildAnnotatePrompt } from '../agents/index.js'; import { resolveConfig, saveProjectConfig, saveGlobalConfig, loadProjectConfig, loadGlobalConfig, maskKey, describeConfigSource } from '../agents/config.js'; import { getReviewableExposures, applyReviewAction, formatExposureForReview, summarizeReview, type ReviewResult } from '../review/index.js'; +import { populateMetadata, mergeReports, formatMergeSummary, diffMergedReports, formatDiffSummary, linkProject, addToWorkspace, removeFromWorkspace } from '../workspace/index.js'; +import type { MergedReport, LinkResult } from '../workspace/index.js'; import type { ThreatModel, ParseDiagnostic } from '../types/index.js'; import gradient from 'gradient-string'; @@ -299,10 +303,11 @@ program .description('Generate a threat model report with Mermaid diagram') .argument('[dir]', 'Project directory to scan', '.') .option('-p, --project ', 'Project name', 'unknown') - .option('-o, --output ', 'Write report to file (default: threat-model.md)') + .option('-o, --output ', 'Write report to file') + .option('-f, --format ', 'Output format: md, json, or both (default: md)', 'md') .option('--diagram-only', 'Output only the Mermaid diagram, no report wrapper') - .option('--json', 'Also output threat-model.json alongside the report') - .action(async (dir: string, opts: { project: string; output?: string; diagramOnly?: boolean; json?: boolean }) => { + .option('--json', 'Also output threat-model.json alongside the report (legacy; prefer --format)') + .action(async (dir: string, opts: { project: string; output?: string; format: string; diagramOnly?: boolean; json?: boolean }) => { const root = resolve(dir); const { model, diagnostics } = await parseProject({ root, project: opts.project }); @@ -313,9 +318,12 @@ program console.error(`Fix errors above before generating report.\n`); } + // Enrich with provenance metadata (git SHA, branch, workspace, schema version) + const enrichedModel = populateMetadata(model, root); + if (opts.diagramOnly) { // Just output Mermaid - const mermaid = generateMermaid(model); + const mermaid = generateMermaid(enrichedModel); if (opts.output) { const { writeFile } = await import('node:fs/promises'); await writeFile(opts.output, mermaid + '\n'); @@ -323,19 +331,29 @@ program } else { console.log(mermaid); } - } else { - // Full report - const report = generateReport(model); - const outFile = opts.output || 'threat-model.md'; - const { writeFile } = await import('node:fs/promises'); - await writeFile(resolve(root, outFile), report + '\n'); - console.error(`✓ Wrote threat model report to ${outFile}`); + return; + } - if (opts.json) { - const jsonFile = outFile.replace(/\.md$/, '.json'); - await writeFile(resolve(root, jsonFile), JSON.stringify(model, null, 2) + '\n'); - console.error(`✓ Wrote threat model JSON to ${jsonFile}`); - } + const { writeFile } = await import('node:fs/promises'); + const wantJson = opts.format === 'json' || opts.format === 'both' || opts.json; + const wantMd = opts.format === 'md' || opts.format === 'both' || opts.json; + + if (wantMd) { + const report = generateReport(enrichedModel); + const mdFile = opts.output || (opts.format === 'md' ? 'threat-model.md' : 'threat-model.md'); + await writeFile(resolve(root, mdFile), report + '\n'); + console.error(`✓ Wrote threat model report to ${mdFile}`); + } + + if (wantJson) { + const jsonFile = opts.output && opts.format === 'json' + ? opts.output + : (opts.output || 'threat-model').replace(/\.md$/, '') + '.json'; + await writeFile( + resolve(root, jsonFile), + JSON.stringify(enrichedModel, null, 2) + '\n', + ); + console.error(`✓ Wrote threat model JSON to ${jsonFile} (schema v${enrichedModel.metadata?.schema_version})`); } }); @@ -1104,6 +1122,247 @@ program console.error(` Open in browser to view. Toggle ☀️/🌙 for light/dark mode.`); }); +// ─── link-project ──────────────────────────────────────────────────── + +program + .command('link-project') + .description('Link repos into a shared workspace for cross-repo threat modeling') + .argument('[repos...]', 'Repo directories to link (fresh setup: 2+ paths)') + .option('-w, --workspace ', 'Workspace name (fresh link only)', 'workspace') + .option('-r, --registry ', 'GitHub/GitLab org base URL (e.g. github.com/unstructured)') + .option('--add ', 'Add a new repo to an existing workspace (provide path to new repo)') + .option('--remove ', 'Remove a repo from the workspace by name') + .option('--from ', 'Existing workspace repo to read config from (used with --add or --remove)') + .action(async (repos: string[], opts: { workspace: string; registry?: string; add?: string; remove?: string; from?: string }) => { + let result: LinkResult; + + if (opts.remove) { + // ── Remove mode: remove a repo from an existing workspace ── + if (!opts.from) { + console.error('⚠ --remove requires --from '); + console.error(' Example: guardlink link-project --remove payment-svc --from ./api-gateway'); + process.exit(1); + } + + console.error(`Removing "${opts.remove}" from workspace...`); + console.error(` Reference repo: ${opts.from}`); + console.error(''); + + result = removeFromWorkspace({ + repoName: opts.remove, + existingRepoPath: opts.from, + }); + + for (const name of result.updated) { + console.error(` ↻ ${name} — workspace.yaml updated`); + } + for (const f of result.agentFilesUpdated) { + if (f.includes('(cleaned)')) console.error(` 🧹 ${f}`); + } + for (const s of result.skipped) { + console.error(` ✗ ${s.name} — ${s.reason}`); + } + console.error(''); + + if (result.updated.length > 0) { + console.error(`✓ Removed "${opts.remove}", updated ${result.updated.length} remaining repo(s)`); + console.error(''); + console.error('Next steps:'); + console.error(' 1. Review and commit changes in each updated repo'); + } else if (result.skipped.length > 0) { + process.exit(1); + } + } else if (opts.add) { + // ── Add mode: add a new repo to an existing workspace ── + if (!opts.from) { + console.error('⚠ --add requires --from '); + console.error(' Example: guardlink link-project --add ./new-service --from ./api-gateway'); + process.exit(1); + } + + console.error(`Adding repo to existing workspace...`); + console.error(` New repo: ${opts.add}`); + console.error(` From workspace: ${opts.from}`); + console.error(''); + + result = addToWorkspace({ + newRepoPath: opts.add, + existingRepoPath: opts.from, + registry: opts.registry, + }); + + // Report results + for (const name of result.initialized) { + console.error(` ⚡ ${name} — auto-initialized (no prior guardlink setup)`); + } + for (const name of result.linked) { + console.error(` ✓ ${name} — linked to workspace`); + } + for (const name of result.updated) { + console.error(` ↻ ${name} — workspace.yaml updated`); + } + for (const s of result.skipped) { + console.error(` ✗ ${s.name} — skipped: ${s.reason}`); + } + console.error(''); + + if (result.linked.length > 0 || result.updated.length > 0) { + const total = result.linked.length + result.updated.length; + console.error(`✓ ${result.linked.length} repo(s) added, ${result.updated.length} existing repo(s) updated`); + if (result.agentFilesUpdated.length > 0) { + console.error(` ↻ Updated ${result.agentFilesUpdated.length} agent instruction file(s)`); + } + // Warn about repos not found on disk + // (they're in workspace.yaml but we couldn't locate their directory) + console.error(''); + console.error('Next steps:'); + console.error(' 1. Review and commit changes in each updated repo'); + console.error(' 2. Add "guardlink report --format json" to the new repo\'s CI'); + console.error(' 3. Run "guardlink merge" with all repo reports'); + } else { + console.error('✗ No repos were added. Check the paths provided.'); + process.exit(1); + } + } else { + // ── Fresh link mode: link N repos together ── + if (repos.length < 2) { + console.error('⚠ Fresh linking requires at least 2 repo paths.'); + console.error(' To add a repo to an existing workspace, use:'); + console.error(' guardlink link-project --add --from '); + process.exit(1); + } + + console.error(`Linking ${repos.length} repos into workspace "${opts.workspace}"...`); + console.error(''); + + result = linkProject({ + workspace: opts.workspace, + repoPaths: repos, + registry: opts.registry, + }); + + for (const name of result.initialized) { + console.error(` ⚡ ${name} — auto-initialized (no prior guardlink setup)`); + } + for (const name of result.linked) { + console.error(` ✓ ${name} — workspace.yaml written, agent files updated`); + } + for (const s of result.skipped) { + console.error(` ✗ ${s.name} — skipped: ${s.reason}`); + } + console.error(''); + + if (result.linked.length > 0) { + console.error(`✓ Linked ${result.linked.length} repo(s) into "${opts.workspace}"`); + if (result.agentFilesUpdated.length > 0) { + console.error(` ↻ Updated ${result.agentFilesUpdated.length} agent instruction file(s)`); + } + console.error(''); + console.error('Next steps:'); + console.error(' 1. Review and commit .guardlink/workspace.yaml in each repo'); + console.error(' 2. Add "guardlink report --format json -o guardlink-report.json" to each repo\'s CI'); + console.error(' 3. Use "guardlink merge" to combine reports into a unified dashboard'); + } else { + console.error('✗ No repos were linked. Check the paths provided.'); + process.exit(1); + } + } + }); + +// ─── merge ─────────────────────────────────────────────────────────── + +program + .command('merge') + .description('Merge multiple repo report JSONs into a unified workspace threat model') + .argument('', 'Report JSON file paths (glob supported)') + .option('-o, --output ', 'Output file for merged dashboard HTML (default: workspace-dashboard.html)') + .option('--json ', 'Also write merged report JSON to this file') + .option('--diff-against ', 'Compare against a previous merged JSON for weekly summary') + .option('-w, --workspace ', 'Workspace name (auto-detected from reports if not set)') + .option('--summary-only', 'Print only the text summary, skip dashboard generation') + .action(async ( + files: string[], + opts: { output?: string; json?: string; diffAgainst?: string; workspace?: string; summaryOnly?: boolean }, + ) => { + const { writeFile, readFile } = await import('node:fs/promises'); + const { resolve: resolvePath } = await import('node:path'); + + // Resolve file paths (support globs via shell expansion — files already expanded by shell) + const resolvedFiles = files.map(f => resolvePath(f)); + + if (resolvedFiles.length === 0) { + console.error('✗ No report files provided.'); + process.exit(1); + } + + console.error(`Merging ${resolvedFiles.length} report(s)...`); + + // Run merge + const merged = await mergeReports(resolvedFiles, { + workspace: opts.workspace, + }); + + const t = merged.totals; + + // Print summary to stderr + console.error(''); + console.error(`✓ ${merged.workspace} — ${t.repos_loaded}/${t.repos} repos loaded`); + console.error(` ${t.annotations} annotations | ${t.assets} assets | ${t.threats} threats | ${t.controls} controls`); + console.error(` ${t.mitigations} mitigations | ${t.exposures} exposures | ${t.unmitigated_exposures} unmitigated`); + console.error(` ${t.flows} flows | ${t.external_refs_resolved} refs resolved | ${t.external_refs_unresolved} unresolved`); + + // Print warnings + for (const w of merged.warnings) { + const icon = w.level === 'error' ? '✗' : w.level === 'warning' ? '⚠' : 'ℹ'; + console.error(` ${icon} ${w.message}`); + } + console.error(''); + + // Write merged JSON + if (opts.json) { + const jsonPath = resolvePath(opts.json); + await writeFile(jsonPath, JSON.stringify(merged, null, 2) + '\n'); + console.error(`✓ Wrote merged JSON to ${opts.json}`); + } + + // Diff against previous + if (opts.diffAgainst) { + try { + const prevRaw = await readFile(resolvePath(opts.diffAgainst), 'utf-8'); + const previous: MergedReport = JSON.parse(prevRaw); + const diff = diffMergedReports(merged, previous); + const diffMd = formatDiffSummary(diff, merged.workspace); + + // Write diff markdown + const diffFile = (opts.json || 'workspace-merge').replace(/\.json$/, '') + '-weekly-diff.md'; + await writeFile(resolvePath(diffFile), diffMd + '\n'); + console.error(`✓ Wrote weekly diff to ${diffFile}`); + + // Also print a compact version to stderr + const riskIcon = diff.risk_delta === 'increased' ? '🔴' + : diff.risk_delta === 'decreased' ? '🟢' : '⚪'; + console.error(` ${riskIcon} Risk ${diff.risk_delta} since last merge`); + if (diff.new_unmitigated > 0) console.error(` 🔴 +${diff.new_unmitigated} new unmitigated exposure(s)`); + if (diff.resolved_unmitigated > 0) console.error(` 🟢 ${diff.resolved_unmitigated} exposure(s) now mitigated`); + if (diff.mitigations_removed > 0) console.error(` ⚠️ ${diff.mitigations_removed} mitigation(s) removed`); + console.error(''); + } catch (err) { + console.error(`⚠ Could not diff against ${opts.diffAgainst}: ${err instanceof Error ? err.message : err}`); + } + } + + // Generate dashboard HTML (unless --summary-only) + if (!opts.summaryOnly) { + const html = generateDashboardHTML(merged.model); + const outFile = opts.output || 'workspace-dashboard.html'; + await writeFile(resolvePath(outFile), html); + console.error(`✓ Wrote workspace dashboard to ${outFile}`); + } + + // Print full markdown summary to stdout (pipeable) + console.log(formatMergeSummary(merged)); + }); + // ─── mcp ───────────────────────────────────────────────────────────── program diff --git a/src/index.ts b/src/index.ts index a23c81a..08c9c05 100644 --- a/src/index.ts +++ b/src/index.ts @@ -18,3 +18,5 @@ export { diffModels, formatDiff, formatDiffMarkdown, parseAtRef } from './diff/i export type { ThreatModelDiff, DiffSummary, Change, ChangeKind } from './diff/index.js'; export { generateSarif } from './analyzer/index.js'; export type { SarifOptions } from './analyzer/index.js'; +export { populateMetadata, loadWorkspaceConfig, REPORT_SCHEMA_VERSION, mergeReports, formatMergeSummary, diffMergedReports, formatDiffSummary } from './workspace/index.js'; +export type { WorkspaceConfig, WorkspaceRepo, MergedReport, MergeTotals, MergeDiffSummary, MergeOptions } from './workspace/index.js'; diff --git a/src/mcp/server.ts b/src/mcp/server.ts index fc826b3..72b8921 100644 --- a/src/mcp/server.ts +++ b/src/mcp/server.ts @@ -14,6 +14,7 @@ * guardlink_sarif — Export SARIF 2.1.0 * guardlink_diff — Compare threat model against a git ref * guardlink_threat_reports — List saved AI threat report files + * guardlink_workspace_info — Workspace config, siblings, tag prefixes * * Resources: * guardlink://model — Full ThreatModel JSON @@ -54,6 +55,7 @@ import { suggestAnnotations } from './suggest.js'; import { generateThreatReport, listThreatReports, loadThreatReportsForDashboard, buildConfig, serializeModel, serializeModelCompact, FRAMEWORK_LABELS, FRAMEWORK_PROMPTS, buildUserMessage, type AnalysisFramework } from '../analyze/index.js'; import { buildAnnotatePrompt } from '../agents/prompts.js'; import { syncAgentFiles } from '../init/index.js'; +import { loadWorkspaceConfig } from '../workspace/index.js'; import type { ThreatModel } from '../types/index.js'; // ─── Cached model ──────────────────────────────────────────────────── @@ -83,7 +85,7 @@ function invalidateCache() { export function createServer(): McpServer { const server = new McpServer({ name: 'guardlink', - version: '1.3.0', + version: '1.4.0', }); // ── Tool: guardlink_parse ── @@ -591,6 +593,50 @@ export function createServer(): McpServer { }, ); + // ── Tool: guardlink_workspace_info ── + server.tool( + 'guardlink_workspace_info', + 'Get workspace configuration for multi-repo threat modeling. Returns workspace name, this repo\'s identity, sibling repos, and their tag prefixes. Use this to understand cross-repo references when writing annotations. Returns null fields if the repo is not part of a workspace.', + { + root: z.string().describe('Project root directory').default('.'), + }, + async ({ root }) => { + const config = loadWorkspaceConfig(root); + + if (!config) { + return { + content: [{ type: 'text', text: JSON.stringify({ + workspace: null, + message: 'This repo is not part of a workspace. Use "guardlink link-project" to create one.', + }, null, 2) }], + }; + } + + const siblings = config.repos.filter(r => r.name !== config.this_repo); + + return { + content: [{ type: 'text', text: JSON.stringify({ + workspace: config.workspace, + this_repo: config.this_repo, + tag_prefix: `#${config.this_repo}.`, + siblings: siblings.map(r => ({ + name: r.name, + tag_prefix: `#${r.name}.`, + registry: r.registry || null, + })), + total_repos: config.repos.length, + cross_repo_annotation_rules: [ + `Use #${config.this_repo}. for assets defined in this repo`, + `Reference sibling assets/threats/controls by their tag prefix (e.g. #${siblings[0]?.name || 'sibling'}.)`, + 'Do not redefine assets that belong to another repo — reference by tag', + 'Cross-repo @flows are encouraged: @flows #data from #this.component to #sibling.endpoint', + 'External refs resolve during workspace merge, not local validation', + ], + }, null, 2) }], + }; + }, + ); + // ── Resource: guardlink://model ── server.resource( 'threat-model', diff --git a/src/parser/parse-project.ts b/src/parser/parse-project.ts index 8835768..b891378 100644 --- a/src/parser/parse-project.ts +++ b/src/parser/parse-project.ts @@ -22,8 +22,10 @@ import type { HandlesAnnotation, AssumesAnnotation, ShieldAnnotation, CommentAnnotation, DataClassification, + ExternalRef, AnnotationVerb, SourceLocation, } from '../types/index.js'; import { parseFile } from './parse-file.js'; +import { loadWorkspaceConfig } from '../workspace/index.js'; export interface ParseProjectOptions { /** Root directory to scan */ @@ -130,6 +132,9 @@ export async function parseProject(options: ParseProjectOptions): Promise<{ // Assemble ThreatModel const model = assembleModel(allAnnotations, files.length, project, annotatedFiles, unannotatedFiles); + // Detect cross-repo tag references (requires workspace.yaml) + model.external_refs = detectExternalRefs(model, root); + return { model, diagnostics: allDiagnostics }; } @@ -330,5 +335,103 @@ function assembleModel(annotations: Annotation[], fileCount: number, project: st } } + // Detect external (cross-repo) tag references + // (moved to parseProject where root is available) + return model; } + +// ─── External ref detection ────────────────────────────────────────── + +/** + * Detect tag references that point to definitions in sibling repos. + * + * A tag like `#auth-lib.token-verify` is external if: + * - It contains a dot separator + * - The prefix before the first dot matches a sibling repo name + * - The tag is not defined locally (not in this repo's assets/threats/controls) + * + * Requires workspace.yaml to be present — returns [] if not in a workspace. + */ +function detectExternalRefs(model: ThreatModel, root: string): ExternalRef[] { + const config = loadWorkspaceConfig(root); + if (!config) return []; + + // Sibling repo names (exclude this repo) + const siblingNames = new Set( + config.repos + .filter(r => r.name !== config.this_repo) + .map(r => r.name), + ); + if (siblingNames.size === 0) return []; + + // Local definitions — both with and without # prefix + const localIds = new Set(); + for (const a of model.assets) { + if (a.id) { localIds.add(a.id); localIds.add(`#${a.id}`); } + } + for (const t of model.threats) { + if (t.id) { localIds.add(t.id); localIds.add(`#${t.id}`); } + } + for (const c of model.controls) { + if (c.id) { localIds.add(c.id); localIds.add(`#${c.id}`); } + } + + const refs: ExternalRef[] = []; + const seen = new Set(); // dedup by tag+file+line + + function checkTag(tag: string | undefined, verb: AnnotationVerb, location: SourceLocation) { + if (!tag) return; + const key = `${tag}:${location.file}:${location.line}`; + if (seen.has(key)) return; + + // Skip if locally defined + if (localIds.has(tag)) return; + + // Strip leading # for prefix extraction + const bare = tag.startsWith('#') ? tag.slice(1) : tag; + const dotIdx = bare.indexOf('.'); + if (dotIdx < 1) return; // no dot or starts with dot — not a repo prefix + + const prefix = bare.slice(0, dotIdx); + if (!siblingNames.has(prefix)) return; + + seen.add(key); + refs.push({ + tag, + context_verb: verb, + location, + inferred_repo: prefix, + }); + } + + // Scan all relationship annotations for cross-repo tags + for (const m of model.mitigations) { + checkTag(m.asset, 'mitigates', m.location); + checkTag(m.threat, 'mitigates', m.location); + if (m.control) checkTag(m.control, 'mitigates', m.location); + } + for (const e of model.exposures) { + checkTag(e.asset, 'exposes', e.location); + checkTag(e.threat, 'exposes', e.location); + } + for (const a of model.acceptances) { + checkTag(a.asset, 'accepts', a.location); + checkTag(a.threat, 'accepts', a.location); + } + for (const t of model.transfers) { + checkTag(t.source, 'transfers', t.location); + checkTag(t.target, 'transfers', t.location); + checkTag(t.threat, 'transfers', t.location); + } + for (const f of model.flows) { + checkTag(f.source, 'flows', f.location); + checkTag(f.target, 'flows', f.location); + } + for (const b of model.boundaries) { + checkTag(b.asset_a, 'boundary', b.location); + checkTag(b.asset_b, 'boundary', b.location); + } + + return refs; +} diff --git a/src/tui/commands.ts b/src/tui/commands.ts index e63343f..2b5b6cf 100644 --- a/src/tui/commands.ts +++ b/src/tui/commands.ts @@ -37,6 +37,8 @@ import { resolveLLMConfig, saveTuiConfig, loadTuiConfig } from './config.js'; import { AGENTS, parseAgentFlag, launchAgent, launchAgentInline, copyToClipboard, buildAnnotatePrompt, type AgentEntry } from '../agents/index.js'; import { describeConfigSource } from '../agents/config.js'; import { getReviewableExposures, applyReviewAction, formatExposureForReview, summarizeReview, type ReviewResult } from '../review/index.js'; +import { loadWorkspaceConfig, linkProject, addToWorkspace, removeFromWorkspace, mergeReports, formatMergeSummary, diffMergedReports, formatDiffSummary, populateMetadata } from '../workspace/index.js'; +import type { MergedReport } from '../workspace/index.js'; // ─── Shared context ────────────────────────────────────────────────── @@ -123,6 +125,10 @@ export function cmdHelp(): void { ['/diff [ref]', 'Compare model against a git ref (default: HEAD~1)'], ['/sarif [-o file]', 'Export SARIF 2.1.0 for GitHub / VS Code'], ['', ''], + ['/workspace', 'Show workspace config and linked repos'], + ['/link ', 'Link repos into a workspace (--add / --remove)'], + ['/merge ', 'Merge report JSONs into unified dashboard'], + ['', ''], ['/gal', 'GAL annotation language guide'], ['/help', 'This help'], ['/quit', 'Exit'], @@ -1832,3 +1838,234 @@ export async function cmdDashboard(ctx: TuiContext): Promise { } console.log(''); } + +// ─── /workspace ────────────────────────────────────────────────────── + +export function cmdWorkspace(ctx: TuiContext): void { + const config = loadWorkspaceConfig(ctx.root); + if (!config) { + console.log(''); + console.log(C.warn(' This repo is not part of a workspace.')); + console.log(C.dim(' Use /link to create one, or guardlink link-project in the CLI.')); + console.log(''); + return; + } + + console.log(''); + console.log(` ${C.bold('Workspace:')} ${config.workspace}`); + console.log(` ${C.bold('This repo:')} ${config.this_repo}`); + console.log(''); + console.log(` ${C.bold('Linked repos')} (${config.repos.length}):`); + for (const r of config.repos) { + const isSelf = r.name === config.this_repo ? C.dim(' (this)') : ''; + const reg = r.registry ? C.dim(` → ${r.registry}`) : ''; + console.log(` ${r.name === config.this_repo ? C.green('●') : C.cyan('○')} ${r.name}${isSelf}${reg}`); + } + console.log(''); + console.log(C.dim(' /merge to combine reports · /link --add to add a repo · /link --remove to remove')); + console.log(''); +} + +// ─── /link ─────────────────────────────────────────────────────────── + +export async function cmdLink(args: string, ctx: TuiContext): Promise { + const parts = args.trim().split(/\s+/).filter(Boolean); + + // Parse flags + let addPath: string | undefined; + let removeName: string | undefined; + let workspace = 'workspace'; + let registry: string | undefined; + const repoPaths: string[] = []; + + for (let i = 0; i < parts.length; i++) { + const p = parts[i]; + if (p === '--add' && parts[i + 1]) { addPath = parts[++i]; } + else if (p === '--remove' && parts[i + 1]) { removeName = parts[++i]; } + else if ((p === '-w' || p === '--workspace') && parts[i + 1]) { workspace = parts[++i]; } + else if ((p === '-r' || p === '--registry') && parts[i + 1]) { registry = parts[++i]; } + else { repoPaths.push(p); } + } + + if (removeName) { + // ── Remove mode ── + console.log(C.dim(` Removing "${removeName}" from workspace...`)); + const result = removeFromWorkspace({ + repoName: removeName, + existingRepoPath: ctx.root, + }); + + for (const name of result.updated) console.log(` ${C.green('↻')} ${name} — updated`); + for (const f of result.agentFilesUpdated) { + if (f.includes('(cleaned)')) console.log(` ${C.dim('🧹')} ${f}`); + } + for (const s of result.skipped) console.log(` ${C.warn('✗')} ${s.name} — ${s.reason}`); + + if (result.updated.length > 0) { + console.log(''); + console.log(C.success(` ✓ Removed "${removeName}", updated ${result.updated.length} repo(s)`)); + } + + } else if (addPath) { + // ── Add mode (--from is implicit: ctx.root) ── + console.log(C.dim(` Adding ${addPath} to workspace...`)); + const result = addToWorkspace({ + newRepoPath: resolve(addPath), + existingRepoPath: ctx.root, + registry, + }); + + for (const name of result.initialized) console.log(` ${C.cyan('⚡')} ${name} — auto-initialized`); + for (const name of result.linked) console.log(` ${C.green('✓')} ${name} — linked`); + for (const name of result.updated) console.log(` ${C.green('↻')} ${name} — updated`); + for (const s of result.skipped) console.log(` ${C.warn('✗')} ${s.name} — ${s.reason}`); + + if (result.linked.length > 0 || result.updated.length > 0) { + console.log(''); + console.log(C.success(` ✓ ${result.linked.length} added, ${result.updated.length} updated`)); + } + + } else if (repoPaths.length >= 2) { + // ── Fresh link mode ── + console.log(C.dim(` Linking ${repoPaths.length} repos into "${workspace}"...`)); + const result = linkProject({ + workspace, + repoPaths: repoPaths.map(p => resolve(p)), + registry, + }); + + for (const name of result.initialized) console.log(` ${C.cyan('⚡')} ${name} — auto-initialized`); + for (const name of result.linked) console.log(` ${C.green('✓')} ${name} — linked`); + for (const s of result.skipped) console.log(` ${C.warn('✗')} ${s.name} — ${s.reason}`); + + if (result.linked.length > 0) { + console.log(''); + console.log(C.success(` ✓ Linked ${result.linked.length} repo(s) into "${workspace}"`)); + } + + } else { + console.log(''); + console.log(` ${C.bold('Usage:')}`); + console.log(` /link ... ${C.dim('Fresh workspace setup')}`); + console.log(` /link --add ${C.dim('Add a repo (uses current repo as reference)')}`); + console.log(` /link --remove ${C.dim('Remove a repo by name')}`); + console.log(` /link -w -r ... ${C.dim('Set workspace name and registry')}`); + } + console.log(''); +} + +// ─── /merge ────────────────────────────────────────────────────────── + +export async function cmdMerge(args: string, ctx: TuiContext): Promise { + const parts = args.trim().split(/\s+/).filter(Boolean); + + // Parse flags + let outputFile: string | undefined; + let jsonFile: string | undefined; + let diffAgainst: string | undefined; + let workspaceName: string | undefined; + const files: string[] = []; + + for (let i = 0; i < parts.length; i++) { + const p = parts[i]; + if ((p === '-o' || p === '--output') && parts[i + 1]) { outputFile = parts[++i]; } + else if (p === '--json' && parts[i + 1]) { jsonFile = parts[++i]; } + else if (p === '--diff-against' && parts[i + 1]) { diffAgainst = parts[++i]; } + else if ((p === '-w' || p === '--workspace') && parts[i + 1]) { workspaceName = parts[++i]; } + else { files.push(resolve(p)); } + } + + if (files.length === 0) { + console.log(''); + console.log(` ${C.bold('Usage:')}`); + console.log(` /merge ...`); + console.log(` /merge *.json -o dashboard.html --json merged.json`); + console.log(` /merge *.json --diff-against last-week.json`); + console.log(''); + return; + } + + console.log(C.dim(` Merging ${files.length} report(s)...`)); + + // Load and merge + const reportJsons: any[] = []; + for (const f of files) { + if (!existsSync(f)) { + console.log(C.warn(` ✗ File not found: ${f}`)); + continue; + } + try { + reportJsons.push(JSON.parse(readFileSync(f, 'utf-8'))); + } catch (err) { + console.log(C.warn(` ✗ Invalid JSON: ${f}`)); + } + } + + if (reportJsons.length === 0) { + console.log(C.error(' No valid reports to merge.')); + console.log(''); + return; + } + + const merged = await mergeReports(reportJsons, { workspace: workspaceName }); + + // Summary + const t = merged.totals; + console.log(''); + console.log(` ${C.bold(merged.workspace)} — ${merged.repo_statuses.filter(r => r.loaded).length}/${merged.repo_statuses.length} repos loaded`); + console.log(` ${t.annotations} annotations | ${t.assets} assets | ${t.threats} threats | ${t.controls} controls`); + console.log(` ${t.mitigations} mitigations | ${t.exposures} exposures | ${t.unmitigated_exposures} unmitigated`); + console.log(` ${t.flows} flows | ${t.external_refs_resolved} refs resolved | ${t.external_refs_unresolved} unresolved`); + + // Warnings + for (const w of merged.warnings) { + console.log(` ${C.warn('⚠')} ${w.message}`); + } + + // Write JSON + if (jsonFile) { + writeFileSync(resolve(jsonFile), JSON.stringify(merged, null, 2)); + console.log(` ${C.green('✓')} Wrote merged JSON to ${jsonFile}`); + } + + // Diff + if (diffAgainst && existsSync(resolve(diffAgainst))) { + try { + const previous: MergedReport = JSON.parse(readFileSync(resolve(diffAgainst), 'utf-8')); + const diff = diffMergedReports(merged, previous); + + if (diff.risk_delta === 'decreased') { + console.log(` ${C.green('🟢')} Risk decreased since last merge`); + } else if (diff.risk_delta === 'increased') { + console.log(` ${C.red('🔴')} Risk increased since last merge`); + } else { + console.log(` ${C.dim('⚪')} Risk unchanged`); + } + + if (diff.resolved_unmitigated > 0) { + console.log(` ${C.green('🟢')} ${diff.resolved_unmitigated} exposure(s) now mitigated`); + } + if (diff.new_unmitigated > 0) { + console.log(` ${C.red('🔴')} ${diff.new_unmitigated} new unmitigated exposure(s)`); + } + } catch { + console.log(C.warn(` ✗ Could not parse diff file: ${diffAgainst}`)); + } + } + + // Dashboard + if (outputFile || !jsonFile) { + const dashPath = resolve(outputFile || 'workspace-dashboard.html'); + const html = generateDashboardHTML(merged.model); + writeFileSync(dashPath, html); + console.log(` ${C.green('✓')} Dashboard: ${outputFile || 'workspace-dashboard.html'}`); + } + + // Print summary + const summary = formatMergeSummary(merged); + console.log(''); + for (const line of summary.split('\n').slice(0, 15)) { + console.log(` ${line}`); + } + console.log(''); +} diff --git a/src/tui/index.ts b/src/tui/index.ts index 012adc2..b733d53 100644 --- a/src/tui/index.ts +++ b/src/tui/index.ts @@ -53,6 +53,9 @@ import { cmdReport, cmdDashboard, cmdGal, + cmdWorkspace, + cmdLink, + cmdMerge, } from './commands.js'; // ─── Command registry ──────────────────────────────────────────────── @@ -65,6 +68,7 @@ const COMMANDS = [ '/threat-report', '/threat-reports', '/annotate', '/model', '/clear', '/sync', '/unannotated', '/review', '/report', '/dashboard', + '/workspace', '/link', '/merge', '/quit', ]; @@ -102,6 +106,9 @@ const PALETTE_COMMANDS: CommandEntry[] = [ { command: '/dashboard', label: 'HTML dashboard' }, { command: '/diff', label: 'Compare vs git ref' }, { command: '/sarif', label: 'Export SARIF 2.1.0' }, + { command: '/workspace', label: 'Show workspace config and linked repos' }, + { command: '/link', label: 'Link repos into workspace (--add / --remove)' }, + { command: '/merge', label: 'Merge report JSONs into unified dashboard' }, { command: '/gal', label: 'GAL annotation language guide' }, { command: '/help', label: 'Show all commands' }, { command: '/quit', label: 'Exit GuardLink CLI', aliases: ['/exit', '/q'] }, @@ -313,6 +320,9 @@ function printCommandList(): void { ['/dashboard', 'HTML dashboard'], ['/diff [ref]', 'Compare vs git ref'], ['/sarif', 'Export SARIF'], + ['/workspace', 'Workspace config + linked repos'], + ['/link', 'Link repos (--add / --remove)'], + ['/merge', 'Merge report JSONs'], ['/gal', 'GAL annotation guide'], ['/help', 'Full help'], ['/quit', 'Exit GuardLink CLI'], @@ -374,6 +384,9 @@ async function dispatch(input: string, ctx: TuiContext): Promise { case '/review': await cmdReview(args, ctx); break; case '/report': await cmdReport(ctx); break; case '/dashboard': await cmdDashboard(ctx); break; + case '/workspace': cmdWorkspace(ctx); break; + case '/link': await cmdLink(args, ctx); break; + case '/merge': await cmdMerge(args, ctx); break; default: // Fuzzy match const matches = COMMANDS.filter(c => c.startsWith(cmd)); diff --git a/src/types/index.ts b/src/types/index.ts index 4ffb920..b3b7e8e 100644 --- a/src/types/index.ts +++ b/src/types/index.ts @@ -157,6 +157,47 @@ export type Annotation = | CommentAnnotation | ShieldAnnotation; +// ─── Report Metadata ───────────────────────────────────────────────── + +/** + * Provenance metadata embedded in every report JSON. + * Enables merge to verify sources and diff to track history. + */ +export interface ReportMetadata { + /** Schema version for the report JSON format (semver) */ + schema_version: string; + /** GuardLink CLI version that generated this report */ + guardlink_version: string; + /** Repository name (from workspace.yaml this_repo, or project name) */ + repo: string; + /** Git commit SHA at generation time (null if not a git repo) */ + commit_sha: string | null; + /** Git branch at generation time (null if not a git repo) */ + branch: string | null; + /** ISO 8601 timestamp of report generation */ + generated_at: string; + /** Workspace name if this repo is part of a workspace */ + workspace?: string; +} + +// ─── External References ───────────────────────────────────────────── + +/** + * A tag reference that points to a definition in another repo. + * Detected during parsing when a tag uses a service prefix not + * matching any local asset/threat/control definition. + */ +export interface ExternalRef { + /** The referenced tag (e.g. "#auth-lib.token-verify") */ + tag: string; + /** The verb context where this ref appears (e.g. "mitigates", "flows") */ + context_verb: AnnotationVerb; + /** Where the reference was found */ + location: SourceLocation; + /** Inferred target repo from tag prefix (e.g. "auth-lib") */ + inferred_repo?: string; +} + // ─── Threat Model (§5.1) ───────────────────────────────────────────── export interface ThreatModel { @@ -168,6 +209,12 @@ export interface ThreatModel { annotated_files: string[]; unannotated_files: string[]; + /** Report provenance — always populated in report JSON output */ + metadata?: ReportMetadata; + + /** Cross-repo tag references detected during parsing */ + external_refs?: ExternalRef[]; + assets: ThreatModelAsset[]; threats: ThreatModelThreat[]; controls: ThreatModelControl[]; diff --git a/src/workspace/index.ts b/src/workspace/index.ts new file mode 100644 index 0000000..0a2ba69 --- /dev/null +++ b/src/workspace/index.ts @@ -0,0 +1,30 @@ +/** + * GuardLink Workspace — Multi-repo linking and merge. + * + * @comment -- "Workspace module: config loading, merge engine, link-project setup" + */ + +export type { + WorkspaceConfig, WorkspaceRepo, + TagOwnership, UnresolvedRef, MergeWarning, MergeWarningCode, + RepoStatus, MergedReport, MergeTotals, MergeDiffSummary, +} from './types.js'; + +export { + REPORT_SCHEMA_VERSION, + populateMetadata, + loadWorkspaceConfig, + parseWorkspaceYaml, + serializeWorkspaceYaml, +} from './metadata.js'; + +export { + mergeReports, + formatMergeSummary, + diffMergedReports, + formatDiffSummary, +} from './merge.js'; +export type { MergeOptions } from './merge.js'; + +export { linkProject, addToWorkspace, removeFromWorkspace, buildWorkspaceContextBlock, detectRepoName } from './link.js'; +export type { LinkProjectOptions, AddToWorkspaceOptions, RemoveFromWorkspaceOptions, LinkResult } from './link.js'; diff --git a/src/workspace/link.ts b/src/workspace/link.ts new file mode 100644 index 0000000..cc63f3f --- /dev/null +++ b/src/workspace/link.ts @@ -0,0 +1,720 @@ +/** + * GuardLink Workspace — link-project command logic. + * + * Scaffolds workspace.yaml in each repo and updates agent instruction + * files with workspace context so agents write cross-repo-aware annotations. + * + * @asset Workspace.Link (#workspace-link) -- "Multi-repo workspace linking setup" + * @flows UserArgs -> #workspace-link via linkProject -- "CLI args to workspace scaffolding" + * @flows #workspace-link -> AgentFiles via updateAgentWorkspaceContext -- "Inject workspace context" + */ + +import { existsSync, readFileSync, mkdirSync, readdirSync, statSync, unlinkSync } from 'node:fs'; +import { writeFileSync } from 'node:fs'; +import { resolve, basename, dirname, join } from 'node:path'; +import type { WorkspaceConfig, WorkspaceRepo } from './types.js'; +import { serializeWorkspaceYaml, loadWorkspaceConfig } from './metadata.js'; + +// ─── Types ─────────────────────────────────────────────────────────── + +export interface LinkProjectOptions { + /** Workspace name */ + workspace: string; + /** Repo directories (absolute or relative paths) */ + repoPaths: string[]; + /** GitHub/GitLab org registry base URL (e.g. "github.com/unstructured") */ + registry?: string; +} + +export interface AddToWorkspaceOptions { + /** Path to the new repo being added */ + newRepoPath: string; + /** Path to an existing workspace repo (to read workspace.yaml from) */ + existingRepoPath: string; + /** GitHub/GitLab org registry base URL (optional — inherits from existing workspace) */ + registry?: string; +} + +export interface LinkResult { + /** Repos that were successfully linked */ + linked: string[]; + /** Repos that were skipped (e.g. path not found) */ + skipped: { name: string; reason: string }[]; + /** Agent files updated */ + agentFilesUpdated: string[]; + /** Repos that were auto-initialized */ + initialized: string[]; + /** Repos whose workspace.yaml was updated (for --add mode) */ + updated: string[]; +} + +// ─── Fresh Link (all repos specified) ──────────────────────────────── + +/** + * Link multiple repos into a workspace (fresh setup). + * + * For each repo path: + * 1. Auto-init if not yet guardlink-initialized + * 2. Detect repo name from git remote, package.json, or directory name + * 3. Generate .guardlink/workspace.yaml + * 4. Update agent instruction files with workspace context + */ +export function linkProject(options: LinkProjectOptions): LinkResult { + const result: LinkResult = { + linked: [], skipped: [], agentFilesUpdated: [], initialized: [], updated: [], + }; + + // Resolve all repo paths and detect names + const repos: Array<{ name: string; path: string; registry?: string }> = []; + for (const rp of options.repoPaths) { + const absPath = resolve(rp); + if (!existsSync(absPath)) { + result.skipped.push({ name: rp, reason: 'Directory not found' }); + continue; + } + + // Auto-init if needed + const initResult = ensureInitialized(absPath); + if (initResult) result.initialized.push(initResult); + + const name = detectRepoName(absPath); + const registry = options.registry + ? `${options.registry.replace(/\/$/, '')}/${name}` + : undefined; + repos.push({ name, path: absPath, registry }); + } + + if (repos.length === 0) return result; + + // Build the shared repo list + const workspaceRepos: WorkspaceRepo[] = repos.map(r => ({ + name: r.name, + ...(r.registry && { registry: r.registry }), + })); + + // Write workspace.yaml + update agents in each repo + writeWorkspaceToRepos(repos, workspaceRepos, options.workspace, result); + + return result; +} + +// ─── Add to Existing Workspace ─────────────────────────────────────── + +/** + * Add a new repo to an existing workspace. + * + * 1. Read workspace.yaml from the existing repo + * 2. Get the full repo list + workspace name + * 3. Auto-init the new repo if needed + * 4. Discover existing workspace repos on disk (sibling directory scan) + * 5. Add the new repo to the list + * 6. Write updated workspace.yaml + agent files to ALL repos found on disk + */ +export function addToWorkspace(options: AddToWorkspaceOptions): LinkResult { + const result: LinkResult = { + linked: [], skipped: [], agentFilesUpdated: [], initialized: [], updated: [], + }; + + const existingPath = resolve(options.existingRepoPath); + const newPath = resolve(options.newRepoPath); + + // 1. Read existing workspace config + const existingConfig = loadWorkspaceConfig(existingPath); + if (!existingConfig) { + result.skipped.push({ + name: options.existingRepoPath, + reason: 'No .guardlink/workspace.yaml found — not a workspace repo', + }); + return result; + } + + // 2. Auto-init the new repo if needed + const initResult = ensureInitialized(newPath); + if (initResult) result.initialized.push(initResult); + + // 3. Detect new repo name and build its WorkspaceRepo entry + const newRepoName = detectRepoName(newPath); + + // Check if already in workspace + if (existingConfig.repos.some(r => r.name === newRepoName)) { + result.skipped.push({ + name: newRepoName, + reason: `Already in workspace "${existingConfig.workspace}"`, + }); + return result; + } + + // Infer registry from existing repos if not provided + const existingRegistry = existingConfig.repos.find(r => r.registry)?.registry; + const registryBase = options.registry || extractRegistryBase(existingRegistry); + const newRepoEntry: WorkspaceRepo = { + name: newRepoName, + ...(registryBase && { registry: `${registryBase}/${newRepoName}` }), + }; + + // 4. Build updated repo list + const updatedRepos: WorkspaceRepo[] = [...existingConfig.repos, newRepoEntry]; + + // 5. Discover all workspace repos on disk + const discoveredPaths = discoverWorkspaceRepos( + existingPath, + existingConfig, + newPath, + newRepoName, + ); + + // 6. Write to all discovered repos + const reposWithPaths = discoveredPaths.map(d => ({ + name: d.name, + path: d.path, + registry: updatedRepos.find(r => r.name === d.name)?.registry, + })); + + writeWorkspaceToRepos(reposWithPaths, updatedRepos, existingConfig.workspace, result); + + // Track which existing repos got updated vs the new one that got linked + for (const d of discoveredPaths) { + if (d.name !== newRepoName && result.linked.includes(d.name)) { + // Move from "linked" to "updated" for existing repos + result.linked = result.linked.filter(n => n !== d.name); + result.updated.push(d.name); + } + } + + return result; +} + +// ─── Remove from Existing Workspace ────────────────────────────────── + +export interface RemoveFromWorkspaceOptions { + /** Name of the repo to remove (as listed in workspace.yaml) */ + repoName: string; + /** Path to any repo currently in the workspace (to read workspace.yaml from) */ + existingRepoPath: string; +} + +/** + * Remove a repo from an existing workspace. + * + * 1. Read workspace.yaml from the existing repo + * 2. Verify the named repo is actually in the workspace + * 3. Remove it from the repo list + * 4. Discover all remaining workspace repos on disk + * 5. Update workspace.yaml + agent files in all remaining repos + * 6. If the removed repo is found on disk, clean up its workspace.yaml + * and strip workspace context from its agent files + */ +export function removeFromWorkspace(options: RemoveFromWorkspaceOptions): LinkResult { + const result: LinkResult = { + linked: [], skipped: [], agentFilesUpdated: [], initialized: [], updated: [], + }; + + const existingPath = resolve(options.existingRepoPath); + + // 1. Read existing workspace config + const existingConfig = loadWorkspaceConfig(existingPath); + if (!existingConfig) { + result.skipped.push({ + name: options.existingRepoPath, + reason: 'No .guardlink/workspace.yaml found — not a workspace repo', + }); + return result; + } + + // 2. Verify repo is in workspace + const targetRepo = existingConfig.repos.find(r => r.name === options.repoName); + if (!targetRepo) { + result.skipped.push({ + name: options.repoName, + reason: `Not found in workspace "${existingConfig.workspace}" (repos: ${existingConfig.repos.map(r => r.name).join(', ')})`, + }); + return result; + } + + // 3. Can't remove if it would leave < 2 repos + if (existingConfig.repos.length <= 2) { + result.skipped.push({ + name: options.repoName, + reason: 'Cannot remove — workspace must have at least 2 repos. Use fresh link-project to recreate.', + }); + return result; + } + + // 4. Build updated repo list (without the removed repo) + const updatedRepos = existingConfig.repos.filter(r => r.name !== options.repoName); + + // 5. Discover remaining repos on disk + // We pass a dummy new path that won't match anything — we just need discovery + const discoveredPaths = discoverWorkspaceReposForRemoval( + existingPath, + existingConfig, + options.repoName, + ); + + // 6. Update remaining repos + const remainingRepos = discoveredPaths + .filter(d => d.name !== options.repoName) + .map(d => ({ + name: d.name, + path: d.path, + registry: updatedRepos.find(r => r.name === d.name)?.registry, + })); + + writeWorkspaceToRepos(remainingRepos, updatedRepos, existingConfig.workspace, result); + + // Move all from "linked" to "updated" since these are existing repos being updated + result.updated = [...result.linked]; + result.linked = []; + + // 7. Clean up the removed repo if found on disk + const removedOnDisk = discoveredPaths.find(d => d.name === options.repoName); + if (removedOnDisk) { + cleanupRemovedRepo(removedOnDisk.path, options.repoName, result); + } + + return result; +} + +/** + * Clean up a removed repo: delete workspace.yaml, strip workspace context from agent files. + */ +function cleanupRemovedRepo(repoPath: string, repoName: string, result: LinkResult): void { + // Delete workspace.yaml + const yamlPath = resolve(repoPath, '.guardlink', 'workspace.yaml'); + if (existsSync(yamlPath)) { + unlinkSync(yamlPath); + } + + // Strip workspace context block from agent files + for (const agent of AGENT_FILES) { + const filePath = resolve(repoPath, agent.path); + if (!existsSync(filePath)) continue; + + let content = readFileSync(filePath, 'utf-8'); + const markerIdx = content.indexOf(agent.marker); + if (markerIdx === -1) continue; + + // Remove from marker to next ## or end of file + const afterMarker = content.slice(markerIdx); + const nextSectionMatch = afterMarker.match(/\n## (?!Workspace Context)/); + const endIdx = nextSectionMatch + ? markerIdx + (nextSectionMatch.index ?? afterMarker.length) + : content.length; + + content = content.slice(0, markerIdx).trimEnd() + '\n'; + writeFileSync(filePath, content); + result.agentFilesUpdated.push(`${repoName}/${agent.path} (cleaned)`); + } +} + +/** + * Discover workspace repos for removal — scans sibling dirs from the existing repo. + * Also tries to find the repo being removed so we can clean it up. + */ +function discoverWorkspaceReposForRemoval( + existingRepoPath: string, + config: WorkspaceConfig, + removingRepoName: string, +): DiscoveredRepo[] { + const discovered: DiscoveredRepo[] = []; + const found = new Set(); + + // Always include the existing repo + discovered.push({ name: config.this_repo, path: existingRepoPath }); + found.add(config.this_repo); + + // Find all other workspace repos (including the one being removed, for cleanup) + const remaining = config.repos + .map(r => r.name) + .filter(n => !found.has(n)); + + const scanDirs = new Set(); + scanDirs.add(dirname(existingRepoPath)); + scanDirs.add(dirname(dirname(existingRepoPath))); + + for (const scanDir of scanDirs) { + if (!existsSync(scanDir)) continue; + let entries: string[]; + try { entries = readdirSync(scanDir); } catch { continue; } + + for (const entry of entries) { + const entryPath = join(scanDir, entry); + try { if (!statSync(entryPath).isDirectory()) continue; } catch { continue; } + + for (const repoName of remaining) { + if (found.has(repoName)) continue; + if (matchesRepoName(entryPath, entry, repoName)) { + discovered.push({ name: repoName, path: entryPath }); + found.add(repoName); + } + } + } + } + + return discovered; +} + +// ─── Discover Workspace Repos on Disk ───────────────────────────────── + +interface DiscoveredRepo { + name: string; + path: string; +} + +/** + * Find workspace repos on the local filesystem. + * + * Strategy: + * 1. Start with the known existing repo path and the new repo path + * 2. For remaining repos in workspace.yaml, scan common parent directories + * 3. Check: parent of existing repo, parent of new repo, grandparent + * 4. Match by directory name or git remote name + */ +function discoverWorkspaceRepos( + existingRepoPath: string, + config: WorkspaceConfig, + newRepoPath: string, + newRepoName: string, +): DiscoveredRepo[] { + const discovered: DiscoveredRepo[] = []; + const found = new Set(); + + // Always include the existing repo and new repo + discovered.push({ name: config.this_repo, path: existingRepoPath }); + found.add(config.this_repo); + + discovered.push({ name: newRepoName, path: newRepoPath }); + found.add(newRepoName); + + // Find remaining workspace repos + const remaining = config.repos + .map(r => r.name) + .filter(n => !found.has(n)); + + if (remaining.length === 0) return discovered; + + // Directories to scan for sibling repos + const scanDirs = new Set(); + scanDirs.add(dirname(existingRepoPath)); + scanDirs.add(dirname(newRepoPath)); + // Also try grandparent (for monorepo-style layouts like ~/projects/org/repos/) + scanDirs.add(dirname(dirname(existingRepoPath))); + + for (const scanDir of scanDirs) { + if (!existsSync(scanDir)) continue; + + let entries: string[]; + try { + entries = readdirSync(scanDir); + } catch { + continue; + } + + for (const entry of entries) { + if (found.has(entry)) continue; // already discovered by name match + + const entryPath = join(scanDir, entry); + try { + if (!statSync(entryPath).isDirectory()) continue; + } catch { + continue; + } + + // Check if this directory matches a remaining repo + for (const repoName of remaining) { + if (found.has(repoName)) continue; + + if (matchesRepoName(entryPath, entry, repoName)) { + discovered.push({ name: repoName, path: entryPath }); + found.add(repoName); + } + } + } + } + + // Warn about repos we couldn't find (they'll be in the updated workspace.yaml + // but won't get their files updated — user needs to update them manually) + // Caller handles this via checking which names are in result.linked vs config.repos + + return discovered; +} + +/** + * Check if a directory matches a workspace repo name. + * Matches by: exact directory name, or git remote repo name. + */ +function matchesRepoName(dirPath: string, dirName: string, repoName: string): boolean { + // Direct name match + if (dirName === repoName) return true; + + // Check git remote + try { + const gitConfigPath = join(dirPath, '.git', 'config'); + if (existsSync(gitConfigPath)) { + const gitConfig = readFileSync(gitConfigPath, 'utf-8'); + const m = gitConfig.match(/url\s*=\s*.*[/:]([^/\s]+?)(?:\.git)?\s*$/m); + if (m && m[1] === repoName) return true; + } + } catch { /* ignore */ } + + return false; +} + +/** + * Extract the org base URL from a full registry URL. + * "github.com/unstructured/payment-svc" → "github.com/unstructured" + */ +function extractRegistryBase(registry: string | undefined): string | undefined { + if (!registry) return undefined; + const lastSlash = registry.lastIndexOf('/'); + return lastSlash > 0 ? registry.slice(0, lastSlash) : undefined; +} + +// ─── Shared: Write Workspace Config to Repos ───────────────────────── + +function writeWorkspaceToRepos( + repos: Array<{ name: string; path: string; registry?: string }>, + workspaceRepos: WorkspaceRepo[], + workspace: string, + result: LinkResult, +): void { + for (const repo of repos) { + try { + const config: WorkspaceConfig = { + workspace, + this_repo: repo.name, + repos: workspaceRepos, + }; + + // Ensure .guardlink/ exists + const guardlinkDir = resolve(repo.path, '.guardlink'); + if (!existsSync(guardlinkDir)) { + mkdirSync(guardlinkDir, { recursive: true }); + } + + // Write workspace.yaml + const yamlPath = resolve(guardlinkDir, 'workspace.yaml'); + writeFileSync(yamlPath, serializeWorkspaceYaml(config)); + + // Update agent instruction files + const updated = updateAgentWorkspaceContext(repo.path, config, workspaceRepos); + result.agentFilesUpdated.push(...updated); + + result.linked.push(repo.name); + } catch (err) { + result.skipped.push({ + name: repo.name, + reason: err instanceof Error ? err.message : String(err), + }); + } + } +} + +// ─── Auto-Init ─────────────────────────────────────────────────────── + +/** + * Check if a repo has been guardlink-initialized. + * If not, create minimal structure: .guardlink/ dir and base agent files. + * Returns the repo name if initialized, null if already set up. + */ +function ensureInitialized(repoPath: string): string | null { + const guardlinkDir = resolve(repoPath, '.guardlink'); + const hasGuardlink = existsSync(guardlinkDir); + + // Check for at least one agent instruction file + const hasAnyAgentFile = AGENT_FILES.some(a => existsSync(resolve(repoPath, a.path))); + + if (hasGuardlink && hasAnyAgentFile) return null; // already initialized + + const repoName = detectRepoName(repoPath); + + // Create .guardlink/ if missing + if (!hasGuardlink) { + mkdirSync(guardlinkDir, { recursive: true }); + } + + // Create minimal agent instruction files if none exist + if (!hasAnyAgentFile) { + createMinimalAgentFiles(repoPath, repoName); + } + + return repoName; +} + +/** + * Create minimal agent instruction files for a repo that hasn't been + * guardlink-initialized. We create a subset — just the most common ones + * (.claude/guardlink.md, AGENTS.md) rather than the full init flow, + * since the user may not have all agents installed. + */ +function createMinimalAgentFiles(repoPath: string, repoName: string): void { + const baseContent = [ + `# GuardLink — ${repoName}`, + '', + 'This project uses [GuardLink](https://guardlink.bugb.io) for security annotations.', + '', + '## Annotation Rules', + '', + '- Add `@asset`, `@threat`, `@control` annotations to define security elements', + '- Use `@mitigates`, `@exposes`, `@accepts` to document relationships', + '- Use `@flows` to document data movement between components', + '- Never write `@accepts` — risk acceptance is human-only via `guardlink review`', + '- Run `guardlink validate .` after changes to check for errors', + '', + ].join('\n'); + + // Create .claude/guardlink.md (most common agent) + const claudeDir = resolve(repoPath, '.claude'); + if (!existsSync(claudeDir)) mkdirSync(claudeDir, { recursive: true }); + const claudePath = resolve(claudeDir, 'guardlink.md'); + if (!existsSync(claudePath)) writeFileSync(claudePath, baseContent); + + // Create AGENTS.md (universal) + const agentsPath = resolve(repoPath, 'AGENTS.md'); + if (!existsSync(agentsPath)) writeFileSync(agentsPath, baseContent); +} + +// ─── Repo Name Detection ───────────────────────────────────────────── + +/** + * Detect repo name from (in order): git remote origin, package.json name, + * Cargo.toml name, or directory basename. + */ +export function detectRepoName(repoPath: string): string { + // 1. Git remote + try { + const gitConfigPath = resolve(repoPath, '.git', 'config'); + if (existsSync(gitConfigPath)) { + const gitConfig = readFileSync(gitConfigPath, 'utf-8'); + const m = gitConfig.match(/url\s*=\s*.*[/:]([^/\s]+?)(?:\.git)?\s*$/m); + if (m) return m[1]; + } + } catch { /* ignore */ } + + // 2. package.json + try { + const pkgPath = resolve(repoPath, 'package.json'); + if (existsSync(pkgPath)) { + const pkg = JSON.parse(readFileSync(pkgPath, 'utf-8')); + if (pkg.name && !isGenericName(pkg.name)) return pkg.name; + } + } catch { /* ignore */ } + + // 3. Cargo.toml + try { + const cargoPath = resolve(repoPath, 'Cargo.toml'); + if (existsSync(cargoPath)) { + const cargo = readFileSync(cargoPath, 'utf-8'); + const m = cargo.match(/^\s*name\s*=\s*"([^"]+)"/m); + if (m) return m[1]; + } + } catch { /* ignore */ } + + // 4. Directory name + return basename(repoPath); +} + +const GENERIC_NAMES = new Set([ + 'my-app', 'my-project', 'app', 'project', 'unknown', 'starter', + 'my-v0-project', 'vite-project', 'react-app', 'create-react-app', +]); + +function isGenericName(name: string): boolean { + return GENERIC_NAMES.has(name.toLowerCase()); +} + +// ─── Agent File Updates ────────────────────────────────────────────── + +/** Known agent instruction file paths (relative to repo root) */ +const AGENT_FILES = [ + { path: '.claude/guardlink.md', marker: '## Workspace Context' }, + { path: 'CLAUDE.md', marker: '## Workspace Context' }, + { path: '.cursor/rules/guardlink.mdc', marker: '## Workspace Context' }, + { path: '.windsurfrules', marker: '## Workspace Context' }, + { path: '.github/copilot-instructions.md', marker: '## Workspace Context' }, + { path: '.gemini/guardlink.md', marker: '## Workspace Context' }, + { path: 'AGENTS.md', marker: '## Workspace Context' }, +]; + +/** + * Generate the workspace context block that gets injected into agent files. + */ +export function buildWorkspaceContextBlock( + config: WorkspaceConfig, + allRepos: WorkspaceRepo[], +): string { + const siblings = allRepos.filter(r => r.name !== config.this_repo); + const siblingNames = siblings.map(r => r.name).join(', '); + + const lines: string[] = []; + lines.push('## Workspace Context'); + lines.push(''); + lines.push(`This repository (\`${config.this_repo}\`) is part of the **${config.workspace}** workspace`); + lines.push(`containing ${allRepos.length} linked services: ${allRepos.map(r => r.name).join(', ')}.`); + lines.push(''); + lines.push('### Cross-Repo Annotation Rules'); + lines.push(''); + lines.push('When writing GuardLink annotations in this repo:'); + lines.push(''); + lines.push(`- **Tag prefix convention:** Use \`#${config.this_repo}.\` for assets defined here.`); + lines.push(`- **Reference sibling repos:** You may reference assets/threats/controls from: ${siblingNames}.`); + lines.push(` Use their tag prefix, e.g. \`#${siblings[0]?.name || 'other-service'}.\`.`); + lines.push('- **Cross-service data flows:** If this code calls or is called by another service, document it:'); + lines.push(` \`@flows #request from #${config.this_repo}.handler to #${siblings[0]?.name || 'other-service'}.endpoint\``); + lines.push('- **Do not redefine** assets that belong to another repo. Reference them by tag.'); + lines.push('- **External refs are OK:** Tags referencing sibling repos will show as "external refs"'); + lines.push(' during local validation but resolve during workspace merge.'); + lines.push(''); + lines.push(`### Sibling Services`); + lines.push(''); + for (const s of siblings) { + const reg = s.registry ? ` (${s.registry})` : ''; + lines.push(`- **${s.name}**${reg}`); + } + lines.push(''); + + return lines.join('\n'); +} + +/** + * Update agent instruction files in a repo with workspace context. + * If the file exists and already has a workspace context block, replace it. + * If the file exists without the block, append it. + * If the file doesn't exist, skip it. + * + * Returns list of files updated. + */ +function updateAgentWorkspaceContext( + repoPath: string, + config: WorkspaceConfig, + allRepos: WorkspaceRepo[], +): string[] { + const contextBlock = buildWorkspaceContextBlock(config, allRepos); + const updated: string[] = []; + + for (const agent of AGENT_FILES) { + const filePath = resolve(repoPath, agent.path); + if (!existsSync(filePath)) continue; + + let content = readFileSync(filePath, 'utf-8'); + const markerIdx = content.indexOf(agent.marker); + + if (markerIdx !== -1) { + // Replace existing workspace context block (from marker to next ## or end) + const afterMarker = content.slice(markerIdx); + const nextSectionMatch = afterMarker.match(/\n## (?!Workspace Context)/); + const endIdx = nextSectionMatch + ? markerIdx + (nextSectionMatch.index ?? afterMarker.length) + : content.length; + content = content.slice(0, markerIdx) + contextBlock + content.slice(endIdx); + } else { + // Append workspace context block + content = content.trimEnd() + '\n\n' + contextBlock; + } + + writeFileSync(filePath, content); + updated.push(`${config.this_repo}/${agent.path}`); + } + + return updated; +} diff --git a/src/workspace/merge.ts b/src/workspace/merge.ts new file mode 100644 index 0000000..0c7befb --- /dev/null +++ b/src/workspace/merge.ts @@ -0,0 +1,876 @@ +/** + * GuardLink Workspace — Merge engine for multi-repo reports. + * + * Takes N per-repo report JSONs and produces a unified MergedReport + * with cross-repo tag resolution, warning detection, and aggregated stats. + * + * @asset Workspace.Merge (#merge-engine) -- "Cross-repo threat model unification" + * @threat Tag_Collision (#tag-collision) [medium] -- "Duplicate tag definitions across repos" + * @mitigates #merge-engine against #tag-collision using #prefix-ownership -- "Tag prefix determines owning repo" + * @flows ReportJSON -> #merge-engine via mergeReports -- "Per-repo reports feed into merge" + * @flows #merge-engine -> MergedReport via mergeReports -- "Unified output" + */ + +import { readFile } from 'node:fs/promises'; +import { basename } from 'node:path'; +import type { + ThreatModel, ThreatModelAsset, ThreatModelThreat, ThreatModelControl, + ThreatModelMitigation, ThreatModelExposure, ThreatModelAcceptance, + ThreatModelTransfer, ThreatModelFlow, ThreatModelBoundary, + ThreatModelValidation, ThreatModelAudit, ThreatModelOwnership, + ThreatModelDataHandling, ThreatModelAssumption, + SourceLocation, ExternalRef, AnnotationVerb, +} from '../types/index.js'; +import type { + MergedReport, MergeTotals, MergeDiffSummary, TagOwnership, UnresolvedRef, + MergeWarning, MergeWarningCode, RepoStatus, +} from './types.js'; +import { REPORT_SCHEMA_VERSION } from './metadata.js'; + +// ─── Report Loading ────────────────────────────────────────────────── + +/** A loaded per-repo report with its origin info */ +interface LoadedReport { + /** Repo name (from metadata.repo, or inferred from filename) */ + repo: string; + /** The parsed ThreatModel */ + model: ThreatModel; + /** Path we loaded from */ + source_path: string; +} + +/** + * Load a single report JSON file. Returns the parsed model + repo name. + * Throws on missing file or invalid JSON; caller handles gracefully. + */ +export async function loadReportJson(filePath: string): Promise { + const raw = await readFile(filePath, 'utf-8'); + const model: ThreatModel = JSON.parse(raw); + + // Determine repo name: prefer metadata.repo, fall back to project, then filename + const repo = model.metadata?.repo + || model.project + || basename(filePath, '.json').replace(/^guardlink-report-?/, '') || 'unknown'; + + return { repo, model, source_path: filePath }; +} + +/** + * Attempt to load multiple report files. Returns loaded reports + statuses. + * Missing or invalid files produce a RepoStatus with loaded=false rather than throwing. + */ +export async function loadAllReports( + filePaths: string[], + expectedRepos?: string[], +): Promise<{ reports: LoadedReport[]; statuses: RepoStatus[] }> { + const reports: LoadedReport[] = []; + const statuses: RepoStatus[] = []; + + for (const fp of filePaths) { + try { + const report = await loadReportJson(fp); + reports.push(report); + statuses.push({ + name: report.repo, + loaded: true, + generated_at: report.model.metadata?.generated_at || report.model.generated_at, + commit_sha: report.model.metadata?.commit_sha ?? undefined, + annotation_count: report.model.annotations_parsed, + }); + } catch (err) { + const name = basename(fp, '.json').replace(/^guardlink-report-?/, '') || fp; + statuses.push({ + name, + loaded: false, + error: err instanceof Error ? err.message : String(err), + }); + } + } + + // Flag expected repos that had no report file at all + if (expectedRepos) { + const loadedNames = new Set(statuses.map(s => s.name)); + for (const repo of expectedRepos) { + if (!loadedNames.has(repo)) { + statuses.push({ name: repo, loaded: false, error: 'No report file provided' }); + } + } + } + + return { reports, statuses }; +} + +// ─── Tag Registry ──────────────────────────────────────────────────── + +/** + * Extract all tag definitions (assets, threats, controls) from a ThreatModel. + * Tags come from the `id` field (e.g. "#payment-svc.refund"). + */ +function extractTagDefinitions(model: ThreatModel, repo: string): TagOwnership[] { + const tags: TagOwnership[] = []; + + for (const a of model.assets) { + if (a.id) { + tags.push({ tag: a.id, owner_repo: repo, kind: 'asset' }); + // Also register with # prefix since relationships use #tag form + if (!a.id.startsWith('#')) tags.push({ tag: `#${a.id}`, owner_repo: repo, kind: 'asset' }); + } + } + for (const t of model.threats) { + if (t.id) { + tags.push({ tag: t.id, owner_repo: repo, kind: 'threat' }); + if (!t.id.startsWith('#')) tags.push({ tag: `#${t.id}`, owner_repo: repo, kind: 'threat' }); + } + } + for (const c of model.controls) { + if (c.id) { + tags.push({ tag: c.id, owner_repo: repo, kind: 'control' }); + if (!c.id.startsWith('#')) tags.push({ tag: `#${c.id}`, owner_repo: repo, kind: 'control' }); + } + } + + return tags; +} + +/** + * Build a unified tag registry from all loaded reports. + * + * Ownership rule: the repo whose name matches the tag prefix owns it. + * e.g. "#payment-svc.refund" → owned by repo "payment-svc" (or "payment-service"). + * If no prefix match, first definition wins. + * + * Returns the registry + any duplicate-tag warnings. + */ +export function buildTagRegistry( + reports: LoadedReport[], +): { registry: TagOwnership[]; warnings: MergeWarning[] } { + const warnings: MergeWarning[] = []; + const repoNames = new Set(reports.map(r => r.repo)); + + // Collect all definitions grouped by tag + const definitionsByTag = new Map(); + for (const report of reports) { + const defs = extractTagDefinitions(report.model, report.repo); + for (const def of defs) { + const existing = definitionsByTag.get(def.tag) || []; + existing.push(def); + definitionsByTag.set(def.tag, existing); + } + } + + // Resolve ownership: prefix match > first definition + const registry: TagOwnership[] = []; + for (const [tag, defs] of definitionsByTag) { + if (defs.length === 1) { + registry.push(defs[0]); + continue; + } + + // Multiple repos define this tag — find best owner + const prefixOwner = inferOwnerFromPrefix(tag, repoNames); + const winner = prefixOwner + ? defs.find(d => d.owner_repo === prefixOwner) || defs[0] + : defs[0]; + + registry.push(winner); + + // Warn about duplicates + const otherRepos = defs + .filter(d => d.owner_repo !== winner.owner_repo) + .map(d => d.owner_repo); + if (otherRepos.length > 0) { + warnings.push({ + level: 'warning', + code: 'duplicate_tag', + message: `Tag "${tag}" defined in ${winner.owner_repo} (owner) and also in: ${otherRepos.join(', ')}`, + repos: [winner.owner_repo, ...otherRepos], + tag, + }); + } + } + + return { registry, warnings }; +} + +/** + * Normalize a tag for comparison: strip leading '#'. + * Asset ids are stored as "parser" but references use "#parser". + */ +function normalizeTag(tag: string): string { + return tag.startsWith('#') ? tag.slice(1) : tag; +} + +/** + * Infer which repo owns a tag based on its prefix. + * "#payment-svc.refund" → look for repo named "payment-svc" or "payment-service". + * Returns null if no prefix match found. + */ +function inferOwnerFromPrefix(tag: string, repoNames: Set): string | null { + // Strip leading # if present + const clean = tag.startsWith('#') ? tag.slice(1) : tag; + const dotIdx = clean.indexOf('.'); + if (dotIdx === -1) return null; // no service prefix + + const prefix = clean.slice(0, dotIdx); + + // Direct match + if (repoNames.has(prefix)) return prefix; + + // Fuzzy: "payment-svc" matches "payment-service" (prefix is substring or vice versa) + for (const repo of repoNames) { + if (repo.startsWith(prefix) || prefix.startsWith(repo)) return repo; + // Also try with common suffix variations: -svc, -service, -lib, -api + const normalized = repo.replace(/-(?:service|svc|lib|api|worker)$/, ''); + const prefixNorm = prefix.replace(/-(?:service|svc|lib|api|worker)$/, ''); + if (normalized === prefixNorm) return repo; + } + + return null; +} + +// ─── Cross-Repo Reference Resolution ───────────────────────────────── + +/** + * Collect all tag references from relationship annotations (mitigates, exposes, + * flows, etc.) and check which ones resolve to the tag registry. + * + * Returns unresolved refs + additional warnings. + */ +export function resolveReferences( + reports: LoadedReport[], + registry: TagOwnership[], + repoNames: Set, +): { unresolved: UnresolvedRef[]; warnings: MergeWarning[] } { + // Build tag set with both "#tag" and "tag" forms for lookup + const tagSet = new Set(); + for (const t of registry) { + tagSet.add(t.tag); + tagSet.add(normalizeTag(t.tag)); + } + const unresolved: UnresolvedRef[] = []; + const warnings: MergeWarning[] = []; + + for (const report of reports) { + const m = report.model; + + // Gather all tag references used in relationship annotations + const refs = collectTagReferences(m, report.repo); + + for (const ref of refs) { + if (tagSet.has(ref.tag) || tagSet.has(normalizeTag(ref.tag))) continue; // resolved + + // Check if the tag prefix suggests a known repo (but definition is missing) + const prefix = inferOwnerFromPrefix(ref.tag, repoNames); + + unresolved.push({ + ...ref, + source_repo: report.repo, + inferred_repo: prefix ?? undefined, + }); + } + } + + // Generate warnings for unresolved refs + // Group by tag for cleaner output + const byTag = new Map(); + for (const u of unresolved) { + const existing = byTag.get(u.tag) || []; + existing.push(u); + byTag.set(u.tag, existing); + } + + for (const [tag, refs] of byTag) { + const repos = [...new Set(refs.map(r => r.source_repo))]; + const inferred = refs[0].inferred_repo; + const detail = inferred + ? ` (prefix suggests repo "${inferred}" but no definition found)` + : ''; + warnings.push({ + level: 'warning', + code: 'unresolved_ref', + message: `Tag "${tag}" referenced in ${repos.join(', ')} but not defined in any repo${detail}`, + repos, + tag, + }); + } + + // Also warn about tag prefixes that don't match any known repo + for (const entry of registry) { + const clean = entry.tag.startsWith('#') ? entry.tag.slice(1) : entry.tag; + const dotIdx = clean.indexOf('.'); + if (dotIdx === -1) continue; + const prefix = clean.slice(0, dotIdx); + if (!inferOwnerFromPrefix(entry.tag, repoNames)) { + warnings.push({ + level: 'info', + code: 'tag_prefix_mismatch', + message: `Tag "${entry.tag}" has prefix "${prefix}" which doesn't match any workspace repo`, + repos: [entry.owner_repo], + tag: entry.tag, + }); + } + } + + return { unresolved, warnings }; +} + +/** Simple ref container used during collection */ +interface TagRef { + tag: string; + context_verb: AnnotationVerb; + location: SourceLocation; +} + +/** + * Collect all tag references from a ThreatModel's relationship annotations. + * These are the tags used in mitigates, exposes, flows, etc. — NOT definitions. + */ +function collectTagReferences(m: ThreatModel, _repo: string): TagRef[] { + const refs: TagRef[] = []; + + // Helper: add tag ref only if it looks like a tag reference (starts with #) + // Plain text like "EnvVars", "FileSystem" in flows are descriptive, not cross-repo refs + const addRef = (tag: string | undefined, verb: AnnotationVerb, loc: SourceLocation) => { + if (!tag) return; + if (!tag.startsWith('#')) return; // not a tag reference + refs.push({ tag, context_verb: verb, location: loc }); + }; + + for (const mit of m.mitigations) { + addRef(mit.asset, 'mitigates', mit.location); + addRef(mit.threat, 'mitigates', mit.location); + if (mit.control) addRef(mit.control, 'mitigates', mit.location); + } + for (const exp of m.exposures) { + addRef(exp.asset, 'exposes', exp.location); + addRef(exp.threat, 'exposes', exp.location); + } + for (const acc of m.acceptances) { + addRef(acc.asset, 'accepts', acc.location); + addRef(acc.threat, 'accepts', acc.location); + } + for (const tr of m.transfers) { + addRef(tr.source, 'transfers', tr.location); + addRef(tr.target, 'transfers', tr.location); + addRef(tr.threat, 'transfers', tr.location); + } + for (const fl of m.flows) { + addRef(fl.source, 'flows', fl.location); + addRef(fl.target, 'flows', fl.location); + } + for (const b of m.boundaries) { + addRef(b.asset_a, 'boundary', b.location); + addRef(b.asset_b, 'boundary', b.location); + } + for (const v of m.validations) { + addRef(v.control, 'validates', v.location); + addRef(v.asset, 'validates', v.location); + } + + return refs; +} + +// ─── Model Merging ─────────────────────────────────────────────────── + +/** + * Prefix all file paths in a SourceLocation with the repo name + * so merged output shows "payment-service/src/routes/refund.ts:42" + */ +function prefixLocation(loc: SourceLocation, repo: string): SourceLocation { + return { + ...loc, + file: `${repo}/${loc.file}`, + }; +} + +/** Prefix locations on an array of items that have a `location` field */ +function prefixAll(items: T[], repo: string): T[] { + return items.map(item => ({ ...item, location: prefixLocation(item.location, repo) })); +} + +/** + * Combine multiple ThreatModels into a single unified model. + * File paths are prefixed with repo name for disambiguation. + * Deduplication is by tag ID for definitions (assets/threats/controls). + * Relationships are kept from all repos (no dedup — same relationship + * stated in two repos is meaningful). + */ +export function combineModels(reports: LoadedReport[]): ThreatModel { + const seenAssetIds = new Set(); + const seenThreatIds = new Set(); + const seenControlIds = new Set(); + + const combined: ThreatModel = { + version: REPORT_SCHEMA_VERSION, + project: reports.length > 0 ? (reports[0].model.metadata?.workspace || 'workspace') : 'workspace', + generated_at: new Date().toISOString(), + source_files: 0, + annotations_parsed: 0, + annotated_files: [], + unannotated_files: [], + assets: [], + threats: [], + controls: [], + mitigations: [], + exposures: [], + acceptances: [], + transfers: [], + flows: [], + boundaries: [], + validations: [], + audits: [], + ownership: [], + data_handling: [], + assumptions: [], + shields: [], + comments: [], + coverage: { total_symbols: 0, annotated_symbols: 0, coverage_percent: 0, unannotated_critical: [] }, + }; + + for (const { repo, model: m } of reports) { + combined.source_files += m.source_files; + combined.annotations_parsed += m.annotations_parsed; + combined.annotated_files.push(...m.annotated_files.map(f => `${repo}/${f}`)); + combined.unannotated_files.push(...m.unannotated_files.map(f => `${repo}/${f}`)); + + // Definitions: dedup by tag ID, keep first (registry determines owner) + for (const a of m.assets) { + if (a.id && seenAssetIds.has(a.id)) continue; + if (a.id) seenAssetIds.add(a.id); + combined.assets.push({ ...a, location: prefixLocation(a.location, repo) }); + } + for (const t of m.threats) { + if (t.id && seenThreatIds.has(t.id)) continue; + if (t.id) seenThreatIds.add(t.id); + combined.threats.push({ ...t, location: prefixLocation(t.location, repo) }); + } + for (const c of m.controls) { + if (c.id && seenControlIds.has(c.id)) continue; + if (c.id) seenControlIds.add(c.id); + combined.controls.push({ ...c, location: prefixLocation(c.location, repo) }); + } + + // Relationships: keep all (no dedup — cross-repo relationships are valuable) + combined.mitigations.push(...prefixAll(m.mitigations, repo)); + combined.exposures.push(...prefixAll(m.exposures, repo)); + combined.acceptances.push(...prefixAll(m.acceptances, repo)); + combined.transfers.push(...prefixAll(m.transfers, repo)); + combined.flows.push(...prefixAll(m.flows, repo)); + combined.boundaries.push(...prefixAll(m.boundaries, repo)); + combined.validations.push(...prefixAll(m.validations, repo)); + combined.audits.push(...prefixAll(m.audits, repo)); + combined.ownership.push(...prefixAll(m.ownership, repo)); + combined.data_handling.push(...prefixAll(m.data_handling, repo)); + combined.assumptions.push(...prefixAll(m.assumptions, repo)); + combined.shields.push(...prefixAll(m.shields, repo)); + combined.comments.push(...prefixAll(m.comments, repo)); + + // Aggregate coverage + combined.coverage.total_symbols += m.coverage.total_symbols; + combined.coverage.annotated_symbols += m.coverage.annotated_symbols; + } + + // Recompute coverage percent + combined.coverage.coverage_percent = combined.coverage.total_symbols > 0 + ? Math.round((combined.coverage.annotated_symbols / combined.coverage.total_symbols) * 100) + : 0; + + return combined; +} + +// ─── Totals & Unmitigated Detection ────────────────────────────────── + +/** + * Count unmitigated exposures: exposures with no corresponding mitigation + * (same asset+threat pair) and no acceptance. + */ +function countUnmitigated(model: ThreatModel): number { + const mitigatedPairs = new Set( + model.mitigations.map(m => `${m.asset}::${m.threat}`), + ); + const acceptedPairs = new Set( + model.acceptances.map(a => `${a.asset}::${a.threat}`), + ); + + return model.exposures.filter(e => { + const key = `${e.asset}::${e.threat}`; + return !mitigatedPairs.has(key) && !acceptedPairs.has(key); + }).length; +} + +/** Compute aggregate totals from a combined model */ +export function computeTotals( + model: ThreatModel, + statuses: RepoStatus[], + resolvedCount: number, + unresolvedCount: number, +): MergeTotals { + return { + repos: statuses.length, + repos_loaded: statuses.filter(s => s.loaded).length, + annotations: model.annotations_parsed, + assets: model.assets.length, + threats: model.threats.length, + controls: model.controls.length, + mitigations: model.mitigations.length, + exposures: model.exposures.length, + unmitigated_exposures: countUnmitigated(model), + acceptances: model.acceptances.length, + flows: model.flows.length, + boundaries: model.boundaries.length, + external_refs_resolved: resolvedCount, + external_refs_unresolved: unresolvedCount, + }; +} + +// ─── Top-Level Merge Orchestrator ──────────────────────────────────── + +export interface MergeOptions { + /** Workspace name (used in output if no report carries workspace metadata) */ + workspace?: string; + /** Expected repo names (from workspace.yaml). Missing repos generate warnings. */ + expectedRepos?: string[]; + /** Stale threshold in hours. Reports older than this get a warning. Default: 168 (7 days) */ + staleThresholdHours?: number; +} + +/** + * Main entry point: merge N report JSON files into a unified MergedReport. + * + * 1. Load all report JSONs (partial load on failure) + * 2. Build tag registry (who owns each tag) + * 3. Resolve cross-repo references + * 4. Combine all ThreatModels into one + * 5. Compute totals + warnings + * 6. Return MergedReport + */ +export async function mergeReports( + filePaths: string[], + options: MergeOptions = {}, +): Promise { + const staleHours = options.staleThresholdHours ?? 168; + + // 1. Load reports + const { reports, statuses } = await loadAllReports(filePaths, options.expectedRepos); + + if (reports.length === 0) { + // Return empty merged report with all repos marked as failed + return emptyMergedReport(options.workspace || 'unknown', statuses); + } + + // 2. Build tag registry + const { registry, warnings: tagWarnings } = buildTagRegistry(reports); + + // 3. Resolve cross-repo references + const repoNames = new Set(reports.map(r => r.repo)); + const { unresolved, warnings: refWarnings } = resolveReferences(reports, registry, repoNames); + + // Count resolved: total refs from external_refs fields minus unresolved + const totalExternalRefs = reports.reduce( + (sum, r) => sum + (r.model.external_refs?.length || 0), 0, + ); + const resolvedCount = Math.max(0, totalExternalRefs - unresolved.length); + + // 4. Combine models + const combinedModel = combineModels(reports); + + // 5. Detect stale reports + const staleWarnings = detectStaleReports(statuses, staleHours); + + // 6. Schema mismatch warnings + const schemaWarnings = detectSchemaMismatch(reports); + + // Determine workspace name + const workspaceName = options.workspace + || reports.find(r => r.model.metadata?.workspace)?.model.metadata?.workspace + || 'workspace'; + + // Assemble all warnings + const allWarnings = [...tagWarnings, ...refWarnings, ...staleWarnings, ...schemaWarnings]; + + // Missing repo warnings + for (const s of statuses) { + if (!s.loaded) { + allWarnings.push({ + level: 'warning', + code: 'missing_repo', + message: `Repo "${s.name}" report not loaded: ${s.error || 'unknown error'}`, + repos: [s.name], + }); + } + } + + return { + workspace: workspaceName, + merged_at: new Date().toISOString(), + schema_version: REPORT_SCHEMA_VERSION, + repo_statuses: statuses, + tag_registry: registry, + unresolved_refs: unresolved, + warnings: allWarnings, + totals: computeTotals(combinedModel, statuses, resolvedCount, unresolved.length), + model: combinedModel, + }; +} + +// ─── Helper Functions ───────────────────────────────────────────────── + +function detectStaleReports(statuses: RepoStatus[], staleHours: number): MergeWarning[] { + const warnings: MergeWarning[] = []; + const now = Date.now(); + const threshold = staleHours * 60 * 60 * 1000; + + for (const s of statuses) { + if (!s.loaded || !s.generated_at) continue; + const age = now - new Date(s.generated_at).getTime(); + if (age > threshold) { + const days = Math.round(age / (24 * 60 * 60 * 1000)); + warnings.push({ + level: 'warning', + code: 'stale_report', + message: `Repo "${s.name}" report is ${days} day(s) old (generated ${s.generated_at})`, + repos: [s.name], + }); + } + } + return warnings; +} + +function detectSchemaMismatch(reports: LoadedReport[]): MergeWarning[] { + const versions = new Set(reports.map(r => r.model.metadata?.schema_version).filter(Boolean)); + if (versions.size <= 1) return []; + + return [{ + level: 'warning', + code: 'schema_mismatch', + message: `Reports use different schema versions: ${[...versions].join(', ')}. Results may be inconsistent.`, + repos: reports.map(r => r.repo), + }]; +} + +function emptyMergedReport(workspace: string, statuses: RepoStatus[]): MergedReport { + return { + workspace, + merged_at: new Date().toISOString(), + schema_version: REPORT_SCHEMA_VERSION, + repo_statuses: statuses, + tag_registry: [], + unresolved_refs: [], + warnings: statuses.map(s => ({ + level: 'warning' as const, + code: 'missing_repo' as MergeWarningCode, + message: `Repo "${s.name}" report not loaded: ${s.error || 'unknown error'}`, + repos: [s.name], + })), + totals: { + repos: statuses.length, repos_loaded: 0, annotations: 0, assets: 0, + threats: 0, controls: 0, mitigations: 0, exposures: 0, + unmitigated_exposures: 0, acceptances: 0, flows: 0, boundaries: 0, + external_refs_resolved: 0, external_refs_unresolved: 0, + }, + model: { + version: REPORT_SCHEMA_VERSION, project: workspace, + generated_at: new Date().toISOString(), source_files: 0, + annotations_parsed: 0, annotated_files: [], unannotated_files: [], + assets: [], threats: [], controls: [], mitigations: [], exposures: [], + acceptances: [], transfers: [], flows: [], boundaries: [], + validations: [], audits: [], ownership: [], data_handling: [], + assumptions: [], shields: [], comments: [], + coverage: { total_symbols: 0, annotated_symbols: 0, coverage_percent: 0, unannotated_critical: [] }, + }, + }; +} + +// ─── Merge Diff (--diff-against) ───────────────────────────────────── + +/** + * Compute a diff summary between two merged reports. + * Used for weekly "what changed" output. + */ +export function diffMergedReports( + current: MergedReport, + previous: MergedReport, +): MergeDiffSummary { + const c = current.totals; + const p = previous.totals; + + const prevRepoNames = new Set(previous.repo_statuses.map(s => s.name)); + const currRepoNames = new Set(current.repo_statuses.map(s => s.name)); + + // Repos with changed annotation counts or new commits + const reposWithChanges: string[] = []; + for (const cs of current.repo_statuses) { + const ps = previous.repo_statuses.find(s => s.name === cs.name); + if (!ps) continue; // new repo, handled separately + if (cs.annotation_count !== ps.annotation_count || cs.commit_sha !== ps.commit_sha) { + reposWithChanges.push(cs.name); + } + } + + const newUnmitigated = c.unmitigated_exposures - p.unmitigated_exposures; + const riskDelta: 'increased' | 'decreased' | 'unchanged' = + newUnmitigated > 0 ? 'increased' : newUnmitigated < 0 ? 'decreased' : 'unchanged'; + + return { + previous_merged_at: previous.merged_at, + current_merged_at: current.merged_at, + assets_added: Math.max(0, c.assets - p.assets), + assets_removed: Math.max(0, p.assets - c.assets), + threats_added: Math.max(0, c.threats - p.threats), + threats_removed: Math.max(0, p.threats - c.threats), + mitigations_added: Math.max(0, c.mitigations - p.mitigations), + mitigations_removed: Math.max(0, p.mitigations - c.mitigations), + exposures_added: Math.max(0, c.exposures - p.exposures), + exposures_removed: Math.max(0, p.exposures - c.exposures), + new_unmitigated: Math.max(0, newUnmitigated), + resolved_unmitigated: Math.max(0, -newUnmitigated), + risk_delta: riskDelta, + new_flows: Math.max(0, c.flows - p.flows), + removed_flows: Math.max(0, p.flows - c.flows), + new_unresolved_refs: Math.max(0, c.external_refs_unresolved - p.external_refs_unresolved), + resolved_refs: Math.max(0, p.external_refs_unresolved - c.external_refs_unresolved), + repos_added: [...currRepoNames].filter(n => !prevRepoNames.has(n)), + repos_removed: [...prevRepoNames].filter(n => !currRepoNames.has(n)), + repos_with_changes: reposWithChanges, + }; +} + +/** + * Format a diff summary as markdown for weekly reports / Slack / email. + */ +export function formatDiffSummary(diff: MergeDiffSummary, workspace: string): string { + const lines: string[] = []; + const riskIcon = diff.risk_delta === 'increased' ? '🔴' + : diff.risk_delta === 'decreased' ? '🟢' : '⚪'; + + lines.push(`# ${workspace} — Weekly Threat Model Changes`); + lines.push(''); + lines.push(`**Period:** ${diff.previous_merged_at.slice(0, 10)} → ${diff.current_merged_at.slice(0, 10)}`); + lines.push(`**Risk trend:** ${riskIcon} ${diff.risk_delta}`); + lines.push(''); + + // Deltas + lines.push('## Changes'); + lines.push(''); + const deltas: string[] = []; + if (diff.assets_added) deltas.push(`+${diff.assets_added} new asset(s)`); + if (diff.assets_removed) deltas.push(`-${diff.assets_removed} removed asset(s)`); + if (diff.threats_added) deltas.push(`+${diff.threats_added} new threat(s)`); + if (diff.threats_removed) deltas.push(`-${diff.threats_removed} removed threat(s)`); + if (diff.mitigations_added) deltas.push(`+${diff.mitigations_added} new mitigation(s)`); + if (diff.mitigations_removed) deltas.push(`-${diff.mitigations_removed} removed mitigation(s) ⚠️`); + if (diff.exposures_added) deltas.push(`+${diff.exposures_added} new exposure(s)`); + if (diff.exposures_removed) deltas.push(`-${diff.exposures_removed} resolved exposure(s)`); + if (diff.new_flows) deltas.push(`+${diff.new_flows} new data flow(s)`); + if (diff.removed_flows) deltas.push(`-${diff.removed_flows} removed data flow(s)`); + + if (deltas.length === 0) { + lines.push('No annotation changes this period.'); + } else { + for (const d of deltas) lines.push(`- ${d}`); + } + lines.push(''); + + // Risk highlights + if (diff.new_unmitigated > 0 || diff.resolved_unmitigated > 0) { + lines.push('## Risk'); + lines.push(''); + if (diff.new_unmitigated > 0) lines.push(`- 🔴 ${diff.new_unmitigated} new unmitigated exposure(s)`); + if (diff.resolved_unmitigated > 0) lines.push(`- 🟢 ${diff.resolved_unmitigated} exposure(s) now mitigated`); + lines.push(''); + } + + // Cross-repo refs + if (diff.new_unresolved_refs > 0 || diff.resolved_refs > 0) { + lines.push('## Cross-Repo References'); + lines.push(''); + if (diff.new_unresolved_refs > 0) lines.push(`- ⚠️ ${diff.new_unresolved_refs} new unresolved ref(s)`); + if (diff.resolved_refs > 0) lines.push(`- ✓ ${diff.resolved_refs} ref(s) now resolved`); + lines.push(''); + } + + // Repo changes + if (diff.repos_added.length > 0 || diff.repos_removed.length > 0 || diff.repos_with_changes.length > 0) { + lines.push('## Repos'); + lines.push(''); + for (const r of diff.repos_added) lines.push(`- 🆕 ${r} (new)`); + for (const r of diff.repos_removed) lines.push(`- ❌ ${r} (removed)`); + for (const r of diff.repos_with_changes) lines.push(`- 📝 ${r} (updated)`); + lines.push(''); + } + + return lines.join('\n'); +} + +// ─── Merge Summary Markdown ────────────────────────────────────────── + +/** + * Generate a human-readable markdown summary of a merged report. + * Used for terminal output, weekly emails, and Slack notifications. + */ +export function formatMergeSummary(merged: MergedReport): string { + const lines: string[] = []; + const t = merged.totals; + + lines.push(`# ${merged.workspace} — Threat Model Summary`); + lines.push(''); + lines.push(`**Generated:** ${merged.merged_at}`); + lines.push(`**Repos:** ${t.repos_loaded}/${t.repos} loaded`); + lines.push(''); + + // Totals + lines.push('## Overview'); + lines.push(''); + lines.push(`| Metric | Count |`); + lines.push(`|--------|-------|`); + lines.push(`| Annotations | ${t.annotations} |`); + lines.push(`| Assets | ${t.assets} |`); + lines.push(`| Threats | ${t.threats} |`); + lines.push(`| Controls | ${t.controls} |`); + lines.push(`| Mitigations | ${t.mitigations} |`); + lines.push(`| Exposures | ${t.exposures} |`); + lines.push(`| Unmitigated | ${t.unmitigated_exposures} |`); + lines.push(`| Data flows | ${t.flows} |`); + lines.push(`| Cross-repo refs resolved | ${t.external_refs_resolved} |`); + lines.push(`| Cross-repo refs unresolved | ${t.external_refs_unresolved} |`); + lines.push(''); + + // Repo statuses + lines.push('## Repos'); + lines.push(''); + for (const s of merged.repo_statuses) { + const status = s.loaded ? '✓' : '✗'; + const detail = s.loaded + ? `${s.annotation_count || 0} annotations, commit ${(s.commit_sha || '').slice(0, 7)}` + : `MISSING — ${s.error || 'no report'}`; + lines.push(`- ${status} **${s.name}** — ${detail}`); + } + lines.push(''); + + // Warnings + const errors = merged.warnings.filter(w => w.level === 'error'); + const warns = merged.warnings.filter(w => w.level === 'warning'); + if (errors.length > 0 || warns.length > 0) { + lines.push('## Warnings'); + lines.push(''); + for (const w of [...errors, ...warns]) { + const icon = w.level === 'error' ? '🔴' : '⚠️'; + lines.push(`- ${icon} ${w.message}`); + } + lines.push(''); + } + + // Unresolved refs + if (merged.unresolved_refs.length > 0) { + lines.push('## Unresolved Cross-Repo References'); + lines.push(''); + for (const u of merged.unresolved_refs) { + const inferred = u.inferred_repo ? ` (expected in ${u.inferred_repo})` : ''; + lines.push(`- \`${u.tag}\` referenced in ${u.source_repo}${inferred}`); + } + lines.push(''); + } + + return lines.join('\n'); +} + diff --git a/src/workspace/metadata.ts b/src/workspace/metadata.ts new file mode 100644 index 0000000..8ed7afa --- /dev/null +++ b/src/workspace/metadata.ts @@ -0,0 +1,186 @@ +/** + * GuardLink Workspace — Report metadata population. + * + * Enriches a ThreatModel with provenance metadata (git SHA, branch, + * workspace info) for the report JSON contract. + * + * @asset Workspace.Metadata (#report-metadata) -- "Report provenance data" + * @flows GitRepo -> #report-metadata via execSync -- "Git info extraction" + * @flows #report-metadata -> ThreatModel via populateMetadata -- "Metadata injection" + */ + +import { execSync } from 'node:child_process'; +import { readFileSync, existsSync } from 'node:fs'; +import { join } from 'node:path'; +import type { ThreatModel, ReportMetadata } from '../types/index.js'; +import type { WorkspaceConfig } from './types.js'; + +/** Current report JSON schema version */ +export const REPORT_SCHEMA_VERSION = '1.0.0'; + +/** + * Get the guardlink package version at runtime. + * Falls back to 'unknown' if not determinable. + */ +function getGuardlinkVersion(): string { + try { + // Walk up from this file to find package.json + const pkgPath = new URL('../../package.json', import.meta.url); + const pkg = JSON.parse(readFileSync(pkgPath, 'utf-8')); + return pkg.version || 'unknown'; + } catch { + return 'unknown'; + } +} + +/** + * Get git commit SHA (full) for the given directory. + * Returns null if not a git repo. + */ +function getCommitSha(root: string): string | null { + try { + return execSync('git rev-parse HEAD', { cwd: root, encoding: 'utf-8', stdio: 'pipe' }).trim(); + } catch { + return null; + } +} + +/** + * Get current git branch name. + * Returns null if detached HEAD or not a git repo. + */ +function getBranch(root: string): string | null { + try { + const branch = execSync('git rev-parse --abbrev-ref HEAD', { cwd: root, encoding: 'utf-8', stdio: 'pipe' }).trim(); + return branch === 'HEAD' ? null : branch; // detached HEAD + } catch { + return null; + } +} + +/** + * Try to load workspace.yaml from the project's .guardlink/ directory. + * Returns null if not found or invalid. + */ +export function loadWorkspaceConfig(root: string): WorkspaceConfig | null { + const yamlPath = join(root, '.guardlink', 'workspace.yaml'); + if (!existsSync(yamlPath)) return null; + + try { + const content = readFileSync(yamlPath, 'utf-8'); + return parseWorkspaceYaml(content); + } catch { + return null; + } +} + +/** + * Parse workspace.yaml content (simple YAML subset — no dependency needed). + * Supports the flat structure we define; falls back gracefully on malformed input. + */ +export function parseWorkspaceYaml(content: string): WorkspaceConfig { + const lines = content.split('\n').map(l => l.trimEnd()); + const config: Partial = { repos: [] }; + + let inRepos = false; + let currentRepo: Partial<{ name: string; registry: string }> | null = null; + + for (const line of lines) { + const trimmed = line.trim(); + if (!trimmed || trimmed.startsWith('#')) continue; + + // Top-level scalar fields + if (!line.startsWith(' ') && !line.startsWith('\t') && trimmed.includes(':')) { + // Flush pending repo + if (currentRepo?.name) { + config.repos!.push({ name: currentRepo.name, registry: currentRepo.registry }); + currentRepo = null; + } + + const [key, ...rest] = trimmed.split(':'); + const value = rest.join(':').trim().replace(/^["']|["']$/g, ''); + + if (key === 'workspace') config.workspace = value; + else if (key === 'this_repo') config.this_repo = value; + else if (key === 'shared_definitions' && value && value !== 'null') config.shared_definitions = value; + else if (key === 'repos') { inRepos = true; continue; } + else inRepos = false; + continue; + } + + // Inside repos list + if (inRepos && trimmed.startsWith('- ')) { + // Flush pending repo + if (currentRepo?.name) { + config.repos!.push({ name: currentRepo.name, registry: currentRepo.registry }); + } + currentRepo = {}; + // Handle "- name: value" shorthand + const afterDash = trimmed.slice(2).trim(); + if (afterDash.startsWith('name:')) { + currentRepo.name = afterDash.slice(5).trim().replace(/^["']|["']$/g, ''); + } + continue; + } + + // Repo sub-fields (indented under - ) + if (inRepos && currentRepo && (line.startsWith(' ') || line.startsWith('\t\t'))) { + const [key, ...rest] = trimmed.split(':'); + const value = rest.join(':').trim().replace(/^["']|["']$/g, ''); + if (key === 'name') currentRepo.name = value; + else if (key === 'registry') currentRepo.registry = value; + } + } + + // Flush last repo + if (currentRepo?.name) { + config.repos!.push({ name: currentRepo.name, registry: currentRepo.registry }); + } + + if (!config.workspace) throw new Error('workspace.yaml missing "workspace" field'); + if (!config.this_repo) throw new Error('workspace.yaml missing "this_repo" field'); + + return config as WorkspaceConfig; +} + +/** + * Generate workspace.yaml content from a WorkspaceConfig. + */ +export function serializeWorkspaceYaml(config: WorkspaceConfig): string { + const lines: string[] = []; + lines.push(`workspace: ${config.workspace}`); + lines.push(`this_repo: ${config.this_repo}`); + lines.push('repos:'); + for (const repo of config.repos) { + lines.push(` - name: ${repo.name}`); + if (repo.registry) lines.push(` registry: ${repo.registry}`); + } + if (config.shared_definitions) { + lines.push(`shared_definitions: ${config.shared_definitions}`); + } + return lines.join('\n') + '\n'; +} + +/** + * Enrich a ThreatModel with provenance metadata. + * Call this before writing the report JSON. + */ +export function populateMetadata(model: ThreatModel, root: string): ThreatModel { + const workspace = loadWorkspaceConfig(root); + + const metadata: ReportMetadata = { + schema_version: REPORT_SCHEMA_VERSION, + guardlink_version: getGuardlinkVersion(), + repo: workspace?.this_repo || model.project, + commit_sha: getCommitSha(root), + branch: getBranch(root), + generated_at: model.generated_at, + ...(workspace?.workspace && { workspace: workspace.workspace }), + }; + + return { + ...model, + metadata, + external_refs: model.external_refs || [], + }; +} diff --git a/src/workspace/types.ts b/src/workspace/types.ts new file mode 100644 index 0000000..5e9a5ea --- /dev/null +++ b/src/workspace/types.ts @@ -0,0 +1,161 @@ +/** + * GuardLink Workspace — Types for multi-repo linking. + * + * workspace.yaml lives in each repo's .guardlink/ directory. + * It declares membership in a workspace and lists sibling repos. + * + * @asset Workspace.Config (#workspace-config) -- "Multi-repo workspace definition" + * @threat Config_Tampering (#config-tamper) [medium] cwe:CWE-15 -- "Malicious workspace.yaml could misdirect agent annotations" + * @mitigates #workspace-config against #config-tamper using #yaml-validation -- "Schema validation on load" + */ + +import type { ThreatModel, Severity, ExternalRef } from '../types/index.js'; + +// ─── Workspace Configuration (workspace.yaml) ─────────────────────── + +/** A single repo in the workspace */ +export interface WorkspaceRepo { + /** Short name used as tag prefix (e.g. "payment-service") */ + name: string; + /** Remote registry URL (e.g. "github.com/unstructured/payment-service") */ + registry?: string; + /** Local path — only used during link-project setup, not stored in yaml */ + local_path?: string; +} + +/** Workspace configuration stored in .guardlink/workspace.yaml */ +export interface WorkspaceConfig { + /** Workspace name (e.g. "unstructured-platform") */ + workspace: string; + /** This repo's name within the workspace */ + this_repo: string; + /** All repos in the workspace (including this one) */ + repos: WorkspaceRepo[]; + /** URL to shared definitions file (optional) */ + shared_definitions?: string; +} + +// ─── Merge Types ───────────────────────────────────────────────────── + +/** Tag ownership record: which repo defines a given tag */ +export interface TagOwnership { + /** The tag (e.g. "#payment-svc.refund") */ + tag: string; + /** Repo that defines this tag */ + owner_repo: string; + /** What it defines: asset, threat, or control */ + kind: 'asset' | 'threat' | 'control'; +} + +/** A cross-repo reference that could not be resolved during merge */ +export interface UnresolvedRef extends ExternalRef { + /** Repo where this reference was found */ + source_repo: string; +} + +/** Warning emitted during merge */ +export interface MergeWarning { + level: 'error' | 'warning' | 'info'; + code: MergeWarningCode; + message: string; + repos?: string[]; + tag?: string; +} + +export type MergeWarningCode = + | 'duplicate_tag' // Same tag defined in multiple repos + | 'unresolved_ref' // Tag referenced but not defined anywhere + | 'missing_repo' // Workspace repo has no report (stale/missing) + | 'schema_mismatch' // Report schema_version differs across repos + | 'tag_prefix_mismatch' // Tag prefix doesn't match any known repo name + | 'stale_report'; // Report older than threshold + +/** Per-repo status in a merged report */ +export interface RepoStatus { + name: string; + /** Whether we successfully loaded this repo's report */ + loaded: boolean; + /** ISO timestamp of when the report was generated */ + generated_at?: string; + /** Commit SHA from the report */ + commit_sha?: string; + /** Count of annotations in this repo */ + annotation_count?: number; + /** Why this repo is missing (if loaded=false) */ + error?: string; +} + +/** The merged output combining all repo reports */ +export interface MergedReport { + /** Workspace name */ + workspace: string; + /** ISO timestamp of when merge was run */ + merged_at: string; + /** Schema version of the merged report */ + schema_version: string; + /** Status of each repo */ + repo_statuses: RepoStatus[]; + /** Unified tag registry: who owns each tag */ + tag_registry: TagOwnership[]; + /** Unresolved cross-repo references */ + unresolved_refs: UnresolvedRef[]; + /** Warnings from merge process */ + warnings: MergeWarning[]; + + /** Aggregated totals */ + totals: MergeTotals; + /** The combined threat model (all repos merged) */ + model: ThreatModel; +} + +export interface MergeTotals { + repos: number; + repos_loaded: number; + annotations: number; + assets: number; + threats: number; + controls: number; + mitigations: number; + exposures: number; + unmitigated_exposures: number; + acceptances: number; + flows: number; + boundaries: number; + external_refs_resolved: number; + external_refs_unresolved: number; +} + +// ─── Merge Diff (--diff-against) ──────────────────────────────────── + +/** Delta between two merged reports (weekly summary) */ +export interface MergeDiffSummary { + /** Time range */ + previous_merged_at: string; + current_merged_at: string; + + /** Per-category deltas */ + assets_added: number; + assets_removed: number; + threats_added: number; + threats_removed: number; + mitigations_added: number; + mitigations_removed: number; + exposures_added: number; + exposures_removed: number; + + /** Risk-relevant */ + new_unmitigated: number; + resolved_unmitigated: number; + risk_delta: 'increased' | 'decreased' | 'unchanged'; + + /** Cross-repo changes */ + new_flows: number; + removed_flows: number; + new_unresolved_refs: number; + resolved_refs: number; + + /** Repos changed */ + repos_added: string[]; + repos_removed: string[]; + repos_with_changes: string[]; +}