Skip to content

Commit c5dd2a3

Browse files
authored
Add issue assistant workflow for automated triage (#138)
* Add issue assistant workflow for automated triage Signed-off-by: Dima Birenbaum <dvlasenko86@gmail.com> * Enhance issue assistant workflow with better logging Refactor wiki integration and AI response handling in issue assistant workflow. Improved logging and error handling. Signed-off-by: Dima Birenbaum <dvlasenko86@gmail.com> * Add workflow to refresh wiki cache daily This workflow refreshes the wiki cache daily and allows manual triggering. It clones the wiki repository, builds a context file from various markdown files, and commits changes if there are updates. Signed-off-by: Dima Birenbaum <dvlasenko86@gmail.com> * Add security validation module for issue assistant Implement security validation module for MSDO Issue Assistant, including prompt injection detection, suspicious content detection, rate limiting, and input sanitization. Signed-off-by: Dima Birenbaum <dvlasenko86@gmail.com> * Enhance issue assistant workflow with protections and fixes Updated issue assistant workflow to include bot-loop protection and improved error handling for API calls. Signed-off-by: Dima Birenbaum <dvlasenko86@gmail.com> * Refactor security.js to enhance regex safety and reduce comments Removed extensive comments and added safety measures for regex flags. Signed-off-by: Dima Birenbaum <dvlasenko86@gmail.com> * Refactor issue assistant workflow for clarity and safety Signed-off-by: Dima Birenbaum <dvlasenko86@gmail.com> * Update wiki cache workflow for date in commit message Signed-off-by: Dima Birenbaum <dvlasenko86@gmail.com> * Update issue-assistant.yml Signed-off-by: Dima Birenbaum <dvlasenko86@gmail.com> * Document security validation module design and patterns Added security validation module documentation and design overview. Signed-off-by: Dima Birenbaum <dvlasenko86@gmail.com> --------- Signed-off-by: Dima Birenbaum <dvlasenko86@gmail.com>
1 parent b0a347f commit c5dd2a3

File tree

3 files changed

+732
-0
lines changed

3 files changed

+732
-0
lines changed
Lines changed: 260 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,260 @@
1+
/**
2+
* Security Validation Module for MSDO Issue Assistant
3+
*
4+
* SECURITY DESIGN:
5+
* - Core detection logic is in code (open source)
6+
* - Specific patterns can be overridden via GitHub Secrets (hidden)
7+
* - This prevents attackers from seeing exact patterns to bypass
8+
*
9+
* Pattern sources (in priority order):
10+
* 1. GitHub Secrets (if provided) - hidden from attackers
11+
* 2. Built-in patterns (visible in code) - baseline protection
12+
*/
13+
14+
// Built-in patterns - provides baseline protection
15+
// Additional/custom patterns can be stored in GitHub Secrets
16+
const DEFAULT_INJECTION_PATTERNS = [
17+
/ignore\s+(all\s+)?(previous|prior)/i,
18+
/disregard\s+(your\s+)?instructions/i,
19+
/you\s+are\s+now/i,
20+
/pretend\s+(to\s+be|you)/i,
21+
/system\s*prompt/i,
22+
/jailbreak/i,
23+
/<\|.*\|>/i,
24+
/\[\[.*\]\]/i,
25+
];
26+
27+
const DEFAULT_SUSPICIOUS_PATTERNS = [
28+
/\@(dependabot|github-actions)/i,
29+
/merge\s+this/i,
30+
/webhook/i,
31+
];
32+
33+
function compilePatterns(secretPatterns, defaultPatterns) {
34+
if (secretPatterns && Array.isArray(secretPatterns)) {
35+
return secretPatterns.map(p => {
36+
if (typeof p === 'string') {
37+
const match = p.match(/^\/(.*)\/([gimsuy]*)$/);
38+
if (match) {
39+
const safeFlags = match[2].replace(/[gy]/g, '');
40+
return new RegExp(match[1], safeFlags);
41+
}
42+
return new RegExp(p, 'i');
43+
}
44+
if (p instanceof RegExp) {
45+
const safeFlags = p.flags.replace(/[gy]/g, '');
46+
return new RegExp(p.source, safeFlags);
47+
}
48+
return p;
49+
});
50+
}
51+
return defaultPatterns;
52+
}
53+
54+
function detectPromptInjection(content, customPatterns) {
55+
const patterns = compilePatterns(customPatterns, DEFAULT_INJECTION_PATTERNS);
56+
const normalizedContent = content
57+
.replace(/\s+/g, ' ')
58+
.replace(/[^\x20-\x7E\s]/g, ' ');
59+
60+
const detected = [];
61+
for (const pattern of patterns) {
62+
if (pattern.test(normalizedContent)) {
63+
detected.push('pattern_match');
64+
}
65+
}
66+
67+
return {
68+
detected: detected.length > 0,
69+
count: detected.length
70+
};
71+
}
72+
73+
function detectSuspiciousContent(content, customPatterns) {
74+
const patterns = compilePatterns(customPatterns, DEFAULT_SUSPICIOUS_PATTERNS);
75+
const detected = [];
76+
77+
for (const pattern of patterns) {
78+
if (pattern.test(content)) {
79+
detected.push('suspicious_match');
80+
}
81+
}
82+
83+
const words = content.toLowerCase().split(/\s+/);
84+
const wordCounts = {};
85+
for (const word of words) {
86+
wordCounts[word] = (wordCounts[word] || 0) + 1;
87+
}
88+
const maxRepetition = Math.max(...Object.values(wordCounts), 0);
89+
if (maxRepetition > 50) {
90+
detected.push('excessive_repetition');
91+
}
92+
93+
return {
94+
detected: detected.length > 0,
95+
count: detected.length
96+
};
97+
}
98+
99+
async function checkRateLimit(github, context, userId, limitPerHour) {
100+
const oneHourAgo = new Date(Date.now() - 60 * 60 * 1000).toISOString();
101+
102+
try {
103+
let responseCount = 0;
104+
let page = 1;
105+
const perPage = 100;
106+
107+
while (true) {
108+
const { data: comments } = await github.rest.issues.listCommentsForRepo({
109+
owner: context.repo.owner,
110+
repo: context.repo.repo,
111+
since: oneHourAgo,
112+
per_page: perPage,
113+
page: page
114+
});
115+
116+
if (comments.length === 0) break;
117+
118+
for (const comment of comments) {
119+
if (comment.body && comment.body.includes('<!-- msdo-issue-assistant -->')) {
120+
try {
121+
const { data: issue } = await github.rest.issues.get({
122+
owner: context.repo.owner,
123+
repo: context.repo.repo,
124+
issue_number: comment.issue_url.split('/').pop()
125+
});
126+
127+
if (issue.user && issue.user.id === userId) {
128+
responseCount++;
129+
}
130+
} catch (e) {
131+
responseCount++;
132+
}
133+
}
134+
}
135+
136+
if (comments.length < perPage) break;
137+
page++;
138+
139+
if (page > 10) break;
140+
}
141+
142+
return {
143+
allowed: responseCount < limitPerHour,
144+
currentCount: responseCount
145+
};
146+
} catch (error) {
147+
console.error('Rate limit check failed:', error.message);
148+
return { allowed: false, error: error.message };
149+
}
150+
}
151+
152+
function sanitizeInput(content, maxLength) {
153+
if (!content) return '';
154+
155+
let sanitized = content
156+
.replace(/[\x00-\x08\x0B\x0C\x0E-\x1F\x7F]/g, '')
157+
.replace(/[^\S\r\n]+/g, ' ')
158+
.replace(/\n{3,}/g, '\n\n')
159+
.trim();
160+
161+
if (sanitized.length > maxLength) {
162+
sanitized = sanitized.substring(0, maxLength) + '... [truncated]';
163+
}
164+
165+
return sanitized;
166+
}
167+
168+
function detectIssueType(title, body) {
169+
const content = (title + ' ' + body).toLowerCase();
170+
171+
const bugScore = ['bug', 'error', 'fail', 'crash', 'broken', 'not working'].filter(w => content.includes(w)).length;
172+
const featureScore = ['feature', 'request', 'enhancement', 'suggestion', 'add support'].filter(w => content.includes(w)).length;
173+
const questionScore = ['how to', 'how do', 'question', 'help', 'possible'].filter(w => content.includes(w)).length;
174+
175+
if (bugScore === 0 && featureScore === 0 && questionScore === 0) return 'unknown';
176+
if (bugScore >= featureScore && bugScore >= questionScore) return 'bug';
177+
if (featureScore >= questionScore) return 'feature';
178+
return 'question';
179+
}
180+
181+
async function validateRequest({
182+
github,
183+
context,
184+
maxInputLength,
185+
rateLimitPerHour,
186+
customInjectionPatterns,
187+
customSuspiciousPatterns
188+
}) {
189+
const errors = [];
190+
const issue = context.payload.issue;
191+
const comment = context.payload.comment;
192+
193+
const content = comment ? comment.body : issue.body;
194+
const title = issue.title || '';
195+
const userId = comment ? comment.user.login : issue.user.login;
196+
const userIdNum = comment ? comment.user.id : issue.user.id;
197+
const userType = comment ? comment.user.type : issue.user.type;
198+
199+
if (userType === 'Bot') {
200+
errors.push('Bot users not processed');
201+
return { shouldRespond: false, errors };
202+
}
203+
204+
if (!content || content.length === 0) {
205+
errors.push('Empty content');
206+
return { shouldRespond: false, errors };
207+
}
208+
209+
if (content.length > maxInputLength) {
210+
errors.push('Content exceeds maximum length');
211+
}
212+
213+
const injectionCheck = detectPromptInjection(content, customInjectionPatterns);
214+
if (injectionCheck.detected) {
215+
errors.push('Potential prompt injection detected');
216+
console.log('Injection attempt from ' + userId + ': ' + injectionCheck.count + ' patterns matched');
217+
}
218+
219+
const suspiciousCheck = detectSuspiciousContent(content, customSuspiciousPatterns);
220+
if (suspiciousCheck.detected) {
221+
errors.push('Suspicious content detected');
222+
}
223+
224+
const rateLimit = await checkRateLimit(github, context, userIdNum, rateLimitPerHour);
225+
if (!rateLimit.allowed) {
226+
errors.push('Rate limit exceeded');
227+
}
228+
229+
if (comment) {
230+
const { data: comments } = await github.rest.issues.listComments({
231+
owner: context.repo.owner,
232+
repo: context.repo.repo,
233+
issue_number: issue.number
234+
});
235+
236+
const botComments = comments.filter(c =>
237+
c.body && c.body.includes('<!-- msdo-issue-assistant -->')
238+
);
239+
240+
if (botComments.length >= 3) {
241+
errors.push('Maximum bot responses reached');
242+
}
243+
}
244+
245+
return {
246+
shouldRespond: errors.length === 0,
247+
errors,
248+
sanitizedContent: sanitizeInput(content, maxInputLength),
249+
issueType: detectIssueType(title, content)
250+
};
251+
}
252+
253+
module.exports = {
254+
validateRequest,
255+
detectPromptInjection,
256+
detectSuspiciousContent,
257+
sanitizeInput,
258+
detectIssueType,
259+
checkRateLimit
260+
};

0 commit comments

Comments
 (0)