@erichll/pi-auto-review 0.3.4 → 0.4.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/CHANGELOG.md +20 -0
- package/README.md +2 -2
- package/package.json +8 -8
- package/src/config.json +2 -2
- package/src/index.ts +87 -10
- package/src/policy.ts +132 -8
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,25 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 0.4.0 - 2026-08-19
|
|
4
|
+
|
|
5
|
+
- **Credential-exfiltration hardening.** Expand deterministic terminal denies to cover
|
|
6
|
+
`.env` variants, common credential files, additional file readers and encoders,
|
|
7
|
+
shell substitutions, staged variables, redirects, and multi-stage pipelines that
|
|
8
|
+
upload credentials to network sinks. Template files such as `.env.example` and
|
|
9
|
+
`.env.sample` remain reviewable.
|
|
10
|
+
- **Reviewer refresh safety.** Re-resolve reviewer model/provider metadata for each
|
|
11
|
+
review, reacquire authentication for each attempt, and bind the reviewer session
|
|
12
|
+
identity to the endpoint and authentication fingerprint so provider or OAuth
|
|
13
|
+
refreshes cannot reuse a stale connection.
|
|
14
|
+
- **Compatibility.** Require Pi 0.84.1+ and permission-system 25.1.0+ to match the
|
|
15
|
+
current reviewer and forwarded-permission contracts.
|
|
16
|
+
|
|
17
|
+
## 0.3.5 - 2026-08-13
|
|
18
|
+
|
|
19
|
+
- Raise the default model-review `timeoutMs` from 45s to 90s and the default
|
|
20
|
+
`retries` from 1 to 2. Approval now tolerates real-world model latency better
|
|
21
|
+
while keeping the same bounded transcript surface.
|
|
22
|
+
|
|
3
23
|
## 0.3.4 - 2026-08-08
|
|
4
24
|
|
|
5
25
|
- Normalize direct and permission-system v24 forwarded evidence before policy
|
package/README.md
CHANGED
|
@@ -106,9 +106,9 @@ Package-shipped defaults live in `src/config.json`:
|
|
|
106
106
|
{
|
|
107
107
|
"model": "codex-auto-review",
|
|
108
108
|
"reasoning": "low",
|
|
109
|
-
"timeoutMs":
|
|
109
|
+
"timeoutMs": 90000,
|
|
110
110
|
"maxTokens": 1600,
|
|
111
|
-
"retries":
|
|
111
|
+
"retries": 2,
|
|
112
112
|
"maxUserTranscriptTokens": 1200,
|
|
113
113
|
"maxToolTranscriptTokens": 1200,
|
|
114
114
|
"maxRelevantResultTokens": 800,
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@erichll/pi-auto-review",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.4.0",
|
|
4
4
|
"description": "Model-backed boundary approval broker for Pi",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"license": "MIT",
|
|
@@ -47,15 +47,15 @@
|
|
|
47
47
|
"test": "node --disable-warning=ExperimentalWarning --experimental-loader ./test/typescript-loader.mjs --test test/*.test.ts"
|
|
48
48
|
},
|
|
49
49
|
"peerDependencies": {
|
|
50
|
-
"@earendil-works/pi-ai": "
|
|
51
|
-
"@earendil-works/pi-coding-agent": "
|
|
52
|
-
"@gotgenes/pi-permission-system": "
|
|
50
|
+
"@earendil-works/pi-ai": "^0.84.1",
|
|
51
|
+
"@earendil-works/pi-coding-agent": "^0.84.1",
|
|
52
|
+
"@gotgenes/pi-permission-system": "^25.1.0"
|
|
53
53
|
},
|
|
54
54
|
"devDependencies": {
|
|
55
|
-
"@earendil-works/pi-ai": "0.
|
|
56
|
-
"@earendil-works/pi-coding-agent": "0.
|
|
57
|
-
"@gotgenes/pi-permission-system": "
|
|
58
|
-
"@types/node": "^
|
|
55
|
+
"@earendil-works/pi-ai": "^0.84.1",
|
|
56
|
+
"@earendil-works/pi-coding-agent": "^0.84.1",
|
|
57
|
+
"@gotgenes/pi-permission-system": "^25.1.0",
|
|
58
|
+
"@types/node": "^26.2.0",
|
|
59
59
|
"typescript": "^6.0.3"
|
|
60
60
|
}
|
|
61
61
|
}
|
package/src/config.json
CHANGED
package/src/index.ts
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { createHash } from "node:crypto";
|
|
1
2
|
import { appendFileSync, readFileSync, realpathSync } from "node:fs";
|
|
2
3
|
import { homedir } from "node:os";
|
|
3
4
|
import { dirname, isAbsolute, join, relative, resolve } from "node:path";
|
|
@@ -93,13 +94,24 @@ type ReviewerRuntime = {
|
|
|
93
94
|
model: Parameters<typeof completeSimple>[0];
|
|
94
95
|
auth: {
|
|
95
96
|
apiKey?: string;
|
|
96
|
-
|
|
97
|
+
// ProviderHeaders allows null values (e.g. "clear" directives) in 0.84.x.
|
|
98
|
+
headers?: Record<string, string | null>;
|
|
97
99
|
env?: Record<string, string>;
|
|
98
100
|
};
|
|
99
101
|
streamSimple?: ApiStreamSimpleFunction;
|
|
100
102
|
sessionId: string;
|
|
101
103
|
};
|
|
102
104
|
|
|
105
|
+
// Model/stream metadata is re-resolved for every review instead of being
|
|
106
|
+
// cached for the session: pi can refresh models.json or re-register a
|
|
107
|
+
// provider while a session is live, and the resolved model's baseUrl, api
|
|
108
|
+
// or streamSimple may have changed, so a per-session cache could keep
|
|
109
|
+
// calling a stale endpoint. Authentication is also deliberately excluded
|
|
110
|
+
// because pi resolves models.json auth and headers dynamically on every
|
|
111
|
+
// request (including OAuth refresh); it is reacquired per model call in
|
|
112
|
+
// complete().
|
|
113
|
+
type ReviewerMeta = Omit<ReviewerRuntime, "auth" | "sessionId">;
|
|
114
|
+
|
|
103
115
|
type ReviewResult = {
|
|
104
116
|
decision: ModelDecision;
|
|
105
117
|
attempts: number;
|
|
@@ -125,9 +137,9 @@ const BOUNDED_SURFACES = new Set(["path", "external_directory"]);
|
|
|
125
137
|
const DEFAULT_CONFIG: Config = {
|
|
126
138
|
model: "codex-auto-review",
|
|
127
139
|
reasoning: "low",
|
|
128
|
-
timeoutMs:
|
|
140
|
+
timeoutMs: 90_000,
|
|
129
141
|
maxTokens: 1_600,
|
|
130
|
-
retries:
|
|
142
|
+
retries: 2,
|
|
131
143
|
maxUserTranscriptTokens: 1_200,
|
|
132
144
|
maxToolTranscriptTokens: 1_200,
|
|
133
145
|
maxRelevantResultTokens: 800,
|
|
@@ -644,10 +656,50 @@ function parseModelRef(modelRef: string): {
|
|
|
644
656
|
return { provider, modelId: idParts.join("/") };
|
|
645
657
|
}
|
|
646
658
|
|
|
647
|
-
|
|
659
|
+
function reviewerSessionId(
|
|
660
|
+
ctx: ExtensionContext,
|
|
661
|
+
config: Config,
|
|
662
|
+
model: ReviewerMeta["model"],
|
|
663
|
+
auth: ReviewerRuntime["auth"],
|
|
664
|
+
): string {
|
|
665
|
+
// pi-ai reuses Codex WebSockets by session/account without comparing the
|
|
666
|
+
// socket URL or handshake headers. Bind the cache identity to endpoint and
|
|
667
|
+
// authentication metadata so a live provider/auth refresh cannot reuse the
|
|
668
|
+
// old connection. Sensitive values only feed the hash below.
|
|
669
|
+
// Hash the complete identity and keep the result below pi-ai's 64-character
|
|
670
|
+
// prompt-cache-key limit, which would otherwise be able to truncate away a
|
|
671
|
+
// distinguishing suffix on long session IDs or base URLs.
|
|
672
|
+
const identity = JSON.stringify({
|
|
673
|
+
sessionId: ctx.sessionManager.getSessionId(),
|
|
674
|
+
modelRef: config.model,
|
|
675
|
+
provider: model.provider,
|
|
676
|
+
modelId: model.id,
|
|
677
|
+
api: model.api,
|
|
678
|
+
baseUrl: model.baseUrl,
|
|
679
|
+
modelHeaders: Object.entries(model.headers ?? {}).sort(([a], [b]) =>
|
|
680
|
+
a.localeCompare(b),
|
|
681
|
+
),
|
|
682
|
+
auth: {
|
|
683
|
+
apiKey: auth.apiKey,
|
|
684
|
+
headers: Object.entries(auth.headers ?? {}).sort(([a], [b]) =>
|
|
685
|
+
a.localeCompare(b),
|
|
686
|
+
),
|
|
687
|
+
env: Object.entries(auth.env ?? {}).sort(([a], [b]) =>
|
|
688
|
+
a.localeCompare(b),
|
|
689
|
+
),
|
|
690
|
+
},
|
|
691
|
+
});
|
|
692
|
+
const fingerprint = createHash("sha256")
|
|
693
|
+
.update(identity)
|
|
694
|
+
.digest("hex")
|
|
695
|
+
.slice(0, 48);
|
|
696
|
+
return `pi-auto-review-${fingerprint}`;
|
|
697
|
+
}
|
|
698
|
+
|
|
699
|
+
async function resolveReviewerMeta(
|
|
648
700
|
ctx: ExtensionContext,
|
|
649
701
|
config: Config,
|
|
650
|
-
): Promise<
|
|
702
|
+
): Promise<ReviewerMeta> {
|
|
651
703
|
const { provider, modelId } = parseModelRef(config.model);
|
|
652
704
|
const available = ctx.modelRegistry.getAvailable();
|
|
653
705
|
const registeredModel = provider
|
|
@@ -671,8 +723,6 @@ async function resolveReviewer(
|
|
|
671
723
|
: `model ${config.model} is unavailable`,
|
|
672
724
|
);
|
|
673
725
|
}
|
|
674
|
-
const auth = await ctx.modelRegistry.getApiKeyAndHeaders(model);
|
|
675
|
-
if (!auth.ok) throw new Error(`model authentication failed: ${auth.error}`);
|
|
676
726
|
|
|
677
727
|
const registered = (
|
|
678
728
|
ctx.modelRegistry as ExtensionContext["modelRegistry"] & {
|
|
@@ -689,13 +739,24 @@ async function resolveReviewer(
|
|
|
689
739
|
|
|
690
740
|
return {
|
|
691
741
|
model,
|
|
692
|
-
auth,
|
|
693
742
|
streamSimple:
|
|
694
743
|
registered?.api === model.api ? registered.streamSimple : undefined,
|
|
695
|
-
sessionId: `${ctx.sessionManager.getSessionId()}-codex-auto-review`,
|
|
696
744
|
};
|
|
697
745
|
}
|
|
698
746
|
|
|
747
|
+
// pi resolves models.json auth and headers dynamically on every request;
|
|
748
|
+
// reacquire authentication per model call instead of pinning it for the
|
|
749
|
+
// session (rotating OAuth tokens would otherwise go stale and fail closed
|
|
750
|
+
// until session restart).
|
|
751
|
+
async function resolveApiKeyAndHeaders(
|
|
752
|
+
ctx: ExtensionContext,
|
|
753
|
+
model: ReviewerMeta["model"],
|
|
754
|
+
): Promise<ReviewerRuntime["auth"]> {
|
|
755
|
+
const auth = await ctx.modelRegistry.getApiKeyAndHeaders(model);
|
|
756
|
+
if (!auth.ok) throw new Error(`model authentication failed: ${auth.error}`);
|
|
757
|
+
return auth;
|
|
758
|
+
}
|
|
759
|
+
|
|
699
760
|
async function modelCall(
|
|
700
761
|
runtime: ReviewerRuntime,
|
|
701
762
|
config: Config,
|
|
@@ -745,11 +806,17 @@ async function modelCall(
|
|
|
745
806
|
return text;
|
|
746
807
|
}
|
|
747
808
|
|
|
809
|
+
type ReviewerResolver = (
|
|
810
|
+
ctx: ExtensionContext,
|
|
811
|
+
config: Config,
|
|
812
|
+
) => Promise<ReviewerMeta>;
|
|
813
|
+
|
|
748
814
|
async function complete(
|
|
749
815
|
ctx: ExtensionContext,
|
|
750
816
|
config: Config,
|
|
751
817
|
request: BoundaryRequest,
|
|
752
818
|
reviewerContext?: BoundaryReviewerContext,
|
|
819
|
+
resolve: ReviewerResolver = resolveReviewerMeta,
|
|
753
820
|
): Promise<ReviewResult> {
|
|
754
821
|
const started = Date.now();
|
|
755
822
|
const transcript = buildClassifierTranscript(
|
|
@@ -772,11 +839,17 @@ async function complete(
|
|
|
772
839
|
if (controller.signal.aborted) {
|
|
773
840
|
throw new Error("review aborted before model resolution");
|
|
774
841
|
}
|
|
775
|
-
const
|
|
842
|
+
const meta = await resolve(ctx, config);
|
|
776
843
|
let lastError: unknown;
|
|
777
844
|
const retryErrors: string[] = [];
|
|
778
845
|
for (let attempt = 1; attempt <= config.retries + 1; attempt++) {
|
|
779
846
|
try {
|
|
847
|
+
const auth = await resolveApiKeyAndHeaders(ctx, meta.model);
|
|
848
|
+
const runtime: ReviewerRuntime = {
|
|
849
|
+
...meta,
|
|
850
|
+
auth,
|
|
851
|
+
sessionId: reviewerSessionId(ctx, config, meta.model, auth),
|
|
852
|
+
};
|
|
780
853
|
const decision = parseDecision(
|
|
781
854
|
await modelCall(
|
|
782
855
|
runtime,
|
|
@@ -885,6 +958,9 @@ export function createPiAutoReviewExtension(
|
|
|
885
958
|
let disposeBrokerService: (() => void) | undefined;
|
|
886
959
|
const reviewResults = new Map<string, ReviewResult>();
|
|
887
960
|
let broker: BoundaryApprovalBroker | undefined;
|
|
961
|
+
// Reviewer metadata is re-resolved per review (see ReviewerMeta above),
|
|
962
|
+
// so a models.json or provider refresh mid-session is observed on the
|
|
963
|
+
// next review instead of reusing a stale model/stream binding.
|
|
888
964
|
const uiAutoConfirmer = new PermissionUiAutoConfirmer(
|
|
889
965
|
() => config.autoConfirmBoundedAllows,
|
|
890
966
|
);
|
|
@@ -898,6 +974,7 @@ export function createPiAutoReviewExtension(
|
|
|
898
974
|
config,
|
|
899
975
|
request,
|
|
900
976
|
reviewerContext,
|
|
977
|
+
resolveReviewerMeta,
|
|
901
978
|
);
|
|
902
979
|
if (request.source === "permission-system") {
|
|
903
980
|
reviewResults.set(request.id, result);
|
package/src/policy.ts
CHANGED
|
@@ -298,15 +298,139 @@ export function deterministicHardDeny(
|
|
|
298
298
|
};
|
|
299
299
|
}
|
|
300
300
|
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
301
|
+
// Secret path text is only evidence when it occurs as the operand of an
|
|
302
|
+
// operation that reads a file. In particular, a slash-delimited `.env` in
|
|
303
|
+
// a URL or literal request payload is not itself a credential source.
|
|
304
|
+
const envFileName =
|
|
305
|
+
String.raw`\.env(?:\.(?!example\b|sample\b)[A-Za-z0-9_-]*)*`;
|
|
306
|
+
const envFile =
|
|
307
|
+
envFileName + String.raw`(?=[\s"'/@<>=|;&)\`]|$)`;
|
|
308
|
+
const envPath =
|
|
309
|
+
String.raw`(?:[^\s"'@<>=|;&()]+\/)*` + envFile;
|
|
310
|
+
const credentialDirectoryPath =
|
|
311
|
+
String.raw`(?:~\/|\$HOME\/|\$\{HOME\}\/|\/|\.\.?\/)?` +
|
|
312
|
+
String.raw`(?:[^\s"'@<>=|;&()\/]+\/)*` +
|
|
313
|
+
String.raw`(?:\.ssh\/(?:id_[A-Za-z0-9_-]+|authorized_keys)|\.aws\/credentials|\.kube\/config|\.docker\/config\.json|\.npmrc|\.netrc|\.pi\/agent\/auth\.json)`;
|
|
314
|
+
const credentialPath =
|
|
315
|
+
String.raw`(?:` +
|
|
316
|
+
credentialDirectoryPath +
|
|
317
|
+
String.raw`|` + envPath + String.raw`)`;
|
|
318
|
+
const directCredentialUpload = new RegExp(
|
|
319
|
+
String.raw`\b(?:curl|wget)\b[^;\n]*(?:` +
|
|
320
|
+
String.raw`(?:--data(?:-binary|-urlencode)?|-d|--form|-F)(?:=|\s+)[^;\n\s]*@\s*["']?` + credentialPath +
|
|
321
|
+
String.raw`|(?:--upload-file|--post-file)(?:=|\s+)["']?` + credentialPath +
|
|
322
|
+
String.raw`|-T(?:\s*|=)["']?` + credentialPath +
|
|
323
|
+
String.raw`)["']?`,
|
|
324
|
+
"i",
|
|
325
|
+
);
|
|
326
|
+
const redirectedCredentialUpload = new RegExp(
|
|
327
|
+
String.raw`\b(?:` +
|
|
328
|
+
String.raw`(?:curl|wget)\b[^;\n]*(?:--data(?:-binary|-raw|-urlencode)?|-d|--form|-F|--upload-file|-T|--post-file|--post-data)[^;\n]*` +
|
|
329
|
+
String.raw`|(?:nc|ncat|socat)\b[^;\n]*` +
|
|
330
|
+
String.raw`)<\s*["']?` +
|
|
331
|
+
credentialPath,
|
|
332
|
+
"i",
|
|
333
|
+
);
|
|
334
|
+
// The pipe matcher covers any content-emitting reader that names a secret
|
|
335
|
+
// file (a bare filename such as `head .env`, `dd if=.env`, or a path like
|
|
336
|
+
// ~/.aws/credentials) and feeds a network sink. The producer set is
|
|
337
|
+
// enumerated rather than derived from directCredentialUpload because a bare
|
|
338
|
+
// filename argument carries no path/@/redirect evidence; keeping the list
|
|
339
|
+
// broad is fail-closed and cheap, since a match also requires a secret-file
|
|
340
|
+
// token before the pipe and a network sink after it. This is best-effort
|
|
341
|
+
// hardening, not a completeness guarantee: readers not on the list still
|
|
342
|
+
// fall through to the contextual matchers below or the dynamic reviewer.
|
|
343
|
+
// The leading lookbehind keeps the producers from
|
|
344
|
+
// matching mid-word (star, code) so the terms stay real tool tokens.
|
|
345
|
+
const credentialPipe = new RegExp(
|
|
346
|
+
String.raw`(?<![A-Za-z0-9_])(?:cat|sed|awk|base64|openssl|head|tail|grep|dd|sort|cut|strings|rev|uniq|fold|od|xxd|hexdump|base32|uuencode|gzip|bzip2|xz|zstd|tar|zip)\b[^|;\n]*(?:\.ssh|\.aws|\.kube|\.docker|\.npmrc|\.netrc|` +
|
|
347
|
+
String.raw`(?:^|[\s"'=/@])` +
|
|
348
|
+
envFile +
|
|
349
|
+
String.raw`|auth\.json)[^|;\n]*\|[^;\n]*\b(?:curl|wget|nc|ncat|socat)\b`,
|
|
350
|
+
"i",
|
|
351
|
+
);
|
|
352
|
+
const credentialReader =
|
|
353
|
+
String.raw`(?<![A-Za-z0-9_])(?:cat|sed|awk|base64|openssl|head|tail|grep|dd|sort|cut|strings|rev|uniq|fold|od|xxd|hexdump|base32|uuencode|gzip|bzip2|xz|zstd|tar|zip)\b[^|;\n]*` +
|
|
354
|
+
credentialPath;
|
|
355
|
+
const credentialShellExpansion =
|
|
356
|
+
String.raw`(?:\$\([^)]*` +
|
|
357
|
+
credentialReader +
|
|
358
|
+
String.raw`[^)]*\)|<\([^)]*` +
|
|
359
|
+
credentialReader +
|
|
360
|
+
String.raw`[^)]*\)|\`[^\`\n]*` +
|
|
361
|
+
credentialReader +
|
|
362
|
+
String.raw`[^\`\n]*\`)`;
|
|
363
|
+
const credentialExpansionUpload = new RegExp(
|
|
364
|
+
String.raw`\b(?:curl|wget)\b[^;\n]*(?:--data(?:-binary|-raw|-urlencode)?|-d|--form|-F|--upload-file|-T|--post-file|--post-data)[^;\n]*` +
|
|
365
|
+
credentialShellExpansion,
|
|
366
|
+
"i",
|
|
367
|
+
);
|
|
368
|
+
const redirectedCredentialExpansion = new RegExp(
|
|
369
|
+
String.raw`\b(?:nc|ncat|socat)\b[^;\n]*(?:<<<|<)\s*` +
|
|
370
|
+
credentialShellExpansion,
|
|
371
|
+
"i",
|
|
372
|
+
);
|
|
373
|
+
const stagedCredentialVariableRead = new RegExp(
|
|
374
|
+
String.raw`\b([A-Za-z_][A-Za-z0-9_]*)\s*=\s*` +
|
|
375
|
+
credentialShellExpansion,
|
|
376
|
+
"gi",
|
|
377
|
+
);
|
|
378
|
+
let stagedCredentialVariableUpload = false;
|
|
379
|
+
for (const match of command.matchAll(stagedCredentialVariableRead)) {
|
|
380
|
+
const variable = match[1];
|
|
381
|
+
const suffix = command.slice((match.index ?? 0) + match[0].length);
|
|
382
|
+
const escapedVariable = variable.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
383
|
+
const variableUpload = new RegExp(
|
|
384
|
+
String.raw`(?:\b(?:curl|wget)\b[^;\n]*(?:--data(?:-binary|-raw|-urlencode)?|-d|--form|-F|--upload-file|-T|--post-file|--post-data)[^;\n]*|\b(?:nc|ncat|socat)\b[^;\n]*<<<\s*["']?)` +
|
|
385
|
+
String.raw`(?:\$\{` +
|
|
386
|
+
escapedVariable +
|
|
387
|
+
String.raw`\}|\$` +
|
|
388
|
+
escapedVariable +
|
|
389
|
+
String.raw`(?![A-Za-z0-9_]))`,
|
|
390
|
+
"i",
|
|
391
|
+
);
|
|
392
|
+
if (variableUpload.test(suffix)) {
|
|
393
|
+
stagedCredentialVariableUpload = true;
|
|
394
|
+
break;
|
|
395
|
+
}
|
|
396
|
+
}
|
|
397
|
+
const credentialSubstitution = new RegExp(
|
|
398
|
+
String.raw`\b(?:nc|ncat|socat)\b[^;\n]*\$\([^)]*` +
|
|
399
|
+
credentialReader +
|
|
400
|
+
String.raw`[^)]*\)`,
|
|
401
|
+
"i",
|
|
402
|
+
);
|
|
403
|
+
const stagedCredentialRead = new RegExp(
|
|
404
|
+
credentialReader + String.raw`[^|;\n]*>\s*["']?([^\s"';&|]+)["']?`,
|
|
405
|
+
"gi",
|
|
406
|
+
);
|
|
407
|
+
let stagedCredentialUpload = false;
|
|
408
|
+
for (const match of command.matchAll(stagedCredentialRead)) {
|
|
409
|
+
const target = match[1];
|
|
410
|
+
const suffix = command.slice((match.index ?? 0) + match[0].length);
|
|
411
|
+
const escapedTarget = target.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
412
|
+
const targetUpload = new RegExp(
|
|
413
|
+
String.raw`\b(?:curl|wget)\b[^;\n]*(?:` +
|
|
414
|
+
String.raw`(?:--data(?:-binary|-urlencode)?|-d|--form|-F)(?:=|\s+)[^;\n\s]*@\s*["']?` + escapedTarget +
|
|
415
|
+
String.raw`|(?:--upload-file|--post-file)(?:=|\s+)["']?` + escapedTarget +
|
|
416
|
+
String.raw`|-T(?:\s*|=)["']?` + escapedTarget +
|
|
417
|
+
String.raw`)(?=[\s"']|$)`,
|
|
418
|
+
"i",
|
|
419
|
+
);
|
|
420
|
+
if (targetUpload.test(suffix)) {
|
|
421
|
+
stagedCredentialUpload = true;
|
|
422
|
+
break;
|
|
423
|
+
}
|
|
424
|
+
}
|
|
307
425
|
if (
|
|
308
|
-
|
|
309
|
-
|
|
426
|
+
directCredentialUpload.test(command) ||
|
|
427
|
+
redirectedCredentialUpload.test(command) ||
|
|
428
|
+
credentialPipe.test(command) ||
|
|
429
|
+
credentialExpansionUpload.test(command) ||
|
|
430
|
+
redirectedCredentialExpansion.test(command) ||
|
|
431
|
+
credentialSubstitution.test(command) ||
|
|
432
|
+
stagedCredentialVariableUpload ||
|
|
433
|
+
stagedCredentialUpload
|
|
310
434
|
) {
|
|
311
435
|
return {
|
|
312
436
|
rule: "credential-exfiltration",
|