@node9/proxy 2.11.0 → 2.12.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/README.md +13 -7
- package/dist/cli.js +258 -34
- package/dist/cli.mjs +258 -34
- package/package.json +2 -3
- package/docs/README.md +0 -49
- package/docs/agents/README.md +0 -29
- package/docs/agents/antigravity.md +0 -33
- package/docs/agents/claude-code.md +0 -40
- package/docs/agents/claude-desktop.md +0 -32
- package/docs/agents/codex.md +0 -37
- package/docs/agents/copilot-cli.md +0 -34
- package/docs/agents/cursor.md +0 -36
- package/docs/agents/gemini-cli.md +0 -34
- package/docs/agents/hermes.md +0 -35
- package/docs/agents/opencode.md +0 -36
- package/docs/agents/pi.md +0 -34
- package/docs/agents/vscode.md +0 -33
- package/docs/agents/windsurf.md +0 -31
- package/docs/badges.md +0 -98
- package/docs/comparison.md +0 -66
- package/docs/egress.md +0 -119
package/README.md
CHANGED
|
@@ -97,13 +97,13 @@ node9 scan-repo <owner/repo> --json # machine-readable
|
|
|
97
97
|
|
|
98
98
|
What it checks:
|
|
99
99
|
|
|
100
|
-
| Check | Flags
|
|
101
|
-
| -------- |
|
|
102
|
-
| **CI-1** | committed agent config that pre-authorizes broad tools or runs remote hooks
|
|
103
|
-
| **CI-2** | injectable agent workflows — an outsider can trigger the agent and hijack it
|
|
104
|
-
| **CI-3** | unpinned / `@latest` MCP servers or inline credentials (supply chain)
|
|
105
|
-
| **CI-4** | secrets an injected agent could exfiltrate
|
|
106
|
-
| **CI-6** | poisoned or dangerous instructions in `CLAUDE.md` / `AGENTS.md` /
|
|
100
|
+
| Check | Flags |
|
|
101
|
+
| -------- | -------------------------------------------------------------------------------- |
|
|
102
|
+
| **CI-1** | committed agent config that pre-authorizes broad tools or runs remote hooks |
|
|
103
|
+
| **CI-2** | injectable agent workflows — an outsider can trigger the agent and hijack it |
|
|
104
|
+
| **CI-3** | unpinned / `@latest` MCP servers or inline credentials (supply chain) |
|
|
105
|
+
| **CI-4** | secrets an injected agent could exfiltrate |
|
|
106
|
+
| **CI-6** | poisoned or dangerous instructions in `CLAUDE.md` / `AGENTS.md` / `.cursorrules` |
|
|
107
107
|
|
|
108
108
|
**Gate every PR** — the same engine as a GitHub Action, so a hijackable config can't get merged:
|
|
109
109
|
|
|
@@ -112,8 +112,14 @@ What it checks:
|
|
|
112
112
|
- uses: node9-ai/node9-proxy@v2
|
|
113
113
|
with:
|
|
114
114
|
fail-on: high # or 'never' to just comment
|
|
115
|
+
fail-on-scope: introduced # only what THIS PR added; 'all' (default) judges the whole repo
|
|
115
116
|
```
|
|
116
117
|
|
|
118
|
+
`fail-on-scope: introduced` is what makes the gate adoptable on a repository that already
|
|
119
|
+
has findings: the PR comment leads with what the change introduced, pre-existing findings
|
|
120
|
+
stay listed but do not block, and a base commit that cannot be read falls back to judging
|
|
121
|
+
everything rather than passing.
|
|
122
|
+
|
|
117
123
|
Marketplace: **[node9 Agent Security](https://github.com/marketplace/actions/node9-agent-security)**
|
|
118
124
|
|
|
119
125
|
Running it? Add the **[`scanned by node9` badge](docs/badges.md)** to your README.
|
package/dist/cli.js
CHANGED
|
@@ -57936,6 +57936,50 @@ function readLocalTree(dir) {
|
|
|
57936
57936
|
}
|
|
57937
57937
|
return { source: root, files, notes };
|
|
57938
57938
|
}
|
|
57939
|
+
function readGitRefTree(dir, ref) {
|
|
57940
|
+
const root = dir.replace(/^~/, process.env.HOME ?? "~");
|
|
57941
|
+
if (!ref || ref.startsWith("-")) return null;
|
|
57942
|
+
const git = (args) => {
|
|
57943
|
+
try {
|
|
57944
|
+
return (0, import_node_child_process.execFileSync)("git", ["-C", root, ...args], {
|
|
57945
|
+
encoding: "utf8",
|
|
57946
|
+
stdio: ["ignore", "pipe", "ignore"],
|
|
57947
|
+
maxBuffer: 32 * 1024 * 1024,
|
|
57948
|
+
timeout: 2e4
|
|
57949
|
+
});
|
|
57950
|
+
} catch {
|
|
57951
|
+
return null;
|
|
57952
|
+
}
|
|
57953
|
+
};
|
|
57954
|
+
const sha = git(["rev-parse", "--verify", "--quiet", `${ref}^{commit}`])?.trim();
|
|
57955
|
+
if (!sha) return null;
|
|
57956
|
+
const listing = git(["ls-tree", "-r", "--name-only", "-z", sha]);
|
|
57957
|
+
if (listing === null) return null;
|
|
57958
|
+
const all = listing.split("\0").filter(Boolean);
|
|
57959
|
+
const notes = [];
|
|
57960
|
+
const files = [];
|
|
57961
|
+
const seen = /* @__PURE__ */ new Set();
|
|
57962
|
+
const present = new Set(all);
|
|
57963
|
+
const add = (rel) => {
|
|
57964
|
+
if (seen.has(rel) || !present.has(rel)) return;
|
|
57965
|
+
seen.add(rel);
|
|
57966
|
+
const content = git(["show", `${sha}:${rel}`]);
|
|
57967
|
+
if (content !== null) files.push({ path: rel, content });
|
|
57968
|
+
};
|
|
57969
|
+
for (const rel of SURFACE_FILES) add(rel);
|
|
57970
|
+
const nested = all.filter((rel) => SURFACE_BASENAME.test(rel) && !isIgnoredDir(rel));
|
|
57971
|
+
if (nested.length > MAX_SURFACE_FILES) {
|
|
57972
|
+
notes.push(
|
|
57973
|
+
`repo is large \u2014 some agent-surface files may be INCOMPLETE (capped at ${MAX_SURFACE_FILES} files).`
|
|
57974
|
+
);
|
|
57975
|
+
}
|
|
57976
|
+
for (const rel of nested.slice(0, MAX_SURFACE_FILES)) add(rel);
|
|
57977
|
+
for (const rel of all) {
|
|
57978
|
+
if (rel.startsWith(`${WORKFLOW_DIR}/`) && /\.ya?ml$/.test(rel) && !rel.slice(WORKFLOW_DIR.length + 1).includes("/"))
|
|
57979
|
+
add(rel);
|
|
57980
|
+
}
|
|
57981
|
+
return { source: `${root}@${ref}`, files, notes };
|
|
57982
|
+
}
|
|
57939
57983
|
async function fetchTree(input, onProgress) {
|
|
57940
57984
|
if (isLocalPath(input)) return readLocalTree(input);
|
|
57941
57985
|
const parsed = parseRepoUrl(input);
|
|
@@ -58357,6 +58401,8 @@ function analyzeWorkflow(path77, content) {
|
|
|
58357
58401
|
const title = severity === "critical" || severity === "high" ? "Injectable agent workflow \u2014 untrusted input reaches a tool-using agent with secrets" : severity === "medium" ? "Agent workflow with a risky pattern (partially mitigated)" : "Agent workflow on a privileged trigger \u2014 review the actor gate";
|
|
58358
58402
|
return {
|
|
58359
58403
|
check: "CI-2",
|
|
58404
|
+
// One verdict per workflow file — the finding IS the file's reachability score.
|
|
58405
|
+
rule: "CI-2.injectable-workflow",
|
|
58360
58406
|
dimension: "workflows",
|
|
58361
58407
|
severity,
|
|
58362
58408
|
title,
|
|
@@ -58453,6 +58499,7 @@ function analyzeWorkflowSecrets(path77, content) {
|
|
|
58453
58499
|
if (!worst) return null;
|
|
58454
58500
|
return {
|
|
58455
58501
|
check: "CI-4",
|
|
58502
|
+
rule: "CI-4.agent-reachable-secret",
|
|
58456
58503
|
dimension: "data",
|
|
58457
58504
|
severity: worst.severity,
|
|
58458
58505
|
title: worst.severity === "advisory" ? "Secrets reachable by the agent \u2014 hardening" : "Exfiltratable secrets reachable by an injectable agent",
|
|
@@ -58499,6 +58546,10 @@ function analyzeAgentConfig(path77, content) {
|
|
|
58499
58546
|
const high = remoteExec || unpinned;
|
|
58500
58547
|
findings.push({
|
|
58501
58548
|
check: "CI-1",
|
|
58549
|
+
rule: "CI-1.hook-remote-code",
|
|
58550
|
+
// Identity is the command itself: the same hook keeps its identity when its
|
|
58551
|
+
// severity changes (pinned → unpinned), and two different hooks stay distinct.
|
|
58552
|
+
locator: cmd,
|
|
58502
58553
|
dimension: "toolRules",
|
|
58503
58554
|
severity: high ? "high" : "medium",
|
|
58504
58555
|
title: high ? "Agent hook runs UNPINNED/remote third-party code on every action" : "Agent hook runs third-party code in the agent hot path",
|
|
@@ -58519,6 +58570,9 @@ function analyzeAgentConfig(path77, content) {
|
|
|
58519
58570
|
const hasBackstop = deny.some((d) => /Bash|Write|Edit/.test(d));
|
|
58520
58571
|
findings.push({
|
|
58521
58572
|
check: "CI-1",
|
|
58573
|
+
rule: "CI-1.broad-allow",
|
|
58574
|
+
// File-level: one finding per config file. Adding a SECOND broad allow makes the
|
|
58575
|
+
// same statement about the same file, so it must not read as a new finding.
|
|
58522
58576
|
dimension: "toolRules",
|
|
58523
58577
|
severity: hasBackstop ? "medium" : "high",
|
|
58524
58578
|
title: hasBackstop ? "Committed agent config pre-authorizes broad tools" : "Committed agent config pre-authorizes broad tools with no deny backstop",
|
|
@@ -58552,6 +58606,8 @@ function analyzeMcpServers(servers, path77) {
|
|
|
58552
58606
|
if (/\bnpx\b/.test(argv) && (/@latest\b/.test(argv) || !/@\d/.test(argv))) {
|
|
58553
58607
|
findings.push({
|
|
58554
58608
|
check: "CI-3",
|
|
58609
|
+
rule: "CI-3.mcp-unpinned",
|
|
58610
|
+
locator: name,
|
|
58555
58611
|
dimension: "mcp",
|
|
58556
58612
|
severity: "medium",
|
|
58557
58613
|
title: `MCP server "${name}" runs an unpinned executable`,
|
|
@@ -58566,6 +58622,8 @@ function analyzeMcpServers(servers, path77) {
|
|
|
58566
58622
|
if (hit) {
|
|
58567
58623
|
findings.push({
|
|
58568
58624
|
check: "CI-3",
|
|
58625
|
+
rule: "CI-3.mcp-inline-credential",
|
|
58626
|
+
locator: `${name}.env.${k}`,
|
|
58569
58627
|
dimension: "mcp",
|
|
58570
58628
|
severity: "high",
|
|
58571
58629
|
title: `MCP server "${name}" has an inline credential`,
|
|
@@ -58603,6 +58661,10 @@ function analyzeCodexConfig(path77, content) {
|
|
|
58603
58661
|
].filter((s) => s !== null);
|
|
58604
58662
|
findings.push({
|
|
58605
58663
|
check: "CI-1",
|
|
58664
|
+
// One finding per Codex config; `danger-full-access` vs `approval_policy = never`
|
|
58665
|
+
// are two severities of the same statement, so tightening one is a de-escalation
|
|
58666
|
+
// of THIS finding rather than the removal of one and the arrival of another.
|
|
58667
|
+
rule: "CI-1.codex-unsafe-defaults",
|
|
58606
58668
|
dimension: "toolRules",
|
|
58607
58669
|
severity: fullAccess ? "high" : "medium",
|
|
58608
58670
|
title: fullAccess ? "Codex config grants a full-access sandbox" : "Codex config never requires approval",
|
|
@@ -58662,8 +58724,17 @@ function decodeSuspiciousBase64(text) {
|
|
|
58662
58724
|
}
|
|
58663
58725
|
return out;
|
|
58664
58726
|
}
|
|
58665
|
-
function mk(severity, title, signals, fix, path77) {
|
|
58666
|
-
return {
|
|
58727
|
+
function mk(rule, severity, title, signals, fix, path77) {
|
|
58728
|
+
return {
|
|
58729
|
+
check: "CI-6",
|
|
58730
|
+
rule,
|
|
58731
|
+
dimension: "instructions",
|
|
58732
|
+
severity,
|
|
58733
|
+
title,
|
|
58734
|
+
file: path77,
|
|
58735
|
+
signals,
|
|
58736
|
+
fix
|
|
58737
|
+
};
|
|
58667
58738
|
}
|
|
58668
58739
|
function analyzeInstructionFile(path77, content) {
|
|
58669
58740
|
const findings = [];
|
|
@@ -58671,6 +58742,7 @@ function analyzeInstructionFile(path77, content) {
|
|
|
58671
58742
|
if (TAG_CHARS.test(content))
|
|
58672
58743
|
findings.push(
|
|
58673
58744
|
mk(
|
|
58745
|
+
"CI-6.unicode-tag-chars",
|
|
58674
58746
|
"critical",
|
|
58675
58747
|
"Unicode tag characters in an agent instruction file",
|
|
58676
58748
|
[
|
|
@@ -58683,6 +58755,7 @@ function analyzeInstructionFile(path77, content) {
|
|
|
58683
58755
|
if (BIDI_OVERRIDE.test(content))
|
|
58684
58756
|
findings.push(
|
|
58685
58757
|
mk(
|
|
58758
|
+
"CI-6.bidi-override",
|
|
58686
58759
|
"critical",
|
|
58687
58760
|
"Bidirectional override characters in an agent instruction file",
|
|
58688
58761
|
[
|
|
@@ -58695,6 +58768,7 @@ function analyzeInstructionFile(path77, content) {
|
|
|
58695
58768
|
else if (BIDI_EMBED_ISOLATE.test(content))
|
|
58696
58769
|
findings.push(
|
|
58697
58770
|
mk(
|
|
58771
|
+
"CI-6.bidi-formatting",
|
|
58698
58772
|
"advisory",
|
|
58699
58773
|
"Bidirectional formatting characters in an agent instruction file",
|
|
58700
58774
|
[
|
|
@@ -58709,6 +58783,7 @@ function analyzeInstructionFile(path77, content) {
|
|
|
58709
58783
|
const revealed = OVERRIDE_RE.test(stripZeroWidth(content)) && !OVERRIDE_RE.test(content);
|
|
58710
58784
|
findings.push(
|
|
58711
58785
|
mk(
|
|
58786
|
+
"CI-6.zero-width",
|
|
58712
58787
|
revealed ? "critical" : "medium",
|
|
58713
58788
|
"Zero-width characters splitting text in an agent instruction file",
|
|
58714
58789
|
[
|
|
@@ -58725,6 +58800,7 @@ function analyzeInstructionFile(path77, content) {
|
|
|
58725
58800
|
const m = ov || ovEnc;
|
|
58726
58801
|
findings.push(
|
|
58727
58802
|
mk(
|
|
58803
|
+
"CI-6.prompt-override",
|
|
58728
58804
|
ovEnc ? "critical" : "high",
|
|
58729
58805
|
"Prompt-override directive in an agent instruction file",
|
|
58730
58806
|
[
|
|
@@ -58739,6 +58815,7 @@ function analyzeInstructionFile(path77, content) {
|
|
|
58739
58815
|
if (fo && !inHumanSection(content, fo.index) && !isNegated(content, fo.index)) {
|
|
58740
58816
|
findings.push(
|
|
58741
58817
|
mk(
|
|
58818
|
+
"CI-6.fetch-and-obey",
|
|
58742
58819
|
"medium",
|
|
58743
58820
|
"Instruction directs the agent to fetch and run remote code",
|
|
58744
58821
|
[`\`${fo[0].slice(0, 70).trim()}\` \u2014 fetch-and-obey, outside an install/setup section`],
|
|
@@ -58751,6 +58828,7 @@ function analyzeInstructionFile(path77, content) {
|
|
|
58751
58828
|
if (sp && !isNegated(content, sp.index)) {
|
|
58752
58829
|
findings.push(
|
|
58753
58830
|
mk(
|
|
58831
|
+
"CI-6.secret-path",
|
|
58754
58832
|
"medium",
|
|
58755
58833
|
"Instruction points the agent at credential material",
|
|
58756
58834
|
[`references \`${sp[0].slice(0, 50).trim()}\` \u2014 directs the agent toward secrets`],
|
|
@@ -58763,6 +58841,7 @@ function analyzeInstructionFile(path77, content) {
|
|
|
58763
58841
|
if (ex && !inHumanSection(content, ex.index) && !isNegated(content, ex.index)) {
|
|
58764
58842
|
findings.push(
|
|
58765
58843
|
mk(
|
|
58844
|
+
"CI-6.exfil-directive",
|
|
58766
58845
|
"medium",
|
|
58767
58846
|
"Instruction directs the agent to send data to an external endpoint",
|
|
58768
58847
|
[`\`${ex[0].slice(0, 70).trim()}\` \u2014 possible exfiltration directive`],
|
|
@@ -58774,8 +58853,82 @@ function analyzeInstructionFile(path77, content) {
|
|
|
58774
58853
|
return findings;
|
|
58775
58854
|
}
|
|
58776
58855
|
|
|
58856
|
+
// src/ci-check/diff.ts
|
|
58857
|
+
var import_node_crypto = require("crypto");
|
|
58858
|
+
function fingerprintOf(f) {
|
|
58859
|
+
const key = JSON.stringify([f.rule, f.file, f.locator ?? "", f.ordinal ?? 0]);
|
|
58860
|
+
return (0, import_node_crypto.createHash)("sha256").update(key).digest("hex").slice(0, 16);
|
|
58861
|
+
}
|
|
58862
|
+
function assignOrdinals(findings) {
|
|
58863
|
+
const seen = /* @__PURE__ */ new Map();
|
|
58864
|
+
for (const f of findings) {
|
|
58865
|
+
const key = JSON.stringify([f.rule, f.file, f.locator ?? ""]);
|
|
58866
|
+
const n = seen.get(key) ?? 0;
|
|
58867
|
+
if (n > 0) f.ordinal = n;
|
|
58868
|
+
seen.set(key, n + 1);
|
|
58869
|
+
}
|
|
58870
|
+
return findings;
|
|
58871
|
+
}
|
|
58872
|
+
function worstOf(severities) {
|
|
58873
|
+
let worst = null;
|
|
58874
|
+
for (const s of severities) {
|
|
58875
|
+
if (!worst || SEVERITY_RANK2[s] > SEVERITY_RANK2[worst]) worst = s;
|
|
58876
|
+
}
|
|
58877
|
+
return worst;
|
|
58878
|
+
}
|
|
58879
|
+
function baseStateOf(base) {
|
|
58880
|
+
if (!base) return "did-not-run";
|
|
58881
|
+
return base.incomplete ? "incomplete" : "ok";
|
|
58882
|
+
}
|
|
58883
|
+
function diffScans(base, head) {
|
|
58884
|
+
const state = baseStateOf(base);
|
|
58885
|
+
if (state !== "ok" || !base) {
|
|
58886
|
+
return {
|
|
58887
|
+
base: state,
|
|
58888
|
+
added: [],
|
|
58889
|
+
removed: [],
|
|
58890
|
+
unchanged: [...head.findings],
|
|
58891
|
+
escalated: [],
|
|
58892
|
+
worstIntroduced: head.worst,
|
|
58893
|
+
incomplete: true
|
|
58894
|
+
};
|
|
58895
|
+
}
|
|
58896
|
+
const baseByFp = /* @__PURE__ */ new Map();
|
|
58897
|
+
for (const f of base.findings) baseByFp.set(fingerprintOf(f), f);
|
|
58898
|
+
const added = [];
|
|
58899
|
+
const unchanged = [];
|
|
58900
|
+
const escalated = [];
|
|
58901
|
+
const matched = /* @__PURE__ */ new Set();
|
|
58902
|
+
for (const f of head.findings) {
|
|
58903
|
+
const fp = fingerprintOf(f);
|
|
58904
|
+
const prior = baseByFp.get(fp);
|
|
58905
|
+
if (!prior) {
|
|
58906
|
+
added.push(f);
|
|
58907
|
+
continue;
|
|
58908
|
+
}
|
|
58909
|
+
matched.add(fp);
|
|
58910
|
+
if (SEVERITY_RANK2[f.severity] > SEVERITY_RANK2[prior.severity]) {
|
|
58911
|
+
escalated.push({ finding: f, from: prior.severity, to: f.severity });
|
|
58912
|
+
} else {
|
|
58913
|
+
unchanged.push(f);
|
|
58914
|
+
}
|
|
58915
|
+
}
|
|
58916
|
+
const removed = base.findings.filter((f) => !matched.has(fingerprintOf(f)));
|
|
58917
|
+
return {
|
|
58918
|
+
base: state,
|
|
58919
|
+
added,
|
|
58920
|
+
removed,
|
|
58921
|
+
unchanged,
|
|
58922
|
+
escalated,
|
|
58923
|
+
worstIntroduced: worstOf([...added.map((f) => f.severity), ...escalated.map((e) => e.to)]),
|
|
58924
|
+
// The head side of the same guard: a scan that could not read every file has not
|
|
58925
|
+
// earned the word "clean", however trustworthy the base was.
|
|
58926
|
+
incomplete: head.incomplete
|
|
58927
|
+
};
|
|
58928
|
+
}
|
|
58929
|
+
|
|
58777
58930
|
// src/ci-check/index.ts
|
|
58778
|
-
function
|
|
58931
|
+
function worstOf2(findings) {
|
|
58779
58932
|
let worst = null;
|
|
58780
58933
|
for (const f of findings) {
|
|
58781
58934
|
if (!worst || SEVERITY_RANK2[f.severity] > SEVERITY_RANK2[worst]) worst = f.severity;
|
|
@@ -58809,11 +58962,12 @@ function scanTree(tree) {
|
|
|
58809
58962
|
notes.push(`checker degraded on ${file.path}: ${err2?.message ?? "error"}`);
|
|
58810
58963
|
}
|
|
58811
58964
|
}
|
|
58965
|
+
assignOrdinals(findings);
|
|
58812
58966
|
findings.sort(
|
|
58813
58967
|
(a, b) => SEVERITY_RANK2[b.severity] - SEVERITY_RANK2[a.severity] || a.file.localeCompare(b.file)
|
|
58814
58968
|
);
|
|
58815
58969
|
const incomplete = notes.some((nt) => /may be INCOMPLETE/i.test(nt));
|
|
58816
|
-
return { source: tree.source, findings, inspected, notes, worst:
|
|
58970
|
+
return { source: tree.source, findings, inspected, notes, worst: worstOf2(findings), incomplete };
|
|
58817
58971
|
}
|
|
58818
58972
|
async function scanRepo(input, onProgress) {
|
|
58819
58973
|
const tree = await fetchTree(input, onProgress);
|
|
@@ -58867,12 +59021,37 @@ function renderCta(res) {
|
|
|
58867
59021
|
function ownedHint(source) {
|
|
58868
59022
|
return source.startsWith("/") || source.startsWith(".") || source.startsWith("~");
|
|
58869
59023
|
}
|
|
58870
|
-
function
|
|
59024
|
+
function findingMd(f, L) {
|
|
59025
|
+
L.push(`**${ICON2[f.severity]} ${f.severity.toUpperCase()} \u2014 ${f.title}**`);
|
|
59026
|
+
L.push(`\`${f.file}${f.line ? ":" + f.line : ""}\` \xB7 ${f.rule}`);
|
|
59027
|
+
L.push("");
|
|
59028
|
+
for (const s of f.signals) L.push(`- ${s}`);
|
|
59029
|
+
if (f.mitigations?.length) L.push(`- _mitigated:_ ${f.mitigations.join("; ")}`);
|
|
59030
|
+
L.push("");
|
|
59031
|
+
L.push(`\u2192 **Fix:** ${f.fix}`);
|
|
59032
|
+
L.push("");
|
|
59033
|
+
}
|
|
59034
|
+
function baseWarning(base) {
|
|
59035
|
+
return base === "did-not-run" ? '\u26A0\uFE0F **Could not read the base commit**, so nothing below can be called "new" \u2014 every finding in this repo is listed. (A shallow clone is the usual cause: fetch the base ref.)' : "\u26A0\uFE0F **The base scan could not read every file**, so a finding missing from it would look new. Every finding in this repo is listed instead.";
|
|
59036
|
+
}
|
|
59037
|
+
function renderScan(res, diff) {
|
|
58871
59038
|
const L = [];
|
|
58872
59039
|
const n = res.findings.length;
|
|
58873
59040
|
const head = res.worst === "critical" || res.worst === "high" ? import_chalk32.default.red.bold("\u26A0\uFE0F agent-security risk found") : res.worst ? import_chalk32.default.yellow("agent-security notes") : res.incomplete ? import_chalk32.default.yellow.bold("\u26A0\uFE0F INCOMPLETE \u2014 could not read all files") : import_chalk32.default.green("\u2705 agent-security: clean");
|
|
58874
59041
|
L.push(`\u{1F6E1}\uFE0F ${import_chalk32.default.bold("node9 scan-repo")} \xB7 ${res.source} \xB7 ${head}`);
|
|
58875
59042
|
L.push(import_chalk32.default.gray(` inspected ${res.inspected.length} config file(s), ${n} finding(s)`));
|
|
59043
|
+
if (diff) {
|
|
59044
|
+
const introduced = diff.added.length + diff.escalated.length;
|
|
59045
|
+
L.push(
|
|
59046
|
+
diff.base !== "ok" ? import_chalk32.default.yellow.bold(
|
|
59047
|
+
` \u26A0\uFE0F base ${diff.base === "did-not-run" ? "could not be read" : "scan was incomplete"} \u2014 cannot say what is new; showing everything`
|
|
59048
|
+
) : introduced > 0 ? import_chalk32.default.red.bold(
|
|
59049
|
+
` \u26A0\uFE0F this change introduced ${introduced} finding(s)` + (diff.escalated.length ? ` (${diff.escalated.length} by widening an existing one)` : "")
|
|
59050
|
+
) : import_chalk32.default.green(
|
|
59051
|
+
` \u2705 this change introduced nothing` + (diff.unchanged.length ? ` (${diff.unchanged.length} pre-existing)` : "") + (diff.removed.length ? `, and fixed ${diff.removed.length}` : "")
|
|
59052
|
+
)
|
|
59053
|
+
);
|
|
59054
|
+
}
|
|
58876
59055
|
if (res.incomplete) {
|
|
58877
59056
|
const rateLimited = res.notes.some((nt) => /rate limit/i.test(nt));
|
|
58878
59057
|
L.push(
|
|
@@ -58912,7 +59091,7 @@ function renderScan(res) {
|
|
|
58912
59091
|
L.push(...renderCta(res));
|
|
58913
59092
|
return L.join("\n");
|
|
58914
59093
|
}
|
|
58915
|
-
function renderScanMarkdown(res) {
|
|
59094
|
+
function renderScanMarkdown(res, diff) {
|
|
58916
59095
|
const L = [];
|
|
58917
59096
|
const status = res.worst === "critical" || res.worst === "high" ? "\u26A0\uFE0F" : res.worst ? "\u{1F7E1}" : res.incomplete ? "\u26A0\uFE0F" : "\u2705";
|
|
58918
59097
|
L.push(`### \u{1F6E1}\uFE0F node9 agent-security \xB7 \`${res.source}\` \xB7 ${status}`);
|
|
@@ -58921,25 +59100,57 @@ function renderScanMarkdown(res) {
|
|
|
58921
59100
|
`Inspected ${res.inspected.length} config file(s) \xB7 **${res.findings.length} finding(s)**`
|
|
58922
59101
|
);
|
|
58923
59102
|
L.push("");
|
|
58924
|
-
|
|
58925
|
-
|
|
58926
|
-
|
|
58927
|
-
|
|
58928
|
-
|
|
58929
|
-
|
|
58930
|
-
|
|
58931
|
-
|
|
59103
|
+
if (diff && diff.base === "ok") {
|
|
59104
|
+
const introduced = [...diff.added, ...diff.escalated.map((e) => e.finding)];
|
|
59105
|
+
if (introduced.length === 0) {
|
|
59106
|
+
L.push(
|
|
59107
|
+
`\u2705 **This change introduces no agent-security findings.**` + (diff.removed.length ? ` It also fixes ${diff.removed.length}.` : "")
|
|
59108
|
+
);
|
|
59109
|
+
L.push("");
|
|
59110
|
+
} else {
|
|
59111
|
+
L.push(`#### \u26A0\uFE0F Introduced by this change \u2014 ${introduced.length} finding(s)`);
|
|
59112
|
+
L.push("");
|
|
59113
|
+
for (const f of diff.added) findingMd(f, L);
|
|
59114
|
+
for (const e of diff.escalated) {
|
|
59115
|
+
L.push(
|
|
59116
|
+
`> _Guardrail erosion: this finding already existed at **${e.from}** and this change widens it to **${e.to}**._`
|
|
59117
|
+
);
|
|
59118
|
+
L.push("");
|
|
59119
|
+
findingMd(e.finding, L);
|
|
59120
|
+
}
|
|
59121
|
+
}
|
|
59122
|
+
if (diff.removed.length) {
|
|
59123
|
+
L.push(`\u2705 Fixed by this change: ${diff.removed.length} finding(s).`);
|
|
59124
|
+
L.push("");
|
|
59125
|
+
}
|
|
59126
|
+
if (diff.unchanged.length) {
|
|
59127
|
+
L.push(
|
|
59128
|
+
`<details><summary>${diff.unchanged.length} pre-existing finding(s) \u2014 not introduced by this change</summary>`
|
|
59129
|
+
);
|
|
59130
|
+
L.push("");
|
|
59131
|
+
for (const f of diff.unchanged) findingMd(f, L);
|
|
59132
|
+
L.push("</details>");
|
|
59133
|
+
L.push("");
|
|
59134
|
+
}
|
|
59135
|
+
return L.join("\n");
|
|
59136
|
+
}
|
|
59137
|
+
if (diff) {
|
|
59138
|
+
L.push(baseWarning(diff.base));
|
|
58932
59139
|
L.push("");
|
|
58933
59140
|
}
|
|
59141
|
+
for (const f of res.findings) findingMd(f, L);
|
|
58934
59142
|
if (res.findings.length === 0) L.push("No committed agent-security issues found.");
|
|
58935
59143
|
return L.join("\n");
|
|
58936
59144
|
}
|
|
58937
|
-
function
|
|
58938
|
-
if (
|
|
58939
|
-
if (
|
|
58940
|
-
if (
|
|
59145
|
+
function exitCodeForSeverity(worst, incomplete) {
|
|
59146
|
+
if (worst === "critical" || worst === "high") return 2;
|
|
59147
|
+
if (worst === "medium") return 1;
|
|
59148
|
+
if (incomplete) return 3;
|
|
58941
59149
|
return 0;
|
|
58942
59150
|
}
|
|
59151
|
+
function exitCodeFor(res) {
|
|
59152
|
+
return exitCodeForSeverity(res.worst, res.incomplete);
|
|
59153
|
+
}
|
|
58943
59154
|
|
|
58944
59155
|
// src/cli/commands/scan-repo.ts
|
|
58945
59156
|
var SPIN = ["\u280B", "\u2819", "\u2839", "\u2838", "\u283C", "\u2834", "\u2826", "\u2827", "\u2807", "\u280F"];
|
|
@@ -58965,23 +59176,36 @@ function makeProgress(target, quiet) {
|
|
|
58965
59176
|
};
|
|
58966
59177
|
}
|
|
58967
59178
|
function registerScanRepoCommand(program2) {
|
|
58968
|
-
program2.command("scan-repo <target>").description("Scan a repo's agent-security surface (GitHub URL or local path)").option("--json", "emit the raw result as JSON").option("--markdown", "emit a Markdown report (for a PR comment)").
|
|
58969
|
-
|
|
58970
|
-
|
|
58971
|
-
|
|
58972
|
-
|
|
58973
|
-
|
|
58974
|
-
|
|
58975
|
-
|
|
58976
|
-
|
|
58977
|
-
|
|
58978
|
-
|
|
58979
|
-
|
|
58980
|
-
|
|
58981
|
-
|
|
59179
|
+
program2.command("scan-repo <target>").description("Scan a repo's agent-security surface (GitHub URL or local path)").option("--json", "emit the raw result as JSON").option("--markdown", "emit a Markdown report (for a PR comment)").option(
|
|
59180
|
+
"--base <ref>",
|
|
59181
|
+
"also scan this git ref and report what the working tree INTRODUCED (local path targets only)"
|
|
59182
|
+
).option(
|
|
59183
|
+
"--fail-on-introduced",
|
|
59184
|
+
"exit non-zero only for findings this change introduced (requires --base)"
|
|
59185
|
+
).action(
|
|
59186
|
+
async (target, opts) => {
|
|
59187
|
+
const { onProgress, done } = makeProgress(target, !!(opts.json || opts.markdown));
|
|
59188
|
+
let res;
|
|
59189
|
+
try {
|
|
59190
|
+
res = await scanRepo(target, onProgress);
|
|
59191
|
+
} finally {
|
|
59192
|
+
done();
|
|
59193
|
+
}
|
|
59194
|
+
let diff;
|
|
59195
|
+
if (opts.base) {
|
|
59196
|
+
const baseTree = readGitRefTree(target, opts.base);
|
|
59197
|
+
diff = diffScans(baseTree ? scanTree(baseTree) : null, res);
|
|
59198
|
+
}
|
|
59199
|
+
if (opts.json) {
|
|
59200
|
+
console.log(JSON.stringify(diff ? { ...res, diff } : res, null, 2));
|
|
59201
|
+
} else if (opts.markdown) {
|
|
59202
|
+
console.log(renderScanMarkdown(res, diff));
|
|
59203
|
+
} else {
|
|
59204
|
+
console.log(renderScan(res, diff));
|
|
59205
|
+
}
|
|
59206
|
+
process.exitCode = opts.failOnIntroduced && diff ? exitCodeForSeverity(diff.worstIntroduced, diff.incomplete) : exitCodeFor(res);
|
|
58982
59207
|
}
|
|
58983
|
-
|
|
58984
|
-
});
|
|
59208
|
+
);
|
|
58985
59209
|
}
|
|
58986
59210
|
|
|
58987
59211
|
// src/cli/commands/egress.ts
|