codegate-ai 0.16.2 → 1.0.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/dist/cli.d.ts +3 -1
- package/dist/cli.js +153 -44
- package/dist/commands/scan-command.d.ts +2 -1
- package/dist/commands/scan-command.js +6 -1
- package/dist/commands/trust.d.ts +28 -0
- package/dist/commands/trust.js +69 -0
- package/dist/config/inline-ignore.d.ts +10 -2
- package/dist/config/inline-ignore.js +5 -1
- package/dist/config/suppression-policy.d.ts +1 -1
- package/dist/config/suppression-policy.js +4 -1
- package/dist/config/trust.d.ts +2 -0
- package/dist/config/trust.js +19 -0
- package/dist/config.d.ts +14 -0
- package/dist/config.js +45 -1
- package/dist/content/content-bundle.d.ts +27 -0
- package/dist/content/content-bundle.js +73 -0
- package/dist/content/content-store.d.ts +27 -0
- package/dist/content/content-store.js +0 -0
- package/dist/content/content-updater.d.ts +32 -0
- package/dist/content/content-updater.js +111 -0
- package/dist/content/known-bad.d.ts +26 -0
- package/dist/content/known-bad.js +100 -0
- package/dist/content/publisher-key.d.ts +10 -0
- package/dist/content/publisher-key.js +10 -0
- package/dist/layer1-discovery/knowledge-base.js +33 -1
- package/dist/layer2-static/data/popular-mcp-packages.d.ts +16 -0
- package/dist/layer2-static/data/popular-mcp-packages.js +83 -0
- package/dist/layer2-static/detectors/known-bad.d.ts +22 -0
- package/dist/layer2-static/detectors/known-bad.js +116 -0
- package/dist/layer2-static/detectors/mcp-package-hygiene.d.ts +28 -0
- package/dist/layer2-static/detectors/mcp-package-hygiene.js +191 -0
- package/dist/layer2-static/detectors/rule-file.js +128 -75
- package/dist/layer2-static/detectors/skill-frontmatter.d.ts +6 -0
- package/dist/layer2-static/detectors/skill-frontmatter.js +130 -0
- package/dist/layer2-static/engine.d.ts +2 -0
- package/dist/layer2-static/engine.js +0 -0
- package/dist/layer2-static/rule-pack-loader.js +24 -1
- package/dist/layer2-static/state/scan-state.d.ts +7 -2
- package/dist/layer2-static/state/scan-state.js +74 -30
- package/dist/layer2-static/text/confusables.d.ts +7 -0
- package/dist/layer2-static/text/confusables.js +70 -0
- package/dist/layer2-static/text/edit-distance.d.ts +6 -0
- package/dist/layer2-static/text/edit-distance.js +33 -0
- package/dist/layer2-static/text/encoded-payloads.d.ts +16 -0
- package/dist/layer2-static/text/encoded-payloads.js +116 -0
- package/dist/layer2-static/text/normalize.d.ts +11 -0
- package/dist/layer2-static/text/normalize.js +19 -0
- package/dist/layer2-static/text/override-phrases.d.ts +14 -0
- package/dist/layer2-static/text/override-phrases.js +49 -0
- package/dist/layer2-static/text/threat-patterns.d.ts +33 -0
- package/dist/layer2-static/text/threat-patterns.js +45 -0
- package/dist/layer2-static/text/unicode.d.ts +33 -0
- package/dist/layer2-static/text/unicode.js +83 -0
- package/dist/layer3-dynamic/deep-resource-executor.d.ts +21 -0
- package/dist/layer3-dynamic/deep-resource-executor.js +73 -0
- package/dist/layer3-dynamic/meta-agent.js +2 -1
- package/dist/layer3-dynamic/registry-client.d.ts +26 -0
- package/dist/layer3-dynamic/registry-client.js +138 -0
- package/dist/layer3-dynamic/registry-findings.d.ts +7 -0
- package/dist/layer3-dynamic/registry-findings.js +64 -0
- package/dist/layer3-dynamic/tool-description-scanner.js +22 -13
- package/dist/layer3-dynamic/toxic-flow.d.ts +4 -0
- package/dist/layer3-dynamic/toxic-flow.js +41 -8
- package/dist/pipeline.d.ts +5 -2
- package/dist/pipeline.js +53 -16
- package/dist/report-summary.d.ts +1 -1
- package/dist/report-summary.js +9 -1
- package/dist/scan.d.ts +13 -0
- package/dist/scan.js +291 -17
- package/dist/types/finding.d.ts +14 -0
- package/dist/types/finding.js +14 -0
- package/dist/types/report.d.ts +2 -0
- package/dist/wrapper.js +2 -19
- package/package.json +1 -1
package/dist/scan.js
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
|
-
import { existsSync, readdirSync, readFileSync, statSync } from "node:fs";
|
|
1
|
+
import { closeSync, existsSync, openSync, readSync, readdirSync, readFileSync, statSync, } from "node:fs";
|
|
2
2
|
import { homedir } from "node:os";
|
|
3
|
-
import { basename, join, relative, resolve, sep } from "node:path";
|
|
3
|
+
import { basename, dirname, join, relative, resolve, sep } from "node:path";
|
|
4
4
|
import { collectLocalTextAnalysisTargets, } from "./layer3-dynamic/local-text-analysis.js";
|
|
5
5
|
import { buildResourceId, normalizeRemoteUrl } from "./layer3-dynamic/url-validation.js";
|
|
6
6
|
import { runStaticPipeline } from "./pipeline.js";
|
|
@@ -11,11 +11,46 @@ import { detectTools } from "./layer1-discovery/tool-detector.js";
|
|
|
11
11
|
import { walkProjectTree } from "./layer1-discovery/file-walker.js";
|
|
12
12
|
import { evaluateScanStateSnapshots, extractMcpServerSnapshots, loadScanState, saveScanState, } from "./layer2-static/state/scan-state.js";
|
|
13
13
|
import { applyInlineIgnoreDirectives, collectInlineIgnoreDirectives, } from "./config/inline-ignore.js";
|
|
14
|
+
import { isTrustedDirectory } from "./config/trust.js";
|
|
15
|
+
import { loadKnownBadIndicators } from "./content/known-bad.js";
|
|
16
|
+
import { escalateKnownBadFindings } from "./layer2-static/detectors/known-bad.js";
|
|
17
|
+
import { normalizeForMatching } from "./layer2-static/text/normalize.js";
|
|
18
|
+
import { REMOTE_INSTRUCTION_INDIRECTION_PATTERN } from "./layer2-static/text/threat-patterns.js";
|
|
19
|
+
import { withFindingFingerprint } from "./report/finding-fingerprint.js";
|
|
14
20
|
import { isGitHubDependabotPath } from "./layer2-static/dependabot/parser.js";
|
|
15
21
|
const MCP_SERVER_CONTAINER_KEYS = ["mcpServers", "mcp_servers", "context_servers"];
|
|
16
22
|
const REMOTE_MCP_SERVER_ARRAY_KEYS = ["remoteMCPServers", "remote_mcp_servers"];
|
|
17
23
|
const USER_SCOPE_WILDCARD_MAX_DEPTH = 6;
|
|
18
24
|
const USER_SCOPE_WILDCARD_MAX_FILES = 500;
|
|
25
|
+
const SKILL_FILE_NAME = "skill.md";
|
|
26
|
+
const SKILL_SIBLING_MAX_DEPTH = 3;
|
|
27
|
+
const SKILL_SIBLING_MAX_FILES = 200;
|
|
28
|
+
const BINARY_SNIFF_BYTES = 512;
|
|
29
|
+
const SKILL_SIBLING_FORMATS = {
|
|
30
|
+
md: "markdown",
|
|
31
|
+
markdown: "markdown",
|
|
32
|
+
json: "json",
|
|
33
|
+
yaml: "yaml",
|
|
34
|
+
yml: "yaml",
|
|
35
|
+
toml: "toml",
|
|
36
|
+
sh: "text",
|
|
37
|
+
bash: "text",
|
|
38
|
+
zsh: "text",
|
|
39
|
+
ps1: "text",
|
|
40
|
+
py: "text",
|
|
41
|
+
js: "text",
|
|
42
|
+
mjs: "text",
|
|
43
|
+
cjs: "text",
|
|
44
|
+
ts: "text",
|
|
45
|
+
rb: "text",
|
|
46
|
+
txt: "text",
|
|
47
|
+
};
|
|
48
|
+
export const SKILL_BINARY_KIND = {
|
|
49
|
+
Elf: "elf",
|
|
50
|
+
MachO: "mach-o",
|
|
51
|
+
Pe: "pe",
|
|
52
|
+
Unknown: "binary",
|
|
53
|
+
};
|
|
19
54
|
const INFERRED_ARTIFACT_RULES = [
|
|
20
55
|
{
|
|
21
56
|
pattern: /(?:^|\/)\.github\/workflows\/[^/]+\.ya?ml$/iu,
|
|
@@ -45,12 +80,19 @@ const INFERRED_ARTIFACT_RULES = [
|
|
|
45
80
|
function escapeRegex(value) {
|
|
46
81
|
return value.replace(/[|\\{}()[\]^$+?.*]/g, "\\$&");
|
|
47
82
|
}
|
|
83
|
+
const wildcardRegexCache = new Map();
|
|
48
84
|
function wildcardToRegex(pattern) {
|
|
85
|
+
const cached = wildcardRegexCache.get(pattern);
|
|
86
|
+
if (cached) {
|
|
87
|
+
return cached;
|
|
88
|
+
}
|
|
49
89
|
let escaped = escapeRegex(pattern);
|
|
50
90
|
escaped = escaped.replace(/\\\*\\\*\//g, "(?:[^/]+/)*");
|
|
51
91
|
escaped = escaped.replace(/\\\*\\\*/g, ".*");
|
|
52
92
|
escaped = escaped.replace(/\\\*/g, "[^/]*");
|
|
53
|
-
|
|
93
|
+
const regex = new RegExp(`^${escaped}$`, "u");
|
|
94
|
+
wildcardRegexCache.set(pattern, regex);
|
|
95
|
+
return regex;
|
|
54
96
|
}
|
|
55
97
|
function normalizePathForMatch(path) {
|
|
56
98
|
return path.split(sep).join("/");
|
|
@@ -279,15 +321,15 @@ function collectSelectedCandidates(absoluteTarget, walkedFiles, patterns, option
|
|
|
279
321
|
relativePath: normalizePathForMatch(relative(absoluteTarget, filePath)),
|
|
280
322
|
}))
|
|
281
323
|
.filter((entry) => !entry.relativePath.startsWith(".."));
|
|
324
|
+
const projectPatterns = patterns
|
|
325
|
+
.filter((candidate) => candidate.scope === "project")
|
|
326
|
+
.map((candidate) => ({ candidate, regex: wildcardToRegex(candidate.pattern) }));
|
|
282
327
|
for (const file of filesByRelativePath) {
|
|
283
328
|
if (!includeProject) {
|
|
284
329
|
continue;
|
|
285
330
|
}
|
|
286
|
-
for (const candidate of
|
|
287
|
-
if (
|
|
288
|
-
continue;
|
|
289
|
-
}
|
|
290
|
-
if (!wildcardToRegex(candidate.pattern).test(file.relativePath)) {
|
|
331
|
+
for (const { candidate, regex } of projectPatterns) {
|
|
332
|
+
if (!regex.test(file.relativePath)) {
|
|
291
333
|
continue;
|
|
292
334
|
}
|
|
293
335
|
if (!matchesCollectionKinds(file.relativePath, options.collectKinds)) {
|
|
@@ -418,6 +460,140 @@ function mergeExplicitCandidates(selected, explicitCandidates, collectKinds) {
|
|
|
418
460
|
}
|
|
419
461
|
return Array.from(merged.values());
|
|
420
462
|
}
|
|
463
|
+
function sniffFileHead(path) {
|
|
464
|
+
const buffer = Buffer.alloc(BINARY_SNIFF_BYTES);
|
|
465
|
+
let bytesRead;
|
|
466
|
+
try {
|
|
467
|
+
const fd = openSync(path, "r");
|
|
468
|
+
try {
|
|
469
|
+
bytesRead = readSync(fd, buffer, 0, BINARY_SNIFF_BYTES, 0);
|
|
470
|
+
}
|
|
471
|
+
finally {
|
|
472
|
+
closeSync(fd);
|
|
473
|
+
}
|
|
474
|
+
}
|
|
475
|
+
catch {
|
|
476
|
+
return { binaryKind: null, hasShebang: false };
|
|
477
|
+
}
|
|
478
|
+
const head = buffer.subarray(0, bytesRead);
|
|
479
|
+
const hasShebang = bytesRead >= 2 && head[0] === 0x23 && head[1] === 0x21;
|
|
480
|
+
if (bytesRead >= 4 &&
|
|
481
|
+
head[0] === 0x7f &&
|
|
482
|
+
head[1] === 0x45 &&
|
|
483
|
+
head[2] === 0x4c &&
|
|
484
|
+
head[3] === 0x46) {
|
|
485
|
+
return { binaryKind: SKILL_BINARY_KIND.Elf, hasShebang: false };
|
|
486
|
+
}
|
|
487
|
+
const magic = bytesRead >= 4 ? head.readUInt32BE(0) : 0;
|
|
488
|
+
if (magic === 0xfeedface ||
|
|
489
|
+
magic === 0xfeedfacf ||
|
|
490
|
+
magic === 0xcefaedfe ||
|
|
491
|
+
magic === 0xcffaedfe ||
|
|
492
|
+
magic === 0xcafebabe) {
|
|
493
|
+
return { binaryKind: SKILL_BINARY_KIND.MachO, hasShebang: false };
|
|
494
|
+
}
|
|
495
|
+
if (bytesRead >= 2 && head[0] === 0x4d && head[1] === 0x5a) {
|
|
496
|
+
return { binaryKind: SKILL_BINARY_KIND.Pe, hasShebang: false };
|
|
497
|
+
}
|
|
498
|
+
if (head.includes(0)) {
|
|
499
|
+
return { binaryKind: SKILL_BINARY_KIND.Unknown, hasShebang: false };
|
|
500
|
+
}
|
|
501
|
+
return { binaryKind: null, hasShebang };
|
|
502
|
+
}
|
|
503
|
+
function isSkillCandidate(candidate) {
|
|
504
|
+
return basename(normalizePathForMatch(candidate.reportPath)).toLowerCase() === SKILL_FILE_NAME;
|
|
505
|
+
}
|
|
506
|
+
function siblingReportPath(candidate, siblingAbsolute) {
|
|
507
|
+
const skillDir = dirname(candidate.absolutePath);
|
|
508
|
+
const relFromSkillDir = normalizePathForMatch(relative(skillDir, siblingAbsolute));
|
|
509
|
+
const normalizedReport = normalizePathForMatch(candidate.reportPath);
|
|
510
|
+
const slashIndex = normalizedReport.lastIndexOf("/");
|
|
511
|
+
const parentReport = slashIndex >= 0 ? normalizedReport.slice(0, slashIndex) : "";
|
|
512
|
+
return parentReport ? `${parentReport}/${relFromSkillDir}` : relFromSkillDir;
|
|
513
|
+
}
|
|
514
|
+
/**
|
|
515
|
+
* Skills ship payloads next to SKILL.md (helper scripts, nested docs,
|
|
516
|
+
* sometimes binaries). Collect text-like siblings as scan candidates so the
|
|
517
|
+
* rule-file detectors see them, and record binary artifacts for reporting.
|
|
518
|
+
*/
|
|
519
|
+
function collectSkillSiblings(selected) {
|
|
520
|
+
const known = new Set(selected.map((candidate) => normalizePathForMatch(candidate.reportPath)));
|
|
521
|
+
const candidates = [];
|
|
522
|
+
const binaries = [];
|
|
523
|
+
const visitedDirs = new Set();
|
|
524
|
+
for (const skillCandidate of selected.filter(isSkillCandidate)) {
|
|
525
|
+
const skillDir = dirname(skillCandidate.absolutePath);
|
|
526
|
+
if (visitedDirs.has(skillDir)) {
|
|
527
|
+
continue;
|
|
528
|
+
}
|
|
529
|
+
visitedDirs.add(skillDir);
|
|
530
|
+
let filesSeen = 0;
|
|
531
|
+
const queue = [{ dir: skillDir, depth: 0 }];
|
|
532
|
+
while (queue.length > 0 && filesSeen < SKILL_SIBLING_MAX_FILES) {
|
|
533
|
+
const current = queue.pop();
|
|
534
|
+
if (!current) {
|
|
535
|
+
break;
|
|
536
|
+
}
|
|
537
|
+
let entries;
|
|
538
|
+
try {
|
|
539
|
+
entries = readdirSync(current.dir, { withFileTypes: true });
|
|
540
|
+
}
|
|
541
|
+
catch {
|
|
542
|
+
continue;
|
|
543
|
+
}
|
|
544
|
+
for (const entry of entries) {
|
|
545
|
+
if (filesSeen >= SKILL_SIBLING_MAX_FILES) {
|
|
546
|
+
break;
|
|
547
|
+
}
|
|
548
|
+
if (entry.isSymbolicLink()) {
|
|
549
|
+
continue;
|
|
550
|
+
}
|
|
551
|
+
const absolutePath = join(current.dir, entry.name);
|
|
552
|
+
if (entry.isDirectory()) {
|
|
553
|
+
if (current.depth < SKILL_SIBLING_MAX_DEPTH) {
|
|
554
|
+
queue.push({ dir: absolutePath, depth: current.depth + 1 });
|
|
555
|
+
}
|
|
556
|
+
continue;
|
|
557
|
+
}
|
|
558
|
+
if (!entry.isFile() || absolutePath === skillCandidate.absolutePath) {
|
|
559
|
+
continue;
|
|
560
|
+
}
|
|
561
|
+
filesSeen += 1;
|
|
562
|
+
const reportPath = siblingReportPath(skillCandidate, absolutePath);
|
|
563
|
+
if (known.has(reportPath)) {
|
|
564
|
+
continue;
|
|
565
|
+
}
|
|
566
|
+
const sniffed = sniffFileHead(absolutePath);
|
|
567
|
+
if (sniffed.binaryKind) {
|
|
568
|
+
let executable;
|
|
569
|
+
try {
|
|
570
|
+
executable = (statSync(absolutePath).mode & 0o111) !== 0;
|
|
571
|
+
}
|
|
572
|
+
catch {
|
|
573
|
+
executable = false;
|
|
574
|
+
}
|
|
575
|
+
binaries.push({ reportPath, kind: sniffed.binaryKind, executable });
|
|
576
|
+
continue;
|
|
577
|
+
}
|
|
578
|
+
const extension = entry.name.includes(".")
|
|
579
|
+
? (entry.name.split(".").pop() ?? "").toLowerCase()
|
|
580
|
+
: "";
|
|
581
|
+
const format = SKILL_SIBLING_FORMATS[extension] ?? (sniffed.hasShebang ? "text" : null);
|
|
582
|
+
if (!format) {
|
|
583
|
+
continue;
|
|
584
|
+
}
|
|
585
|
+
known.add(reportPath);
|
|
586
|
+
candidates.push({
|
|
587
|
+
reportPath,
|
|
588
|
+
absolutePath,
|
|
589
|
+
format,
|
|
590
|
+
tool: skillCandidate.tool,
|
|
591
|
+
});
|
|
592
|
+
}
|
|
593
|
+
}
|
|
594
|
+
}
|
|
595
|
+
return { candidates, binaries };
|
|
596
|
+
}
|
|
421
597
|
function inferArtifactCandidate(relativePath, absolutePath) {
|
|
422
598
|
for (const rule of INFERRED_ARTIFACT_RULES) {
|
|
423
599
|
if (!rule.pattern.test(relativePath)) {
|
|
@@ -446,6 +622,57 @@ function ensureParsedCandidates(context) {
|
|
|
446
622
|
}
|
|
447
623
|
return context.parsedCandidates;
|
|
448
624
|
}
|
|
625
|
+
function makeUntrustedProjectConfigFinding(ignoredSettings) {
|
|
626
|
+
return {
|
|
627
|
+
rule_id: "untrusted-project-config",
|
|
628
|
+
finding_id: "UNTRUSTED_PROJECT_CONFIG-.codegate.json",
|
|
629
|
+
severity: "INFO",
|
|
630
|
+
category: "CONFIG_CHANGE",
|
|
631
|
+
layer: "L1",
|
|
632
|
+
file_path: ".codegate.json",
|
|
633
|
+
location: { field: ignoredSettings.join(", ") },
|
|
634
|
+
description: `Ignored ${ignoredSettings.length} policy setting(s) from untrusted project config: ` +
|
|
635
|
+
`${ignoredSettings.join(", ")}. Project config in untrusted directories may only set ` +
|
|
636
|
+
"presentation options. Run `codegate trust <dir>` to honor its policy settings.",
|
|
637
|
+
affected_tools: [],
|
|
638
|
+
cve: null,
|
|
639
|
+
owasp: [],
|
|
640
|
+
cwe: "CWE-807",
|
|
641
|
+
confidence: "HIGH",
|
|
642
|
+
fixable: false,
|
|
643
|
+
remediation_actions: [],
|
|
644
|
+
suppressed: false,
|
|
645
|
+
};
|
|
646
|
+
}
|
|
647
|
+
function makeSkillBinaryFinding(artifact) {
|
|
648
|
+
const isExecutableFormat = artifact.kind !== SKILL_BINARY_KIND.Unknown;
|
|
649
|
+
const severity = artifact.executable || isExecutableFormat ? "HIGH" : "MEDIUM";
|
|
650
|
+
const kindLabel = isExecutableFormat ? `${artifact.kind} executable` : "binary file";
|
|
651
|
+
return {
|
|
652
|
+
rule_id: "skill-binary-payload",
|
|
653
|
+
finding_id: `SKILL_BINARY-${artifact.reportPath}`,
|
|
654
|
+
severity,
|
|
655
|
+
category: "COMMAND_EXEC",
|
|
656
|
+
layer: "L1",
|
|
657
|
+
file_path: artifact.reportPath,
|
|
658
|
+
location: { field: "content" },
|
|
659
|
+
description: `Skill directory ships a ${kindLabel}${artifact.executable ? " with execute permissions" : ""}. ` +
|
|
660
|
+
"Binary payloads cannot be reviewed as text and have no place in an instruction skill.",
|
|
661
|
+
affected_tools: ["claude-code", "codex-cli", "opencode", "cursor"],
|
|
662
|
+
cve: null,
|
|
663
|
+
owasp: ["ASI02"],
|
|
664
|
+
cwe: "CWE-506",
|
|
665
|
+
confidence: "HIGH",
|
|
666
|
+
fixable: true,
|
|
667
|
+
remediation_actions: ["quarantine_file"],
|
|
668
|
+
metadata: {
|
|
669
|
+
sources: [artifact.reportPath],
|
|
670
|
+
risk_tags: ["skill", "binary-payload"],
|
|
671
|
+
origin: "skill-siblings",
|
|
672
|
+
},
|
|
673
|
+
suppressed: false,
|
|
674
|
+
};
|
|
675
|
+
}
|
|
449
676
|
function makeParseErrorFinding(filePath, tool, message, strictCollection) {
|
|
450
677
|
return {
|
|
451
678
|
rule_id: "parse-error",
|
|
@@ -507,7 +734,7 @@ function commandResourceFromTokens(command) {
|
|
|
507
734
|
id: `npm:${locator}`,
|
|
508
735
|
kind: "npm",
|
|
509
736
|
locator,
|
|
510
|
-
preview: `
|
|
737
|
+
preview: `GET https://registry.npmjs.org/${locator} (online mode only; offline records the package name)`,
|
|
511
738
|
};
|
|
512
739
|
}
|
|
513
740
|
if (launcher === "uvx" || launcher === "pipx") {
|
|
@@ -519,7 +746,7 @@ function commandResourceFromTokens(command) {
|
|
|
519
746
|
id: `pypi:${locator}`,
|
|
520
747
|
kind: "pypi",
|
|
521
748
|
locator,
|
|
522
|
-
preview: `https://pypi.org/pypi/${locator}/json`,
|
|
749
|
+
preview: `GET https://pypi.org/pypi/${locator}/json (online mode only; offline records the package name)`,
|
|
523
750
|
};
|
|
524
751
|
}
|
|
525
752
|
return null;
|
|
@@ -654,7 +881,7 @@ export function createScanDiscoveryContext(scanTarget, kbInput, options = {}) {
|
|
|
654
881
|
const collectModes = normalizeCollectionModes(options.collectModes);
|
|
655
882
|
const collectKinds = normalizeCollectionKinds(options.collectKinds);
|
|
656
883
|
const explicitOnly = collectModes.size === 1 && collectModes.has("explicit");
|
|
657
|
-
const
|
|
884
|
+
const baseSelected = mergeExplicitCandidates(explicitOnly
|
|
658
885
|
? []
|
|
659
886
|
: collectSelectedCandidates(absoluteTarget, walked.files, patterns, {
|
|
660
887
|
includeUserScope: options.includeUserScope === true,
|
|
@@ -662,21 +889,50 @@ export function createScanDiscoveryContext(scanTarget, kbInput, options = {}) {
|
|
|
662
889
|
collectModes,
|
|
663
890
|
collectKinds,
|
|
664
891
|
}), options.explicitCandidates, collectKinds);
|
|
892
|
+
const skillSiblings = collectSkillSiblings(baseSelected);
|
|
893
|
+
const selected = [...baseSelected, ...skillSiblings.candidates];
|
|
665
894
|
return {
|
|
666
895
|
absoluteTarget,
|
|
667
896
|
kb,
|
|
668
897
|
walked,
|
|
669
898
|
selected,
|
|
670
899
|
parsedCandidates: options.parseSelected ? parseSelectedCandidates(selected) : undefined,
|
|
900
|
+
skillBinaries: skillSiblings.binaries,
|
|
671
901
|
};
|
|
672
902
|
}
|
|
903
|
+
function collectIndirectionUrlResources(textContent, filePath, resources) {
|
|
904
|
+
const lines = textContent.split(/\r?\n/u);
|
|
905
|
+
for (const line of lines) {
|
|
906
|
+
const normalized = normalizeForMatching(line);
|
|
907
|
+
if (!REMOTE_INSTRUCTION_INDIRECTION_PATTERN.test(normalized)) {
|
|
908
|
+
continue;
|
|
909
|
+
}
|
|
910
|
+
const urlMatch = line.match(/https?:\/\/[^\s)\]"'`<>]+/iu);
|
|
911
|
+
if (!urlMatch) {
|
|
912
|
+
continue;
|
|
913
|
+
}
|
|
914
|
+
const url = urlMatch[0];
|
|
915
|
+
const kind = inferHttpKind(url);
|
|
916
|
+
const id = `${kind}:${url}`;
|
|
917
|
+
if (resources.has(id)) {
|
|
918
|
+
continue;
|
|
919
|
+
}
|
|
920
|
+
resources.set(id, {
|
|
921
|
+
id,
|
|
922
|
+
request: { id, kind, locator: url },
|
|
923
|
+
commandPreview: `GET ${url} (from ${filePath} -> remote instruction indirection)`,
|
|
924
|
+
});
|
|
925
|
+
}
|
|
926
|
+
}
|
|
673
927
|
export function discoverDeepScanResourcesFromContext(context) {
|
|
674
928
|
const discovered = new Map();
|
|
675
929
|
for (const item of ensureParsedCandidates(context)) {
|
|
676
|
-
if (
|
|
677
|
-
|
|
930
|
+
if (item.parsed.ok) {
|
|
931
|
+
collectDeepScanResourcesFromParsed(item.parsed.data, item.reportPath, discovered);
|
|
932
|
+
}
|
|
933
|
+
if (item.format === "markdown" || item.format === "text") {
|
|
934
|
+
collectIndirectionUrlResources(readCandidateText(item), item.reportPath, discovered);
|
|
678
935
|
}
|
|
679
|
-
collectDeepScanResourcesFromParsed(item.parsed.data, item.reportPath, discovered);
|
|
680
936
|
}
|
|
681
937
|
return Array.from(discovered.values()).sort((a, b) => a.id.localeCompare(b.id));
|
|
682
938
|
}
|
|
@@ -712,6 +968,8 @@ export async function runScanEngine(input) {
|
|
|
712
968
|
});
|
|
713
969
|
const absoluteTarget = context.absoluteTarget;
|
|
714
970
|
const kb = context.kb;
|
|
971
|
+
const scanHomeDir = input.homeDir;
|
|
972
|
+
const knownBadIndicators = loadKnownBadIndicators(scanHomeDir ? { homeDir: () => scanHomeDir } : {});
|
|
715
973
|
const parseErrors = [];
|
|
716
974
|
const staticFiles = [];
|
|
717
975
|
for (const item of ensureParsedCandidates(context)) {
|
|
@@ -772,6 +1030,7 @@ export async function runScanEngine(input) {
|
|
|
772
1030
|
runtimeMode: input.config.runtime_mode,
|
|
773
1031
|
workflowAuditsEnabled: input.config.workflow_audits?.enabled === true,
|
|
774
1032
|
rulePolicies: input.config.rules,
|
|
1033
|
+
knownBadIndicators,
|
|
775
1034
|
},
|
|
776
1035
|
});
|
|
777
1036
|
const snapshots = new Map();
|
|
@@ -780,17 +1039,32 @@ export async function runScanEngine(input) {
|
|
|
780
1039
|
snapshots.set(snapshot.serverId, snapshot);
|
|
781
1040
|
}
|
|
782
1041
|
}
|
|
783
|
-
const
|
|
1042
|
+
const trustedTarget = input.config.project_config_trusted ??
|
|
1043
|
+
isTrustedDirectory(absoluteTarget, input.config.trusted_directories);
|
|
1044
|
+
const previousState = loadScanState(input.scanStatePath, absoluteTarget);
|
|
784
1045
|
const stateResult = evaluateScanStateSnapshots({
|
|
785
1046
|
snapshots: Array.from(snapshots.values()),
|
|
786
1047
|
previousState,
|
|
1048
|
+
trustedTarget,
|
|
1049
|
+
firstScanReview: input.config.first_scan_review,
|
|
787
1050
|
});
|
|
788
|
-
saveScanState(stateResult.nextState, input.scanStatePath);
|
|
1051
|
+
saveScanState(stateResult.nextState, input.scanStatePath, absoluteTarget);
|
|
789
1052
|
const inlineIgnores = collectInlineIgnoreDirectives(staticFiles.map((file) => ({
|
|
790
1053
|
filePath: file.filePath,
|
|
791
1054
|
textContent: file.textContent,
|
|
792
1055
|
})));
|
|
793
|
-
const
|
|
1056
|
+
const ignoredProjectSettings = input.config.ignored_project_settings ?? [];
|
|
1057
|
+
const configNoticeFindings = ignoredProjectSettings.length > 0
|
|
1058
|
+
? [withFindingFingerprint(makeUntrustedProjectConfigFinding(ignoredProjectSettings))]
|
|
1059
|
+
: [];
|
|
1060
|
+
const skillBinaryFindings = (context.skillBinaries ?? []).map((artifact) => withFindingFingerprint(makeSkillBinaryFinding(artifact)));
|
|
1061
|
+
const findings = applyInlineIgnoreDirectives(escalateKnownBadFindings([
|
|
1062
|
+
...report.findings,
|
|
1063
|
+
...parseErrors,
|
|
1064
|
+
...stateResult.findings,
|
|
1065
|
+
...configNoticeFindings,
|
|
1066
|
+
...skillBinaryFindings,
|
|
1067
|
+
], knownBadIndicators), inlineIgnores, { trustedTarget });
|
|
794
1068
|
return applyReportSummary({
|
|
795
1069
|
...report,
|
|
796
1070
|
findings,
|
package/dist/types/finding.d.ts
CHANGED
|
@@ -4,6 +4,19 @@ export declare const FINDING_CATEGORIES: readonly ["ENV_OVERRIDE", "COMMAND_EXEC
|
|
|
4
4
|
export type FindingCategory = (typeof FINDING_CATEGORIES)[number];
|
|
5
5
|
export type FindingLayer = "L1" | "L2" | "L3";
|
|
6
6
|
export type FindingConfidence = "HIGH" | "MEDIUM" | "LOW";
|
|
7
|
+
/**
|
|
8
|
+
* Where a suppression came from. InlineUntrusted marks inline
|
|
9
|
+
* `codegate: ignore[...]` directives found in content the user has not
|
|
10
|
+
* trusted; such findings stay visibly suppressed in reports but still
|
|
11
|
+
* count toward the exit code so untrusted content cannot self-approve.
|
|
12
|
+
*/
|
|
13
|
+
export declare const SUPPRESSION_SOURCE: {
|
|
14
|
+
readonly Inline: "inline";
|
|
15
|
+
readonly InlineUntrusted: "inline-untrusted";
|
|
16
|
+
readonly Config: "config";
|
|
17
|
+
};
|
|
18
|
+
export type SuppressionSource = (typeof SUPPRESSION_SOURCE)[keyof typeof SUPPRESSION_SOURCE];
|
|
19
|
+
export declare function isUntrustedInlineSuppression(finding: Pick<Finding, "suppressed" | "suppression_source">): boolean;
|
|
7
20
|
export interface FindingLocation {
|
|
8
21
|
field?: string;
|
|
9
22
|
line?: number;
|
|
@@ -52,4 +65,5 @@ export interface Finding {
|
|
|
52
65
|
incident_primary?: boolean | null;
|
|
53
66
|
source_config?: FindingSourceConfig | null;
|
|
54
67
|
suppressed: boolean;
|
|
68
|
+
suppression_source?: SuppressionSource | null;
|
|
55
69
|
}
|
package/dist/types/finding.js
CHANGED
|
@@ -18,3 +18,17 @@ export const FINDING_CATEGORIES = [
|
|
|
18
18
|
"CI_TEMPLATE_INJECTION",
|
|
19
19
|
"CI_VULNERABLE_ACTION",
|
|
20
20
|
];
|
|
21
|
+
/**
|
|
22
|
+
* Where a suppression came from. InlineUntrusted marks inline
|
|
23
|
+
* `codegate: ignore[...]` directives found in content the user has not
|
|
24
|
+
* trusted; such findings stay visibly suppressed in reports but still
|
|
25
|
+
* count toward the exit code so untrusted content cannot self-approve.
|
|
26
|
+
*/
|
|
27
|
+
export const SUPPRESSION_SOURCE = {
|
|
28
|
+
Inline: "inline",
|
|
29
|
+
InlineUntrusted: "inline-untrusted",
|
|
30
|
+
Config: "config",
|
|
31
|
+
};
|
|
32
|
+
export function isUntrustedInlineSuppression(finding) {
|
|
33
|
+
return (finding.suppressed === true && finding.suppression_source === SUPPRESSION_SOURCE.InlineUntrusted);
|
|
34
|
+
}
|
package/dist/types/report.d.ts
CHANGED
|
@@ -4,6 +4,8 @@ export interface ReportSummary {
|
|
|
4
4
|
by_severity: Record<string, number>;
|
|
5
5
|
fixable: number;
|
|
6
6
|
suppressed: number;
|
|
7
|
+
/** Suppressions requested by untrusted content; these still count toward exit_code. */
|
|
8
|
+
suppressed_untrusted?: number;
|
|
7
9
|
exit_code: number;
|
|
8
10
|
}
|
|
9
11
|
export interface CodeGateReport {
|
package/dist/wrapper.js
CHANGED
|
@@ -1,9 +1,9 @@
|
|
|
1
1
|
import { spawnSync } from "node:child_process";
|
|
2
2
|
import { readFileSync, statSync } from "node:fs";
|
|
3
3
|
import { createHash } from "node:crypto";
|
|
4
|
-
import {
|
|
5
|
-
import { isAbsolute, relative, resolve, sep } from "node:path";
|
|
4
|
+
import { resolve } from "node:path";
|
|
6
5
|
import { applyConfigPolicy } from "./config.js";
|
|
6
|
+
import { isTrustedDirectory } from "./config/trust.js";
|
|
7
7
|
import { evaluatePostScanGuard, evaluatePreLaunchGuard } from "./commands/run-policy.js";
|
|
8
8
|
import { detectTools } from "./layer1-discovery/tool-detector.js";
|
|
9
9
|
import { renderTerminalReport } from "./reporter/terminal.js";
|
|
@@ -87,23 +87,6 @@ function snapshotsEqual(before, after) {
|
|
|
87
87
|
}
|
|
88
88
|
return true;
|
|
89
89
|
}
|
|
90
|
-
function expandHomePath(path) {
|
|
91
|
-
if (path === "~") {
|
|
92
|
-
return homedir();
|
|
93
|
-
}
|
|
94
|
-
if (path.startsWith(`~${sep}`) || path.startsWith("~/")) {
|
|
95
|
-
return resolve(homedir(), path.slice(2));
|
|
96
|
-
}
|
|
97
|
-
return path;
|
|
98
|
-
}
|
|
99
|
-
function isTrustedDirectory(cwd, trustedDirectories) {
|
|
100
|
-
const resolvedCwd = resolve(cwd);
|
|
101
|
-
return trustedDirectories.some((trustedPath) => {
|
|
102
|
-
const resolvedTrusted = resolve(expandHomePath(trustedPath));
|
|
103
|
-
const rel = relative(resolvedTrusted, resolvedCwd);
|
|
104
|
-
return rel === "" || (!rel.startsWith("..") && !isAbsolute(rel));
|
|
105
|
-
});
|
|
106
|
-
}
|
|
107
90
|
const defaultWrapperDeps = {
|
|
108
91
|
runScan: async (input) => runScanEngine({
|
|
109
92
|
version: input.version,
|