raqib 0.0.0-stage → 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +274 -2
- package/action.yml +81 -0
- package/bin/raqib.js +98 -0
- package/package.json +47 -4
- package/src/discover.js +73 -0
- package/src/index.js +129 -0
- package/src/lib/report.js +166 -0
- package/src/lib/severity.js +41 -0
- package/src/lib/text.js +123 -0
- package/src/rules/budget.js +151 -0
- package/src/rules/coherence.js +151 -0
- package/src/rules/guardrails.js +110 -0
- package/src/rules/hidden.js +121 -0
- package/src/rules/index.js +25 -0
- package/src/rules/injection.js +152 -0
- package/src/rules/secrets.js +82 -0
|
@@ -0,0 +1,151 @@
|
|
|
1
|
+
import { excerpt } from '../lib/text.js';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Whether the instructions can actually be followed.
|
|
5
|
+
*
|
|
6
|
+
* Two failure modes, both silent: the file tells the agent to run a command
|
|
7
|
+
* the repository does not have, and two files tell it opposite things. Neither
|
|
8
|
+
* produces an error. The agent just guesses, and the guess looks like a bug in
|
|
9
|
+
* the model.
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
/** Manifest that must exist for a given command to be runnable. */
|
|
13
|
+
const COMMAND_REQUIRES = [
|
|
14
|
+
{ re: /^(npm|npx|pnpm|yarn|bun)\b/, needs: ['package.json'], label: 'a Node package manifest' },
|
|
15
|
+
{ re: /^(pytest|python -m pytest|tox|ruff|mypy|poetry)\b/, needs: ['pyproject.toml', 'setup.py', 'setup.cfg', 'requirements.txt', 'tox.ini'], label: 'a Python project file' },
|
|
16
|
+
{ re: /^(cargo)\b/, needs: ['Cargo.toml'], label: 'Cargo.toml' },
|
|
17
|
+
{ re: /^(go)\s+(build|test|run|vet)\b/, needs: ['go.mod'], label: 'go.mod' },
|
|
18
|
+
{ re: /^(make)\b/, needs: ['Makefile', 'makefile', 'GNUmakefile'], label: 'a Makefile' },
|
|
19
|
+
{ re: /^(mvn|mvnw)\b/, needs: ['pom.xml'], label: 'pom.xml' },
|
|
20
|
+
{ re: /^(gradle|\.\/gradlew)\b/, needs: ['build.gradle', 'build.gradle.kts', 'settings.gradle'], label: 'a Gradle build file' },
|
|
21
|
+
{ re: /^(bundle|rake|rails)\b/, needs: ['Gemfile'], label: 'a Gemfile' },
|
|
22
|
+
{ re: /^(composer|artisan|php artisan)\b/, needs: ['composer.json'], label: 'composer.json' },
|
|
23
|
+
{ re: /^(docker-compose|docker compose)\b/, needs: ['docker-compose.yml', 'docker-compose.yaml', 'compose.yml', 'compose.yaml'], label: 'a Compose file' },
|
|
24
|
+
];
|
|
25
|
+
|
|
26
|
+
/** Pull `backticked` single-line commands out of a document. */
|
|
27
|
+
function commands(doc) {
|
|
28
|
+
const found = [];
|
|
29
|
+
const re = /`([^`\n]{2,120})`/g;
|
|
30
|
+
let match;
|
|
31
|
+
while ((match = re.exec(doc.content)) !== null) {
|
|
32
|
+
const text = match[1].trim();
|
|
33
|
+
if (!/^[a-z./][\w./-]*(\s|$)/.test(text)) continue;
|
|
34
|
+
found.push({ text, offset: match.index });
|
|
35
|
+
}
|
|
36
|
+
return found;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* Directives of the form "use X" / "never use X", keyed on the identifier.
|
|
41
|
+
* Only backticked or capitalised identifiers count, which keeps this to
|
|
42
|
+
* library and tool names instead of every English noun.
|
|
43
|
+
*/
|
|
44
|
+
const BACKTICKED = /`([^`\n]{2,40})`/;
|
|
45
|
+
const POSITIVE = /\b(use|prefer|always\s+use|adopt|switch\s+to|standardi[sz]e\s+on)\b/i;
|
|
46
|
+
const NEGATIVE = /\b(never\s+use|do\s+not\s+use|don'?t\s+use|avoid\s+using|forbidden|banned|deprecated|no\s+longer\s+use)\b/i;
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* The thing a directive is about, and only when the file names it as code.
|
|
50
|
+
*
|
|
51
|
+
* An earlier version fell back to the first capitalised word, which read
|
|
52
|
+
* "Never use X" as a rule about "Never" and turned every long prose bullet in
|
|
53
|
+
* a real repository into a contradiction. Requiring backticks costs some
|
|
54
|
+
* recall and buys the whole rule its credibility: instruction files name
|
|
55
|
+
* libraries, APIs and commands in backticks as a matter of course.
|
|
56
|
+
*/
|
|
57
|
+
function subjectOf(line, from = 0) {
|
|
58
|
+
const after = /`([^`\n]{2,40})`/g;
|
|
59
|
+
after.lastIndex = from;
|
|
60
|
+
const match = after.exec(line) ?? BACKTICKED.exec(line);
|
|
61
|
+
return match ? match[1].trim().toLowerCase() : null;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
function directives(doc) {
|
|
65
|
+
const out = [];
|
|
66
|
+
doc.lines.forEach((line, index) => {
|
|
67
|
+
// "Never use" contains "use", so the positive test runs on what is left
|
|
68
|
+
// after the negative clause is removed. A line that still reads positive
|
|
69
|
+
// once the prohibition is cut out carries both polarities and no single
|
|
70
|
+
// subject, so it is skipped rather than guessed at.
|
|
71
|
+
const negative = NEGATIVE.exec(line);
|
|
72
|
+
const remainder = negative
|
|
73
|
+
? `${line.slice(0, negative.index)} ${line.slice(negative.index + negative[0].length)}`
|
|
74
|
+
: line;
|
|
75
|
+
const positive = POSITIVE.test(remainder);
|
|
76
|
+
if (negative && positive) return;
|
|
77
|
+
if (!negative && !positive) return;
|
|
78
|
+
const name = subjectOf(line, negative ? negative.index + negative[0].length : 0);
|
|
79
|
+
if (!name || name.length < 2) return;
|
|
80
|
+
out.push({ name, polarity: negative ? 'forbid' : 'require', path: doc.path, line: index + 1, text: line });
|
|
81
|
+
});
|
|
82
|
+
return out;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
export const rules = [
|
|
86
|
+
{
|
|
87
|
+
id: 'RAQIB060',
|
|
88
|
+
severity: 'medium',
|
|
89
|
+
title: 'Command the repository cannot run',
|
|
90
|
+
check: (doc, ctx) => {
|
|
91
|
+
// Without a file listing there is nothing to check against; stay silent
|
|
92
|
+
// rather than guess. A wrong finding here would train people to ignore
|
|
93
|
+
// the tool.
|
|
94
|
+
if (!ctx.repoFiles?.length) return [];
|
|
95
|
+
const names = new Set(ctx.repoFiles.map((f) => f.split('/').pop()));
|
|
96
|
+
const reported = new Set();
|
|
97
|
+
const findings = [];
|
|
98
|
+
for (const { text, offset } of commands(doc)) {
|
|
99
|
+
const rule = COMMAND_REQUIRES.find((r) => r.re.test(text));
|
|
100
|
+
if (!rule || rule.needs.some((n) => names.has(n))) continue;
|
|
101
|
+
if (reported.has(rule.label)) continue;
|
|
102
|
+
reported.add(rule.label);
|
|
103
|
+
const { line, column } = doc.locate(offset);
|
|
104
|
+
findings.push({
|
|
105
|
+
ruleId: 'RAQIB060',
|
|
106
|
+
path: doc.path,
|
|
107
|
+
line,
|
|
108
|
+
column,
|
|
109
|
+
severity: 'medium',
|
|
110
|
+
title: 'Command the repository cannot run',
|
|
111
|
+
message: `The instructions tell the agent to run \`${text}\`, but the repository is missing ${rule.label}.`,
|
|
112
|
+
excerpt: text,
|
|
113
|
+
remediation: 'Fix the command, or delete it. An agent handed a command that fails will invent a replacement.',
|
|
114
|
+
});
|
|
115
|
+
}
|
|
116
|
+
return findings;
|
|
117
|
+
},
|
|
118
|
+
},
|
|
119
|
+
{
|
|
120
|
+
id: 'RAQIB061',
|
|
121
|
+
severity: 'high',
|
|
122
|
+
title: 'Contradictory directives',
|
|
123
|
+
scope: 'project',
|
|
124
|
+
check: (_doc, ctx) => {
|
|
125
|
+
const all = ctx.docs.flatMap(directives);
|
|
126
|
+
const bySubject = new Map();
|
|
127
|
+
for (const d of all) {
|
|
128
|
+
if (!bySubject.has(d.name)) bySubject.set(d.name, []);
|
|
129
|
+
bySubject.get(d.name).push(d);
|
|
130
|
+
}
|
|
131
|
+
const findings = [];
|
|
132
|
+
for (const [name, group] of bySubject) {
|
|
133
|
+
const require = group.find((g) => g.polarity === 'require');
|
|
134
|
+
const forbid = group.find((g) => g.polarity === 'forbid');
|
|
135
|
+
if (!require || !forbid) continue;
|
|
136
|
+
findings.push({
|
|
137
|
+
ruleId: 'RAQIB061',
|
|
138
|
+
path: forbid.path,
|
|
139
|
+
line: forbid.line,
|
|
140
|
+
column: 1,
|
|
141
|
+
severity: 'high',
|
|
142
|
+
title: 'Contradictory directives',
|
|
143
|
+
message: `"${name}" is required at ${require.path}:${require.line} and forbidden at ${forbid.path}:${forbid.line}. Which one wins depends on ordering the agent does not expose.`,
|
|
144
|
+
excerpt: `${excerpt(require.text)} ⟷ ${excerpt(forbid.text)}`,
|
|
145
|
+
remediation: 'Resolve it in the file, not in the model. Delete the stale directive or scope each one to a directory.',
|
|
146
|
+
});
|
|
147
|
+
}
|
|
148
|
+
return findings;
|
|
149
|
+
},
|
|
150
|
+
},
|
|
151
|
+
];
|
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
import { scan, isProhibition } from '../lib/text.js';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Instructions that dismantle the checks standing between the agent and the
|
|
5
|
+
* repository. None of these are exotic. They are the lines people add on a
|
|
6
|
+
* Friday to make the agent stop nagging, and then forget.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
const RULES = [
|
|
10
|
+
{
|
|
11
|
+
id: 'RAQIB040',
|
|
12
|
+
severity: 'critical',
|
|
13
|
+
title: 'Permission prompts disabled',
|
|
14
|
+
pattern: /--dangerously-skip-permissions\b|--yolo\b|\bauto[- ]?approve\s+(all|every|any)\b/i,
|
|
15
|
+
message: 'Turns off the confirmation step for every action the agent takes, including destructive ones.',
|
|
16
|
+
remediation: 'Grant specific tools in an allowlist instead of removing the prompt entirely.',
|
|
17
|
+
},
|
|
18
|
+
{
|
|
19
|
+
id: 'RAQIB041',
|
|
20
|
+
severity: 'critical',
|
|
21
|
+
title: 'Recursive delete',
|
|
22
|
+
pattern: /\brm\s+-[a-zA-Z]*[rR][a-zA-Z]*f[a-zA-Z]*\s+(\/|~|\$HOME|\*)/,
|
|
23
|
+
message: 'A recursive force-delete rooted at the filesystem, the home directory, or a glob.',
|
|
24
|
+
remediation: 'Delete the line. If a clean step is genuinely needed, scope it to a named build directory.',
|
|
25
|
+
},
|
|
26
|
+
{
|
|
27
|
+
id: 'RAQIB042',
|
|
28
|
+
severity: 'high',
|
|
29
|
+
title: 'Confirmation waived',
|
|
30
|
+
pattern: /\b(never|do\s+not|don'?t)\s+(ask|prompt|request|wait)\s+(me\s+|the\s+user\s+)?(for\s+)?(confirmation|permission|approval|input)/i,
|
|
31
|
+
message: 'Removes the human decision point before the agent acts.',
|
|
32
|
+
remediation: 'Name the narrow set of actions that may run unattended rather than waiving confirmation wholesale.',
|
|
33
|
+
allowProhibition: true,
|
|
34
|
+
},
|
|
35
|
+
{
|
|
36
|
+
id: 'RAQIB043',
|
|
37
|
+
severity: 'high',
|
|
38
|
+
title: 'Verification step skipped',
|
|
39
|
+
pattern: /\b(skip|bypass|ignore|disable|don'?t\s+run|do\s+not\s+run|no\s+need\s+to\s+run)\s+(the\s+|any\s+|all\s+)?(tests?|test\s+suite|linting|linter|type\s?check\w*|ci\b|checks?|code\s?review|security\s+(scan|review|check))/i,
|
|
40
|
+
message: 'Tells the agent to ship without the verification the project otherwise runs.',
|
|
41
|
+
remediation: 'If a check is too slow, fix the check. An instruction file that disables it hides the problem from everyone.',
|
|
42
|
+
allowProhibition: true,
|
|
43
|
+
},
|
|
44
|
+
{
|
|
45
|
+
id: 'RAQIB044',
|
|
46
|
+
severity: 'high',
|
|
47
|
+
title: 'Git hooks bypassed',
|
|
48
|
+
pattern: /\bgit\s+(commit|push)\b[^\n]{0,80}--no-verify\b|--no-verify\b[^\n]{0,80}\bgit\s+(commit|push)\b/i,
|
|
49
|
+
message: 'Commits or pushes with pre-commit hooks disabled.',
|
|
50
|
+
remediation: 'Remove the flag. Hooks exist because someone decided the check was worth blocking on.',
|
|
51
|
+
},
|
|
52
|
+
{
|
|
53
|
+
id: 'RAQIB045',
|
|
54
|
+
severity: 'medium',
|
|
55
|
+
title: 'Force push',
|
|
56
|
+
pattern: /\b(git\s+)?push\s+(--force\b(?!-with-lease)|-f\b)/,
|
|
57
|
+
message: 'A force push overwrites history that other people may have pulled.',
|
|
58
|
+
remediation: 'Use --force-with-lease, which refuses when the remote has moved.',
|
|
59
|
+
},
|
|
60
|
+
{
|
|
61
|
+
id: 'RAQIB046',
|
|
62
|
+
severity: 'medium',
|
|
63
|
+
title: 'Direct commit to the default branch',
|
|
64
|
+
pattern: /\b(commit|push)\s+(changes\s+)?(directly\s+)?to\s+(the\s+)?(main|master|trunk)\b/i,
|
|
65
|
+
message: 'Directs the agent to write to the default branch without review.',
|
|
66
|
+
remediation: 'Have the agent branch first. Every review control the repository has hangs off the pull request.',
|
|
67
|
+
},
|
|
68
|
+
{
|
|
69
|
+
id: 'RAQIB047',
|
|
70
|
+
severity: 'medium',
|
|
71
|
+
title: 'Blanket permissions change',
|
|
72
|
+
pattern: /\bchmod\s+(-R\s+)?777\b/,
|
|
73
|
+
message: 'Makes files world-writable.',
|
|
74
|
+
remediation: 'Set the narrowest mode that lets the task run.',
|
|
75
|
+
},
|
|
76
|
+
{
|
|
77
|
+
id: 'RAQIB048',
|
|
78
|
+
severity: 'medium',
|
|
79
|
+
title: 'Unattended privilege escalation',
|
|
80
|
+
pattern: /\bsudo\s+(?!-h|--help)[a-z]/i,
|
|
81
|
+
message: 'The agent is told to run a command as root without a human in the loop.',
|
|
82
|
+
remediation: 'Run privileged steps yourself, or grant the specific capability the task needs.',
|
|
83
|
+
// A documented package install is the overwhelmingly common legitimate
|
|
84
|
+
// use, and reporting four of them at medium buries the one that matters.
|
|
85
|
+
downgradeWhen: /\b(apt|apt-get|dnf|yum|pacman|apk|zypper|brew|snap|port)\b/i,
|
|
86
|
+
downgradeTo: 'low',
|
|
87
|
+
},
|
|
88
|
+
];
|
|
89
|
+
|
|
90
|
+
export const rules = RULES.map((rule) => ({
|
|
91
|
+
id: rule.id,
|
|
92
|
+
severity: rule.severity,
|
|
93
|
+
title: rule.title,
|
|
94
|
+
check: (doc) =>
|
|
95
|
+
scan(doc, rule.pattern, (match, line, context) => {
|
|
96
|
+
// Most rules describe an action, so "never force push" must not fire.
|
|
97
|
+
// The context is the enclosing Markdown block, because prose wraps and
|
|
98
|
+
// the negation is often on the line above. A few rules are themselves
|
|
99
|
+
// phrased as prohibitions ("do not run the tests") and opt out.
|
|
100
|
+
if (!rule.allowProhibition && isProhibition(context)) return null;
|
|
101
|
+
const severity = rule.downgradeWhen?.test(line) ? rule.downgradeTo : rule.severity;
|
|
102
|
+
return {
|
|
103
|
+
ruleId: rule.id,
|
|
104
|
+
severity,
|
|
105
|
+
title: rule.title,
|
|
106
|
+
message: rule.message,
|
|
107
|
+
remediation: rule.remediation,
|
|
108
|
+
};
|
|
109
|
+
}),
|
|
110
|
+
}));
|
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
import { scan, excerpt } from '../lib/text.js';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Text the reviewer cannot see but the agent still reads.
|
|
5
|
+
*
|
|
6
|
+
* GitHub renders Markdown; the agent consumes bytes. Everything in this file
|
|
7
|
+
* is invisible or near-invisible in a pull request diff and fully legible to
|
|
8
|
+
* the model.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
/** Unicode tag block: an exact, invisible mirror of ASCII. Nothing benign uses it in prose. */
|
|
12
|
+
const TAG_BLOCK = /[\u{E0000}-\u{E007F}]+/gu;
|
|
13
|
+
|
|
14
|
+
/** Bidirectional overrides, the Trojan Source class (CVE-2021-42574). */
|
|
15
|
+
const BIDI = /[--]+/gu;
|
|
16
|
+
|
|
17
|
+
/** Zero-width and invisible separators. */
|
|
18
|
+
const ZERO_WIDTH = /[--]+/gu;
|
|
19
|
+
|
|
20
|
+
const codePoints = (text) =>
|
|
21
|
+
[...text].map((c) => `U+${c.codePointAt(0).toString(16).toUpperCase().padStart(4, '0')}`).join(' ');
|
|
22
|
+
|
|
23
|
+
/** Decode the tag block back to the ASCII it mirrors, so the report shows the payload. */
|
|
24
|
+
const decodeTags = (text) =>
|
|
25
|
+
[...text]
|
|
26
|
+
.map((c) => String.fromCharCode(c.codePointAt(0) - 0xe0000))
|
|
27
|
+
.join('')
|
|
28
|
+
.replace(/[^\x20-\x7e]/g, '');
|
|
29
|
+
|
|
30
|
+
const IMPERATIVE = /\b(ignore|disregard|you\s+must|you\s+should|always|never|do\s+not|don'?t|send|post|read|execute|run|delete|remove|instead|override|instruction)\b/i;
|
|
31
|
+
|
|
32
|
+
export const rules = [
|
|
33
|
+
{
|
|
34
|
+
id: 'RAQIB020',
|
|
35
|
+
severity: 'critical',
|
|
36
|
+
title: 'Invisible Unicode tag payload',
|
|
37
|
+
check: (doc) =>
|
|
38
|
+
scan(doc, TAG_BLOCK, (match) => ({
|
|
39
|
+
ruleId: 'RAQIB020',
|
|
40
|
+
severity: 'critical',
|
|
41
|
+
title: 'Invisible Unicode tag payload',
|
|
42
|
+
message: `${[...match[0]].length} character(s) from the Unicode tag block, which renders as nothing and decodes to: "${excerpt(decodeTags(match[0]))}"`,
|
|
43
|
+
remediation: 'Delete the characters and treat the commit that introduced them as hostile.',
|
|
44
|
+
})),
|
|
45
|
+
},
|
|
46
|
+
{
|
|
47
|
+
id: 'RAQIB021',
|
|
48
|
+
severity: 'critical',
|
|
49
|
+
title: 'Bidirectional override character',
|
|
50
|
+
check: (doc) =>
|
|
51
|
+
scan(doc, BIDI, (match) => ({
|
|
52
|
+
ruleId: 'RAQIB021',
|
|
53
|
+
severity: 'critical',
|
|
54
|
+
title: 'Bidirectional override character',
|
|
55
|
+
message: `Text-direction control characters (${codePoints(match[0])}) can make a line display in an order different from the one the agent reads.`,
|
|
56
|
+
remediation: 'Delete the control characters. Genuine right-to-left content does not need explicit overrides.',
|
|
57
|
+
})),
|
|
58
|
+
},
|
|
59
|
+
{
|
|
60
|
+
id: 'RAQIB022',
|
|
61
|
+
severity: 'high',
|
|
62
|
+
title: 'Zero-width characters',
|
|
63
|
+
check: (doc) =>
|
|
64
|
+
scan(doc, ZERO_WIDTH, (match) => ({
|
|
65
|
+
ruleId: 'RAQIB022',
|
|
66
|
+
severity: 'high',
|
|
67
|
+
title: 'Zero-width characters',
|
|
68
|
+
message: `Invisible characters (${codePoints(match[0])}) embedded in the instructions.`,
|
|
69
|
+
remediation:
|
|
70
|
+
'Usually a copy-paste artefact, occasionally a smuggled payload. Strip them either way: they cost tokens and hide diffs.',
|
|
71
|
+
})),
|
|
72
|
+
},
|
|
73
|
+
{
|
|
74
|
+
id: 'RAQIB023',
|
|
75
|
+
severity: 'high',
|
|
76
|
+
title: 'Directive hidden in an HTML comment',
|
|
77
|
+
/**
|
|
78
|
+
* GitHub hides HTML comments when it renders Markdown. The agent does not.
|
|
79
|
+
* A comment holding prose is fine; one holding orders is a hiding place.
|
|
80
|
+
*/
|
|
81
|
+
check: (doc) =>
|
|
82
|
+
scan(doc, /<!--([\s\S]{0,2000}?)-->/g, (match) => {
|
|
83
|
+
const body = match[1];
|
|
84
|
+
if (!IMPERATIVE.test(body)) return null;
|
|
85
|
+
return {
|
|
86
|
+
ruleId: 'RAQIB023',
|
|
87
|
+
severity: 'high',
|
|
88
|
+
title: 'Directive hidden in an HTML comment',
|
|
89
|
+
message: `An HTML comment carries what reads as an instruction: "${excerpt(body)}". GitHub hides this when rendering; the agent reads it.`,
|
|
90
|
+
remediation: 'Move the directive into the visible body of the file, or delete it.',
|
|
91
|
+
};
|
|
92
|
+
}),
|
|
93
|
+
},
|
|
94
|
+
{
|
|
95
|
+
id: 'RAQIB024',
|
|
96
|
+
severity: 'medium',
|
|
97
|
+
title: 'Payload past the visible edge',
|
|
98
|
+
/**
|
|
99
|
+
* A very long line pushes its tail beyond the horizontal cut-off of a diff
|
|
100
|
+
* view. The reviewer scrolls past; the agent reads to the end.
|
|
101
|
+
*/
|
|
102
|
+
check: (doc) => {
|
|
103
|
+
const findings = [];
|
|
104
|
+
doc.lines.forEach((line, index) => {
|
|
105
|
+
if (line.length <= 2000) return;
|
|
106
|
+
findings.push({
|
|
107
|
+
ruleId: 'RAQIB024',
|
|
108
|
+
path: doc.path,
|
|
109
|
+
line: index + 1,
|
|
110
|
+
column: 2001,
|
|
111
|
+
severity: 'medium',
|
|
112
|
+
title: 'Payload past the visible edge',
|
|
113
|
+
message: `Line is ${line.length} characters long. Content this far right is not visible in a normal diff view.`,
|
|
114
|
+
excerpt: excerpt(line.slice(2000)),
|
|
115
|
+
remediation: 'Wrap the line so a reviewer sees everything the agent sees.',
|
|
116
|
+
});
|
|
117
|
+
});
|
|
118
|
+
return findings;
|
|
119
|
+
},
|
|
120
|
+
},
|
|
121
|
+
];
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
import { rules as injection } from './injection.js';
|
|
2
|
+
import { rules as hidden } from './hidden.js';
|
|
3
|
+
import { rules as secrets } from './secrets.js';
|
|
4
|
+
import { rules as guardrails } from './guardrails.js';
|
|
5
|
+
import { rules as budget } from './budget.js';
|
|
6
|
+
import { rules as coherence } from './coherence.js';
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* Every rule, grouped by the question it answers.
|
|
10
|
+
*
|
|
11
|
+
* `scope: 'project'` rules run once over all documents instead of once per
|
|
12
|
+
* document; everything else runs per file.
|
|
13
|
+
*/
|
|
14
|
+
export const CATEGORIES = {
|
|
15
|
+
injection: { label: 'Prompt injection & hijacking', rules: injection },
|
|
16
|
+
hidden: { label: 'Hidden text', rules: hidden },
|
|
17
|
+
secrets: { label: 'Leaked credentials', rules: secrets },
|
|
18
|
+
guardrails: { label: 'Weakened guardrails', rules: guardrails },
|
|
19
|
+
budget: { label: 'Context budget & noise', rules: budget },
|
|
20
|
+
coherence: { label: 'Coherence', rules: coherence },
|
|
21
|
+
};
|
|
22
|
+
|
|
23
|
+
export const ALL_RULES = Object.entries(CATEGORIES).flatMap(([category, group]) =>
|
|
24
|
+
group.rules.map((rule) => ({ ...rule, category })),
|
|
25
|
+
);
|
|
@@ -0,0 +1,152 @@
|
|
|
1
|
+
import { scan, isProhibition } from '../lib/text.js';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Prompt injection and agent hijacking.
|
|
5
|
+
*
|
|
6
|
+
* An instruction file is prepended to every request the coding agent makes.
|
|
7
|
+
* Anything written here is, from the model's point of view, indistinguishable
|
|
8
|
+
* from an order given by the repository owner. These rules look for text that
|
|
9
|
+
* tries to use that position against the operator.
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
const OVERRIDE = [
|
|
13
|
+
{
|
|
14
|
+
id: 'RAQIB001',
|
|
15
|
+
severity: 'critical',
|
|
16
|
+
title: 'Instruction override',
|
|
17
|
+
pattern: /\b(ignore|disregard|forget)\s+(all\s+|any\s+|the\s+)?(previous|prior|above|earlier|preceding|system|foregoing)\s+(instructions?|directions?|rules?|prompts?|messages?|context)/i,
|
|
18
|
+
message: 'Orders the agent to discard instructions it was given before reaching this file.',
|
|
19
|
+
remediation: 'Delete the line. A repository instruction file has no legitimate reason to revoke the operator’s own configuration.',
|
|
20
|
+
},
|
|
21
|
+
{
|
|
22
|
+
id: 'RAQIB002',
|
|
23
|
+
severity: 'critical',
|
|
24
|
+
title: 'Safety control bypass',
|
|
25
|
+
pattern: /\b(disable|bypass|ignore|override|turn\s+off|switch\s+off|circumvent)\s+(your\s+|the\s+|all\s+)?(safety|guardrails?|security\s+(checks?|controls?)|restrictions?|content\s+polic\w*|filters?|limitations?)/i,
|
|
26
|
+
message: 'Instructs the agent to switch off its own safety or policy controls.',
|
|
27
|
+
remediation: 'Delete the line and review who added it.',
|
|
28
|
+
},
|
|
29
|
+
{
|
|
30
|
+
id: 'RAQIB003',
|
|
31
|
+
severity: 'critical',
|
|
32
|
+
title: 'Concealment from the operator',
|
|
33
|
+
pattern: /\b(do\s+not|don'?t|never)\s+(tell|inform|notify|mention\s+to|reveal\s+to|show|disclose\s+to|report\s+to)\s+(the\s+)?(user|operator|developer|human|maintainer)/i,
|
|
34
|
+
message: 'Tells the agent to hide part of its behaviour from the person running it.',
|
|
35
|
+
remediation: 'Delete the line. Instructions that ask an agent to keep secrets from its operator are hostile by construction.',
|
|
36
|
+
},
|
|
37
|
+
{
|
|
38
|
+
id: 'RAQIB004',
|
|
39
|
+
severity: 'high',
|
|
40
|
+
title: 'Silent action',
|
|
41
|
+
pattern: /\bwithout\s+(asking|telling|informing|notifying|consulting|confirming\s+with)\s+(the\s+)?(user|operator|anyone|permission)/i,
|
|
42
|
+
message: 'Removes the confirmation step between the agent and an action.',
|
|
43
|
+
remediation: 'State explicitly which narrow actions may proceed unattended, rather than granting blanket silence.',
|
|
44
|
+
},
|
|
45
|
+
{
|
|
46
|
+
id: 'RAQIB005',
|
|
47
|
+
severity: 'high',
|
|
48
|
+
title: 'Precedence claim',
|
|
49
|
+
pattern: /\bth(is|ese)\s+(file|instructions?|rules?|directives?)\s+(take|takes|have|has|override|overrides?)\s+(absolute\s+|complete\s+|full\s+)?(precedence|priority|authority)\s+over\b/i,
|
|
50
|
+
message: 'Asserts authority over instructions from the operator or the platform.',
|
|
51
|
+
remediation: 'Instruction files describe a repository. They do not outrank the person running the agent.',
|
|
52
|
+
},
|
|
53
|
+
{
|
|
54
|
+
id: 'RAQIB006',
|
|
55
|
+
severity: 'high',
|
|
56
|
+
title: 'Persona reset',
|
|
57
|
+
pattern: /\byou\s+are\s+(no\s+longer|not)\s+(bound|restricted|limited|constrained|required)\b|\bfrom\s+now\s+on\s+you\s+(are|will\s+be|must\s+act)\b/i,
|
|
58
|
+
message: 'Attempts to redefine the agent’s role or lift its constraints.',
|
|
59
|
+
remediation: 'Describe the codebase, not the agent’s identity.',
|
|
60
|
+
},
|
|
61
|
+
];
|
|
62
|
+
|
|
63
|
+
/** Files and paths that carry credentials. Reading them is the precondition to leaking them. */
|
|
64
|
+
const SECRET_PATH = /(\.env(?:\.[\w.-]+)?|~\/\.ssh\/?[\w.-]*|id_[rd]sa|\.aws\/credentials|\.npmrc|\.netrc|\.git-credentials|kubeconfig|\.docker\/config\.json|secrets?\.(?:ya?ml|json|toml))/i;
|
|
65
|
+
|
|
66
|
+
/** Verbs that turn a mention of a secret file into an instruction to open it. */
|
|
67
|
+
const READ_VERB = /\b(read|cat|open|load|include|print|dump|show|display|parse|inspect|access|fetch)\b/i;
|
|
68
|
+
|
|
69
|
+
/** Verbs that move data off the machine. */
|
|
70
|
+
const SEND_VERB = /\b(send|post|upload|exfiltrat\w*|transmit|forward|attach|share|report|submit|push|email|curl|wget)\b/i;
|
|
71
|
+
|
|
72
|
+
export const rules = [
|
|
73
|
+
...OVERRIDE.map((rule) => ({
|
|
74
|
+
id: rule.id,
|
|
75
|
+
severity: rule.severity,
|
|
76
|
+
title: rule.title,
|
|
77
|
+
check: (doc) =>
|
|
78
|
+
scan(doc, rule.pattern, () => ({
|
|
79
|
+
ruleId: rule.id,
|
|
80
|
+
severity: rule.severity,
|
|
81
|
+
title: rule.title,
|
|
82
|
+
message: rule.message,
|
|
83
|
+
remediation: rule.remediation,
|
|
84
|
+
})),
|
|
85
|
+
})),
|
|
86
|
+
|
|
87
|
+
{
|
|
88
|
+
id: 'RAQIB010',
|
|
89
|
+
severity: 'critical',
|
|
90
|
+
title: 'Credential exfiltration',
|
|
91
|
+
/**
|
|
92
|
+
* Fires only when a line both names a credential store and tells the agent
|
|
93
|
+
* to move it somewhere. "Never commit .env" names the same file and must
|
|
94
|
+
* not fire, which is why the prohibition check comes first.
|
|
95
|
+
*/
|
|
96
|
+
check: (doc) =>
|
|
97
|
+
scan(doc, SECRET_PATH, (match, line, context) => {
|
|
98
|
+
if (isProhibition(context)) return null;
|
|
99
|
+
const sends = SEND_VERB.test(line);
|
|
100
|
+
const reads = READ_VERB.test(line);
|
|
101
|
+
if (!sends && !reads) return null;
|
|
102
|
+
return {
|
|
103
|
+
ruleId: 'RAQIB010',
|
|
104
|
+
severity: sends ? 'critical' : 'high',
|
|
105
|
+
title: sends ? 'Credential exfiltration' : 'Credential access',
|
|
106
|
+
message: sends
|
|
107
|
+
? `Instructs the agent to move the contents of ${match[0]} somewhere else.`
|
|
108
|
+
: `Instructs the agent to read ${match[0]}, which holds credentials.`,
|
|
109
|
+
remediation:
|
|
110
|
+
'Remove the directive. If the agent genuinely needs a value, name the environment variable it should expect rather than the file that stores every secret.',
|
|
111
|
+
};
|
|
112
|
+
}),
|
|
113
|
+
},
|
|
114
|
+
|
|
115
|
+
{
|
|
116
|
+
id: 'RAQIB011',
|
|
117
|
+
severity: 'critical',
|
|
118
|
+
title: 'Remote code execution',
|
|
119
|
+
check: (doc) =>
|
|
120
|
+
scan(
|
|
121
|
+
doc,
|
|
122
|
+
/\b(curl|wget|iwr|Invoke-WebRequest)\b[^\n|]{0,200}\|\s*(sudo\s+)?(ba|z|k)?sh\b|\bbase64\s+(-d|--decode|-D)\b[^\n|]{0,120}\|\s*(ba|z|k)?sh\b/i,
|
|
123
|
+
(match, line, context) => (isProhibition(context) ? null : {
|
|
124
|
+
ruleId: 'RAQIB011',
|
|
125
|
+
severity: 'critical',
|
|
126
|
+
title: 'Remote code execution',
|
|
127
|
+
message: 'Pipes downloaded or decoded content straight into a shell.',
|
|
128
|
+
remediation:
|
|
129
|
+
'Pin an installer to a version and a checksum, or vendor the script into the repository where it can be reviewed.',
|
|
130
|
+
}),
|
|
131
|
+
),
|
|
132
|
+
},
|
|
133
|
+
|
|
134
|
+
{
|
|
135
|
+
id: 'RAQIB012',
|
|
136
|
+
severity: 'high',
|
|
137
|
+
title: 'Unattended outbound request',
|
|
138
|
+
check: (doc) =>
|
|
139
|
+
scan(
|
|
140
|
+
doc,
|
|
141
|
+
/\b(curl|wget|fetch|axios|requests\.(?:post|put)|Invoke-WebRequest|nc|netcat)\b[^\n]{0,160}?https?:\/\/(?!(?:localhost|127\.0\.0\.1|0\.0\.0\.0|\[::1\]))([\w.-]+)/i,
|
|
142
|
+
(match, line, context) => (isProhibition(context) ? null : {
|
|
143
|
+
ruleId: 'RAQIB012',
|
|
144
|
+
severity: 'high',
|
|
145
|
+
title: 'Unattended outbound request',
|
|
146
|
+
message: `Directs the agent to contact ${match[2]} on its own.`,
|
|
147
|
+
remediation:
|
|
148
|
+
'Name the host in an allowlist your operator controls, and say what may be sent to it.',
|
|
149
|
+
}),
|
|
150
|
+
),
|
|
151
|
+
},
|
|
152
|
+
];
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
import { scan } from '../lib/text.js';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Credentials pasted into the instruction file itself.
|
|
5
|
+
*
|
|
6
|
+
* This happens constantly: someone tells the agent "use this key to call the
|
|
7
|
+
* staging API" and commits it. The file is public, indexed, and copied into
|
|
8
|
+
* every fork.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
const PLACEHOLDER = /(x{4,}|your[_-]?|example|placeholder|changeme|dummy|redacted|<[^>]+>|\.\.\.|abc123|sample|fake|test[_-]?key|insert)/i;
|
|
12
|
+
|
|
13
|
+
const PATTERNS = [
|
|
14
|
+
{ id: 'RAQIB030', label: 'GitHub token', re: /\bgh[pousr]_[A-Za-z0-9]{36,255}\b/ },
|
|
15
|
+
{ id: 'RAQIB030', label: 'GitHub fine-grained token', re: /\bgithub_pat_[A-Za-z0-9_]{22,255}\b/ },
|
|
16
|
+
{ id: 'RAQIB031', label: 'OpenAI API key', re: /\bsk-(?:proj-)?[A-Za-z0-9_-]{20,}\b/ },
|
|
17
|
+
{ id: 'RAQIB032', label: 'Anthropic API key', re: /\bsk-ant-[A-Za-z0-9_-]{20,}\b/ },
|
|
18
|
+
{ id: 'RAQIB033', label: 'AWS access key id', re: /\b(?:AKIA|ASIA)[0-9A-Z]{16}\b/ },
|
|
19
|
+
{ id: 'RAQIB034', label: 'Slack token', re: /\bxox[baprs]-[A-Za-z0-9-]{10,}\b/ },
|
|
20
|
+
{ id: 'RAQIB035', label: 'Google API key', re: /\bAIza[0-9A-Za-z_-]{35}\b/ },
|
|
21
|
+
{ id: 'RAQIB036', label: 'Stripe secret key', re: /\b(?:sk|rk)_live_[0-9A-Za-z]{20,}\b/ },
|
|
22
|
+
];
|
|
23
|
+
|
|
24
|
+
/** Mask everything but the shape, so the report itself never leaks the value. */
|
|
25
|
+
const mask = (value) =>
|
|
26
|
+
value.length <= 12 ? `${value.slice(0, 4)}…` : `${value.slice(0, 6)}…${value.slice(-2)} (${value.length} chars)`;
|
|
27
|
+
|
|
28
|
+
export const rules = [
|
|
29
|
+
...PATTERNS.map(({ id, label, re }) => ({
|
|
30
|
+
id,
|
|
31
|
+
severity: 'critical',
|
|
32
|
+
title: `Hardcoded ${label}`,
|
|
33
|
+
check: (doc) =>
|
|
34
|
+
scan(doc, re, (match) =>
|
|
35
|
+
PLACEHOLDER.test(match[0])
|
|
36
|
+
? null
|
|
37
|
+
: {
|
|
38
|
+
ruleId: id,
|
|
39
|
+
severity: 'critical',
|
|
40
|
+
title: `Hardcoded ${label}`,
|
|
41
|
+
message: `A live-looking ${label} sits in the instruction file: ${mask(match[0])}`,
|
|
42
|
+
remediation: `Rotate the credential now, since it is in the git history regardless of the next commit, then reference an environment variable instead.`,
|
|
43
|
+
},
|
|
44
|
+
),
|
|
45
|
+
})),
|
|
46
|
+
|
|
47
|
+
{
|
|
48
|
+
id: 'RAQIB037',
|
|
49
|
+
severity: 'critical',
|
|
50
|
+
title: 'Private key block',
|
|
51
|
+
check: (doc) =>
|
|
52
|
+
scan(doc, /-----BEGIN\s+[A-Z ]{0,30}PRIVATE KEY-----/g, () => ({
|
|
53
|
+
ruleId: 'RAQIB037',
|
|
54
|
+
severity: 'critical',
|
|
55
|
+
title: 'Private key block',
|
|
56
|
+
message: 'A PEM private key is embedded in the instruction file.',
|
|
57
|
+
remediation: 'Revoke and reissue the key pair, then purge it from the history.',
|
|
58
|
+
})),
|
|
59
|
+
},
|
|
60
|
+
|
|
61
|
+
{
|
|
62
|
+
id: 'RAQIB038',
|
|
63
|
+
severity: 'high',
|
|
64
|
+
title: 'Credential-shaped assignment',
|
|
65
|
+
check: (doc) =>
|
|
66
|
+
scan(
|
|
67
|
+
doc,
|
|
68
|
+
/\b(api[_-]?key|secret|token|password|passwd|credential)\b\s*[:=]\s*["']([^"'\s]{16,})["']/gi,
|
|
69
|
+
(match) =>
|
|
70
|
+
PLACEHOLDER.test(match[2])
|
|
71
|
+
? null
|
|
72
|
+
: {
|
|
73
|
+
ruleId: 'RAQIB038',
|
|
74
|
+
severity: 'high',
|
|
75
|
+
title: 'Credential-shaped assignment',
|
|
76
|
+
message: `A value is assigned to \`${match[1]}\` in plain text: ${mask(match[2])}`,
|
|
77
|
+
remediation:
|
|
78
|
+
'If it is real, rotate it. If it is an example, make it obviously fake (YOUR_API_KEY_HERE) so neither a reader nor a scanner has to guess.',
|
|
79
|
+
},
|
|
80
|
+
),
|
|
81
|
+
},
|
|
82
|
+
];
|