@reneturcios/hookcheck 0.1.10 → 0.1.12
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 +3 -3
- package/dist/core.d.ts +1 -1
- package/dist/core.js +1016 -68
- package/dist/core.js.map +1 -1
- package/package.json +1 -1
- package/server.json +2 -2
package/dist/core.js
CHANGED
|
@@ -6,7 +6,7 @@
|
|
|
6
6
|
*/
|
|
7
7
|
import { existsSync, lstatSync, readdirSync, readFileSync, statSync } from "node:fs";
|
|
8
8
|
import { dirname, join, relative, resolve, sep } from "node:path";
|
|
9
|
-
export const VERSION = "0.1.
|
|
9
|
+
export const VERSION = "0.1.12";
|
|
10
10
|
export const RISKY_RULES = [
|
|
11
11
|
{ id: "DECODE-PIPE-SH", pattern: /\bbase64\s+(-d|--decode|-D)\b[^|]*\|\s*(sudo\s+)?(sh|bash|zsh|node|python3?|perl|ruby)\b/, evidence: "decodes a base64 payload and pipes it straight into an interpreter", severe: true, remote: true },
|
|
12
12
|
{ id: "CURL-PIPE-SH", pattern: /\b(curl|wget)\b[^|]*\|\s*(sudo\s+)?(sh|bash|zsh|node|python3?|perl|ruby)\b/, evidence: "pipes a network download straight into an interpreter", severe: true, remote: true },
|
|
@@ -95,13 +95,60 @@ function inRanges(idx, ranges) {
|
|
|
95
95
|
function refOutsideData(seg, name, segStart, dataRanges) {
|
|
96
96
|
return refOutsideRanges(seg, name, segStart, dataRanges);
|
|
97
97
|
}
|
|
98
|
-
|
|
98
|
+
/**
|
|
99
|
+
* Positions (global indices) of genuine references to `name` inside
|
|
100
|
+
* `seg`: occurrences that do not begin inside an excluded range, are
|
|
101
|
+
* not a property name (obj.name reads a property of obj, never the
|
|
102
|
+
* variable), and are not an object or dict KEY or a parameter
|
|
103
|
+
* declaration (a name followed by ":" whose previous non-space
|
|
104
|
+
* character is "{", ",", "(", or a line start is a binding or a key,
|
|
105
|
+
* not a read). The key rule is the n8n set-latest shape (P-110): a
|
|
106
|
+
* response variable named `body` tainted by a local helper call made
|
|
107
|
+
* every fetch options object with a `body:` key read as a flow of
|
|
108
|
+
* that variable into the send. A ternary value (cond ? name : other)
|
|
109
|
+
* keeps counting: its previous character is "?", not a key context.
|
|
110
|
+
*/
|
|
111
|
+
function refPositions(seg, name, segStart, ranges) {
|
|
112
|
+
const out = [];
|
|
99
113
|
const re = new RegExp(`\\b${name.replace(/\$/g, "\\$")}\\b`, "g");
|
|
100
114
|
for (const m of seg.matchAll(re)) {
|
|
101
|
-
|
|
102
|
-
|
|
115
|
+
const idx = m.index ?? 0;
|
|
116
|
+
if (inRanges(segStart + idx, ranges))
|
|
117
|
+
continue;
|
|
118
|
+
if (idx > 0 && seg[idx - 1] === ".")
|
|
119
|
+
continue;
|
|
120
|
+
let j = idx + name.length;
|
|
121
|
+
while (j < seg.length && (seg[j] === " " || seg[j] === "\t"))
|
|
122
|
+
j++;
|
|
123
|
+
if (seg[j] === ":") {
|
|
124
|
+
let k = idx - 1;
|
|
125
|
+
while (k >= 0 && (seg[k] === " " || seg[k] === "\t"))
|
|
126
|
+
k--;
|
|
127
|
+
if (k < 0 || seg[k] === "{" || seg[k] === "," || seg[k] === "(" || seg[k] === "\n")
|
|
128
|
+
continue;
|
|
129
|
+
}
|
|
130
|
+
// An assignment target is a binding, not a read: a name at a
|
|
131
|
+
// statement position followed by a single "=" (not "==" or
|
|
132
|
+
// "=>") is being bound by this statement, so a send statement
|
|
133
|
+
// that ASSIGNS its result from a command substitution carrying
|
|
134
|
+
// an authenticated curl (id_token_status="$(curl ...)") does not
|
|
135
|
+
// flow the variable anywhere by mentioning its own name.
|
|
136
|
+
if (seg[j] === "=" && seg[j + 1] !== "=" && seg[j + 1] !== ">") {
|
|
137
|
+
let k = idx - 1;
|
|
138
|
+
while (k >= 0 && (seg[k] === " " || seg[k] === "\t"))
|
|
139
|
+
k--;
|
|
140
|
+
if (k < 0 || seg[k] === ";" || seg[k] === "\n" || seg[k] === "," || seg[k] === "{" || seg[k] === "(")
|
|
141
|
+
continue;
|
|
142
|
+
const prevWord = seg.slice(Math.max(0, k - 6), k + 1).match(/(?:^|\s)(?:let|const|var|export)$/);
|
|
143
|
+
if (prevWord)
|
|
144
|
+
continue;
|
|
145
|
+
}
|
|
146
|
+
out.push(segStart + idx);
|
|
103
147
|
}
|
|
104
|
-
return
|
|
148
|
+
return out;
|
|
149
|
+
}
|
|
150
|
+
function refOutsideRanges(seg, name, segStart, ranges) {
|
|
151
|
+
return refPositions(seg, name, segStart, ranges).length > 0;
|
|
105
152
|
}
|
|
106
153
|
function blankRanges(text, ranges) {
|
|
107
154
|
if (ranges.length === 0)
|
|
@@ -543,17 +590,193 @@ function fetchPositions(text, depth = 0) {
|
|
|
543
590
|
}
|
|
544
591
|
return out;
|
|
545
592
|
}
|
|
546
|
-
/**
|
|
547
|
-
|
|
593
|
+
/**
|
|
594
|
+
* True when a CURL-PIPE-SH match starts a real fetch and feeds an
|
|
595
|
+
* interpreter that executes stdin. Matches run on the newline
|
|
596
|
+
* preserving code text (the same text fetchPositions indexes), so
|
|
597
|
+
* the pipe target's own line bounds its argument tail: the next
|
|
598
|
+
* line of a block step is a new statement, never a target operand.
|
|
599
|
+
*/
|
|
600
|
+
function curlPipeIsCommandFetch(codeText, positions) {
|
|
548
601
|
if (positions.size === 0)
|
|
549
602
|
return false;
|
|
550
|
-
for (const m of
|
|
603
|
+
for (const m of codeText.matchAll(/\b(curl|wget)\b[^|]*\|\s*(?:sudo\s+)?(sh|bash|zsh|node|python3?|perl|ruby)\b/g)) {
|
|
551
604
|
const at = m.index + m[0].indexOf(m[1]);
|
|
552
|
-
if (positions.has(at))
|
|
605
|
+
if (!positions.has(at))
|
|
606
|
+
continue;
|
|
607
|
+
if (pipeTargetExecutesStdin(m[2], pipeTail(codeText, m.index + m[0].length)))
|
|
608
|
+
return true;
|
|
609
|
+
}
|
|
610
|
+
return false;
|
|
611
|
+
}
|
|
612
|
+
/** True when any DECODE-PIPE-SH match feeds an interpreter that executes stdin. */
|
|
613
|
+
function decodePipeExecutesStdin(codeText) {
|
|
614
|
+
for (const m of codeText.matchAll(/\bbase64\s+(?:-d|--decode|-D)\b[^|]*\|\s*(?:sudo\s+)?(sh|bash|zsh|node|python3?|perl|ruby)\b/g)) {
|
|
615
|
+
if (pipeTargetExecutesStdin(m[1], pipeTail(codeText, m.index + m[0].length)))
|
|
553
616
|
return true;
|
|
554
617
|
}
|
|
555
618
|
return false;
|
|
556
619
|
}
|
|
620
|
+
/**
|
|
621
|
+
* The text of the pipeline segment that follows a pipe target
|
|
622
|
+
* interpreter word, cut at the next unquoted pipe, statement
|
|
623
|
+
* separator, or redirection (quoted program text is kept whole).
|
|
624
|
+
*/
|
|
625
|
+
function pipeTail(text, from) {
|
|
626
|
+
let quote = "";
|
|
627
|
+
for (let i = from; i < text.length; i++) {
|
|
628
|
+
const c = text[i];
|
|
629
|
+
if (quote) {
|
|
630
|
+
if (c === "\\" && quote === '"') {
|
|
631
|
+
i++;
|
|
632
|
+
continue;
|
|
633
|
+
}
|
|
634
|
+
if (c === quote)
|
|
635
|
+
quote = "";
|
|
636
|
+
continue;
|
|
637
|
+
}
|
|
638
|
+
if (c === "'" || c === '"' || c === "`") {
|
|
639
|
+
quote = c;
|
|
640
|
+
continue;
|
|
641
|
+
}
|
|
642
|
+
if (c === "|" || c === ";" || c === "&" || c === ">" || c === "<" || c === "\n")
|
|
643
|
+
return text.slice(from, i);
|
|
644
|
+
}
|
|
645
|
+
return text.slice(from);
|
|
646
|
+
}
|
|
647
|
+
/** Quote aware spans of the whitespace separated tokens in `seg`. */
|
|
648
|
+
function shellTokenSpans(seg) {
|
|
649
|
+
const out = [];
|
|
650
|
+
let i = 0;
|
|
651
|
+
while (i < seg.length) {
|
|
652
|
+
while (i < seg.length && /\s/.test(seg[i]))
|
|
653
|
+
i++;
|
|
654
|
+
if (i >= seg.length)
|
|
655
|
+
break;
|
|
656
|
+
const start = i;
|
|
657
|
+
let quote = "";
|
|
658
|
+
while (i < seg.length) {
|
|
659
|
+
const c = seg[i];
|
|
660
|
+
if (quote) {
|
|
661
|
+
if (c === "\\" && quote === '"') {
|
|
662
|
+
i += 2;
|
|
663
|
+
continue;
|
|
664
|
+
}
|
|
665
|
+
if (c === quote)
|
|
666
|
+
quote = "";
|
|
667
|
+
i++;
|
|
668
|
+
continue;
|
|
669
|
+
}
|
|
670
|
+
if (c === "'" || c === '"' || c === "`") {
|
|
671
|
+
quote = c;
|
|
672
|
+
i++;
|
|
673
|
+
continue;
|
|
674
|
+
}
|
|
675
|
+
if (/\s/.test(c))
|
|
676
|
+
break;
|
|
677
|
+
i++;
|
|
678
|
+
}
|
|
679
|
+
out.push({ start, end: i, text: seg.slice(start, i) });
|
|
680
|
+
}
|
|
681
|
+
return out;
|
|
682
|
+
}
|
|
683
|
+
function unquoteToken(t) {
|
|
684
|
+
if (t.length >= 2 && ((t.startsWith('"') && t.endsWith('"')) || (t.startsWith("'") && t.endsWith("'"))))
|
|
685
|
+
return t.slice(1, -1);
|
|
686
|
+
return t;
|
|
687
|
+
}
|
|
688
|
+
/**
|
|
689
|
+
* True when an inline program handed to an interpreter (-e, -c)
|
|
690
|
+
* itself executes what arrives on stdin: an exec or eval construct
|
|
691
|
+
* combined with a stdin reference. A constant program that only
|
|
692
|
+
* READS stdin as data (the MC-6 shape: JSON.parse of the piped
|
|
693
|
+
* response) does not execute it.
|
|
694
|
+
*/
|
|
695
|
+
function programExecutesStdin(program) {
|
|
696
|
+
const execy = /\bexec\w*\s*\(|\beval\s*\(|\bFunction\s*\(|runInThisContext|runInNewContext|\bVM\b.*\brun\b/;
|
|
697
|
+
const stdiny = /stdin|\/dev\/stdin|<&0|os\.read\(\s*0|read\(\s*0\s*[,)]/;
|
|
698
|
+
return execy.test(program) && stdiny.test(program);
|
|
699
|
+
}
|
|
700
|
+
/**
|
|
701
|
+
* Pipe target analysis (P-109d, completing the MC-6 calibration).
|
|
702
|
+
* A pipe into an interpreter is download-then-execute only when the
|
|
703
|
+
* interpreter reads its PROGRAM from stdin: a bare interpreter, a
|
|
704
|
+
* lone dash, or an interpreter carrying only mode flags (-s, -x,
|
|
705
|
+
* -u and friends). When the invocation supplies its program
|
|
706
|
+
* another way, stdin is data the program parses, not code it runs:
|
|
707
|
+
* - a -c or -e inline program (unless that program itself
|
|
708
|
+
* executes stdin, which programExecutesStdin catches);
|
|
709
|
+
* - a -m module (python3 -m json.tool is the canonical data
|
|
710
|
+
* parser shape, the jq role with a different binary);
|
|
711
|
+
* - a script file operand (node script.js, bash setup.sh).
|
|
712
|
+
* The OpenHands mock LLM health checks pipe a loopback curl
|
|
713
|
+
* response into python3 -m json.tool; on 0.1.11 that match alone
|
|
714
|
+
* carried CURL-PIPE-SH and the top verdict.
|
|
715
|
+
*/
|
|
716
|
+
function pipeTargetExecutesStdin(interp, tail) {
|
|
717
|
+
// The interpreter word can sit inside a quoted command string
|
|
718
|
+
// handed to an interpreter (execSync('curl ... | sh')): the tail
|
|
719
|
+
// then opens with the string's closing quote and call punctuation,
|
|
720
|
+
// which is the end of the target invocation, not an operand.
|
|
721
|
+
const tt = tail.trimStart();
|
|
722
|
+
if (tt === "" || /^['"`)\];,}]/.test(tt))
|
|
723
|
+
return true;
|
|
724
|
+
const toks = shellTokenSpans(tt);
|
|
725
|
+
if (toks.length === 0)
|
|
726
|
+
return true;
|
|
727
|
+
const shell = interp === "sh" || interp === "bash" || interp === "zsh";
|
|
728
|
+
for (let i = 0; i < toks.length; i++) {
|
|
729
|
+
const t = unquoteToken(toks[i].text);
|
|
730
|
+
if (t === "-")
|
|
731
|
+
return true; // a lone dash: the program comes from stdin
|
|
732
|
+
if (t === "--")
|
|
733
|
+
return true; // only positional arguments follow; the program is still stdin
|
|
734
|
+
if (t.startsWith("-") && t.length > 1) {
|
|
735
|
+
const cluster = t.replace(/^-+/, "");
|
|
736
|
+
const name = cluster.split("=")[0];
|
|
737
|
+
const attached = cluster.includes("=") ? cluster.slice(cluster.indexOf("=") + 1) : cluster.length > name.length ? cluster.slice(name.length) : "";
|
|
738
|
+
const programOf = () => {
|
|
739
|
+
if (attached)
|
|
740
|
+
return attached;
|
|
741
|
+
const next = toks[i + 1];
|
|
742
|
+
return next ? unquoteToken(next.text) : "";
|
|
743
|
+
};
|
|
744
|
+
if (shell) {
|
|
745
|
+
// sh and bash take their program from -c; -o takes a value.
|
|
746
|
+
if (/(^|[^a-zA-Z])c$/.test(name) || name === "c")
|
|
747
|
+
return programExecutesStdin(programOf());
|
|
748
|
+
if (name.endsWith("o")) {
|
|
749
|
+
i++;
|
|
750
|
+
continue;
|
|
751
|
+
}
|
|
752
|
+
continue;
|
|
753
|
+
}
|
|
754
|
+
if (interp === "node" || interp === "bun") {
|
|
755
|
+
if (name === "e" || name === "eval" || name === "p" || name === "print")
|
|
756
|
+
return programExecutesStdin(programOf());
|
|
757
|
+
continue;
|
|
758
|
+
}
|
|
759
|
+
if (interp === "python" || interp === "python3") {
|
|
760
|
+
if (name === "c")
|
|
761
|
+
return programExecutesStdin(programOf());
|
|
762
|
+
if (name === "m")
|
|
763
|
+
return false; // a module is the program; stdin is its data
|
|
764
|
+
if (name === "W" || name === "X") {
|
|
765
|
+
i++;
|
|
766
|
+
continue;
|
|
767
|
+
}
|
|
768
|
+
continue;
|
|
769
|
+
}
|
|
770
|
+
// perl and ruby take their program from -e.
|
|
771
|
+
if (name === "e")
|
|
772
|
+
return programExecutesStdin(programOf());
|
|
773
|
+
continue;
|
|
774
|
+
}
|
|
775
|
+
// A bare operand is a script file: it is the program, stdin is data.
|
|
776
|
+
return false;
|
|
777
|
+
}
|
|
778
|
+
return true;
|
|
779
|
+
}
|
|
557
780
|
/**
|
|
558
781
|
* Content ranges of echo and printf string arguments in shell text:
|
|
559
782
|
* output data, blanked in the code view, unless the pipeline ends in a
|
|
@@ -607,6 +830,122 @@ function shellEchoDataRanges(text) {
|
|
|
607
830
|
}
|
|
608
831
|
return ranges;
|
|
609
832
|
}
|
|
833
|
+
const SEARCH_HEADS = new Set(["grep", "egrep", "fgrep", "rg", "ag", "ack"]);
|
|
834
|
+
/** Short search flags whose next token is a value that is not the pattern (counts, context widths). */
|
|
835
|
+
const SEARCH_VALUE_FLAGS = new Set(["m", "A", "B", "C", "d", "D"]);
|
|
836
|
+
/**
|
|
837
|
+
* Search pattern and search input ranges in shell text (P-108).
|
|
838
|
+
* A search command (grep, egrep, fgrep, rg, ag, ack in command
|
|
839
|
+
* position, the P-062 discipline) reads its pattern as DATA to
|
|
840
|
+
* match against, and its file operands as data to search: neither
|
|
841
|
+
* is behavior. github/docs lints its lockfile by grepping .npmrc
|
|
842
|
+
* and package-lock.json for Microsoft feed URL patterns so it can
|
|
843
|
+
* BLOCK them; on 0.1.11 the .npmrc operand fired CRED-ACCESS and a
|
|
844
|
+
* URL inside a pattern could fire REMOTE-URL, and the pair fed
|
|
845
|
+
* verdict path (b) to COMPROMISED-PATTERN. Content inside a search
|
|
846
|
+
* pattern is pattern text, not behavior, for both halves of a
|
|
847
|
+
* strong evidence combination, and a credential path string used
|
|
848
|
+
* as a search target is a reference, not credential access.
|
|
849
|
+
*
|
|
850
|
+
* Ranges are per pipeline segment: the pattern operand (the first
|
|
851
|
+
* bare token, or the value of -e, -f, --regexp, --file, or
|
|
852
|
+
* --pattern) and the file operands after it. A token containing a
|
|
853
|
+
* command substitution is left visible (its content can execute),
|
|
854
|
+
* parsing stops at a redirection (its target is a write, judged on
|
|
855
|
+
* its own text), and other statements in the same step are
|
|
856
|
+
* untouched: a grep followed by a cat of a credential file, or by
|
|
857
|
+
* a fetch of the grepped URL, still fires on those statements.
|
|
858
|
+
*/
|
|
859
|
+
function searchRanges(text) {
|
|
860
|
+
const pattern = [];
|
|
861
|
+
const operands = [];
|
|
862
|
+
for (const stmt of splitShell(text, ["\n", ";", "&&", "||"])) {
|
|
863
|
+
const stmtText = text.slice(stmt.start, stmt.end);
|
|
864
|
+
for (const seg of splitShell(stmtText, ["|"])) {
|
|
865
|
+
const segText = stmtText.slice(seg.start, seg.end);
|
|
866
|
+
const head = segmentHead(segText);
|
|
867
|
+
if (!head || !SEARCH_HEADS.has(head.word))
|
|
868
|
+
continue;
|
|
869
|
+
const afterHead = head.idx + head.word.length;
|
|
870
|
+
const base = stmt.start + seg.start + afterHead;
|
|
871
|
+
const toks = shellTokenSpans(segText.slice(afterHead));
|
|
872
|
+
let patternSeen = false;
|
|
873
|
+
let expect = null;
|
|
874
|
+
let afterDashDash = false;
|
|
875
|
+
for (const tok of toks) {
|
|
876
|
+
const g = { start: base + tok.start, end: base + tok.end };
|
|
877
|
+
const dynamic = tok.text.includes("$(") || tok.text.includes("`");
|
|
878
|
+
const t = unquoteToken(tok.text);
|
|
879
|
+
if (expect === "pattern") {
|
|
880
|
+
if (!dynamic)
|
|
881
|
+
pattern.push(g);
|
|
882
|
+
patternSeen = true;
|
|
883
|
+
expect = null;
|
|
884
|
+
continue;
|
|
885
|
+
}
|
|
886
|
+
if (expect === "other") {
|
|
887
|
+
expect = null;
|
|
888
|
+
continue;
|
|
889
|
+
}
|
|
890
|
+
if (!afterDashDash && t === "--") {
|
|
891
|
+
afterDashDash = true;
|
|
892
|
+
continue;
|
|
893
|
+
}
|
|
894
|
+
if (!afterDashDash && /^(?:\d+)?[<>]/.test(t))
|
|
895
|
+
break;
|
|
896
|
+
if (!afterDashDash && t.startsWith("--")) {
|
|
897
|
+
const eq = t.indexOf("=");
|
|
898
|
+
const name = eq === -1 ? t : t.slice(0, eq);
|
|
899
|
+
if (name === "--regexp" || name === "--file" || name === "--pattern") {
|
|
900
|
+
if (eq !== -1) {
|
|
901
|
+
if (!dynamic)
|
|
902
|
+
pattern.push(g);
|
|
903
|
+
patternSeen = true;
|
|
904
|
+
}
|
|
905
|
+
else
|
|
906
|
+
expect = "pattern";
|
|
907
|
+
}
|
|
908
|
+
else if (["--max-count", "--after-context", "--before-context", "--context", "--directory", "--devices", "--exclude", "--include", "--type", "--glob", "--max-filesize"].includes(name)) {
|
|
909
|
+
if (eq === -1)
|
|
910
|
+
expect = "other";
|
|
911
|
+
}
|
|
912
|
+
continue;
|
|
913
|
+
}
|
|
914
|
+
if (!afterDashDash && t.startsWith("-") && t.length > 1) {
|
|
915
|
+
const cluster = t.slice(1);
|
|
916
|
+
const ei = cluster.search(/[ef]/);
|
|
917
|
+
if (ei !== -1 && !patternSeen) {
|
|
918
|
+
if (ei < cluster.length - 1) {
|
|
919
|
+
if (!dynamic)
|
|
920
|
+
pattern.push(g);
|
|
921
|
+
patternSeen = true;
|
|
922
|
+
}
|
|
923
|
+
else
|
|
924
|
+
expect = "pattern";
|
|
925
|
+
continue;
|
|
926
|
+
}
|
|
927
|
+
if (SEARCH_VALUE_FLAGS.has(cluster[cluster.length - 1])) {
|
|
928
|
+
expect = "other";
|
|
929
|
+
continue;
|
|
930
|
+
}
|
|
931
|
+
continue;
|
|
932
|
+
}
|
|
933
|
+
if (dynamic) {
|
|
934
|
+
if (!patternSeen)
|
|
935
|
+
patternSeen = true;
|
|
936
|
+
continue;
|
|
937
|
+
}
|
|
938
|
+
if (!patternSeen) {
|
|
939
|
+
pattern.push(g);
|
|
940
|
+
patternSeen = true;
|
|
941
|
+
}
|
|
942
|
+
else
|
|
943
|
+
operands.push(g);
|
|
944
|
+
}
|
|
945
|
+
}
|
|
946
|
+
}
|
|
947
|
+
return { pattern, operands };
|
|
948
|
+
}
|
|
610
949
|
const NETWORK_CALL_RES = [
|
|
611
950
|
/\bfetch\s*\(/g,
|
|
612
951
|
/\baxios(?:\s*\.\s*\w+)?\s*\(/g,
|
|
@@ -619,8 +958,19 @@ const NETWORK_CALL_RES = [
|
|
|
619
958
|
];
|
|
620
959
|
function buildViews(text, lang) {
|
|
621
960
|
if (lang === "shell") {
|
|
622
|
-
const
|
|
623
|
-
|
|
961
|
+
const echoRanges = shellEchoDataRanges(text);
|
|
962
|
+
// Search text is data (P-108): pattern contents are blanked in
|
|
963
|
+
// the behavior and URL views, and pattern plus search input
|
|
964
|
+
// operands are blanked in the credential view, so a defensive
|
|
965
|
+
// grep supplies neither half of a strong evidence combination.
|
|
966
|
+
const search = searchRanges(text);
|
|
967
|
+
const dataRanges = [...echoRanges, ...search.pattern];
|
|
968
|
+
return {
|
|
969
|
+
codeText: blankRanges(text, dataRanges),
|
|
970
|
+
credText: blankRanges(text, [...search.pattern, ...search.operands]),
|
|
971
|
+
dataRanges,
|
|
972
|
+
urlText: blankRanges(text, search.pattern),
|
|
973
|
+
};
|
|
624
974
|
}
|
|
625
975
|
const literals = lang === "python" ? scanPyLiterals(text) : scanJsLiterals(text);
|
|
626
976
|
const execSpans = callFirstArgSpans(text, EXEC_CALLS, literals);
|
|
@@ -1118,7 +1468,7 @@ function consumeValueExpr(text, start) {
|
|
|
1118
1468
|
}
|
|
1119
1469
|
return i;
|
|
1120
1470
|
}
|
|
1121
|
-
const AUTH_HEADER_KEY = /^(authorization|proxy-authorization|api[-_]?key|apikey|x-api-key|x-auth[-_]?token|auth[-_]?token|x-access[-_]?token|access[-_]?token|x-session[-_]?token|session[-_]?token)$/i;
|
|
1471
|
+
const AUTH_HEADER_KEY = /^(authorization|proxy-authorization|auth|api[-_]?key|apikey|x-api-key|x-auth[-_]?token|auth[-_]?token|x-access[-_]?token|access[-_]?token|x-session[-_]?token|session[-_]?token)$/i;
|
|
1122
1472
|
function isAuthHeaderKey(key) {
|
|
1123
1473
|
const k = key.toLowerCase();
|
|
1124
1474
|
return AUTH_HEADER_KEY.test(k) || k.endsWith("-api-key") || k.endsWith("-apikey") || k.endsWith("-auth-token") || k.endsWith("-access-token");
|
|
@@ -1150,9 +1500,19 @@ function isAuthHeaderKey(key) {
|
|
|
1150
1500
|
* or request options object;
|
|
1151
1501
|
* - header setter calls (set, append, setHeader) and Python
|
|
1152
1502
|
* requests-style auth= tuples.
|
|
1153
|
-
* A credential that reaches a payload position (body,
|
|
1154
|
-
*
|
|
1155
|
-
*
|
|
1503
|
+
* A credential that reaches a payload position (body, a data named
|
|
1504
|
+
* query parameter, a partial URL path segment, printed output next
|
|
1505
|
+
* to a send) still fires: only the value expressions of these
|
|
1506
|
+
* positions are scrubbed.
|
|
1507
|
+
*
|
|
1508
|
+
* Mega-corpus (P-106, P-107): the scrub now also covers curl -u and
|
|
1509
|
+
* --user basic auth pairs, the argument following a git
|
|
1510
|
+
* http.extraheader key, and ::add-mask:: registrations; and
|
|
1511
|
+
* destination positions inside the URL of a send (entire path
|
|
1512
|
+
* segments, the whole URL, the URL origin, authentication named
|
|
1513
|
+
* query parameters, the curl URL operand, and the destination first
|
|
1514
|
+
* argument of a fetch style call) are computed separately in
|
|
1515
|
+
* destAuthRanges, always subject to the body test.
|
|
1156
1516
|
*/
|
|
1157
1517
|
function authRanges(text) {
|
|
1158
1518
|
const ranges = [];
|
|
@@ -1191,6 +1551,68 @@ function authRanges(text) {
|
|
|
1191
1551
|
ranges.push(valueAfter(i));
|
|
1192
1552
|
}
|
|
1193
1553
|
}
|
|
1554
|
+
// curl -u and --user basic authentication pairs (P-107): the flag
|
|
1555
|
+
// value (user:password, usually a variable pair) is the credential
|
|
1556
|
+
// presented to the destination as authentication, in exactly the
|
|
1557
|
+
// position the protocol reserves for it. nodejs/node uploads its
|
|
1558
|
+
// own WPT report with -u "$USER:$PASSWORD" and n8n posts to its own
|
|
1559
|
+
// internal webhook with -u "name:$WEBHOOK_PASSWORD"; both fired
|
|
1560
|
+
// ENV-EXFIL on 0.1.10 because the scrubber knew --token style flags
|
|
1561
|
+
// but not the basic auth pair.
|
|
1562
|
+
const flagValue = (from) => {
|
|
1563
|
+
let i = from;
|
|
1564
|
+
while (i < text.length && /\s/.test(text[i]))
|
|
1565
|
+
i++;
|
|
1566
|
+
if (i >= text.length)
|
|
1567
|
+
return;
|
|
1568
|
+
if (text[i] === "'" || text[i] === '"' || text[i] === "`") {
|
|
1569
|
+
const end = consumeLiteral(text, i);
|
|
1570
|
+
ranges.push({ start: i + 1, end: Math.max(i + 1, end - 1) });
|
|
1571
|
+
}
|
|
1572
|
+
else {
|
|
1573
|
+
ranges.push(valueAfter(i));
|
|
1574
|
+
}
|
|
1575
|
+
};
|
|
1576
|
+
for (const m of text.matchAll(/(?:^|[\s;|&])(?:-u|--user|--http-user|--http-password)(?:\s+|=)/g)) {
|
|
1577
|
+
flagValue(m.index + m[0].length);
|
|
1578
|
+
}
|
|
1579
|
+
// git http.extraheader indirection (P-107): the value configured
|
|
1580
|
+
// for an extraheader key IS an Authorization header value, however
|
|
1581
|
+
// it was built (microsoft/TypeScript base64-encodes its app token
|
|
1582
|
+
// into a variable, registers it with add-mask, and configures it as
|
|
1583
|
+
// http.https://github.com/.extraheader "AUTHORIZATION: basic ...").
|
|
1584
|
+
// The argument following the extraheader key is scrubbed.
|
|
1585
|
+
for (const m of text.matchAll(/extraheader\b/gi)) {
|
|
1586
|
+
let i = m.index + m[0].length;
|
|
1587
|
+
while (i < text.length && /[\s"']/.test(text[i]))
|
|
1588
|
+
i++;
|
|
1589
|
+
if (text[i] === "=" || text[i] === ":") {
|
|
1590
|
+
i++;
|
|
1591
|
+
while (i < text.length && /[\s"']/.test(text[i]))
|
|
1592
|
+
i++;
|
|
1593
|
+
}
|
|
1594
|
+
if (i >= text.length)
|
|
1595
|
+
continue;
|
|
1596
|
+
if (text[i] === "'" || text[i] === '"' || text[i] === "`") {
|
|
1597
|
+
const end = consumeLiteral(text, i);
|
|
1598
|
+
ranges.push({ start: i + 1, end: Math.max(i + 1, end - 1) });
|
|
1599
|
+
}
|
|
1600
|
+
else {
|
|
1601
|
+
ranges.push(valueAfter(i));
|
|
1602
|
+
}
|
|
1603
|
+
}
|
|
1604
|
+
// add-mask registrations: an echo whose argument is the workflow
|
|
1605
|
+
// command ::add-mask::<value> registers the value for masking in
|
|
1606
|
+
// the log; printing it there is the masking idiom itself, not a
|
|
1607
|
+
// disclosure of the value as data.
|
|
1608
|
+
for (const m of text.matchAll(/::add-mask::/g)) {
|
|
1609
|
+
const start = m.index + m[0].length;
|
|
1610
|
+
let end = start;
|
|
1611
|
+
while (end < text.length && text[end] !== "\n" && text[end] !== '"' && text[end] !== "'")
|
|
1612
|
+
end++;
|
|
1613
|
+
if (end > start)
|
|
1614
|
+
ranges.push({ start, end });
|
|
1615
|
+
}
|
|
1194
1616
|
// Header setter calls and requests-style auth= tuples.
|
|
1195
1617
|
for (const m of text.matchAll(/\b(?:set|append|setHeader|addHeader|header)\s*\(\s*["'][A-Za-z0-9-]+["']\s*,\s*/g)) {
|
|
1196
1618
|
const end = consumeValueExpr(text, m.index + m[0].length);
|
|
@@ -1230,17 +1652,111 @@ function authRanges(text) {
|
|
|
1230
1652
|
return ranges;
|
|
1231
1653
|
}
|
|
1232
1654
|
/**
|
|
1233
|
-
*
|
|
1234
|
-
*
|
|
1235
|
-
*
|
|
1236
|
-
*
|
|
1237
|
-
|
|
1238
|
-
|
|
1239
|
-
|
|
1240
|
-
|
|
1241
|
-
|
|
1242
|
-
|
|
1655
|
+
* Functions defined in the same text (P-110). Python `def` bodies are
|
|
1656
|
+
* line ranges bounded by indentation; JavaScript `function` bodies
|
|
1657
|
+
* and arrow function bodies are the balanced brace span after the
|
|
1658
|
+
* signature. Only plain (non-method) definitions are collected.
|
|
1659
|
+
*/
|
|
1660
|
+
function localFunctions(text) {
|
|
1661
|
+
const fns = [];
|
|
1662
|
+
const lines = text.split("\n");
|
|
1663
|
+
const lineStart = [];
|
|
1664
|
+
let off = 0;
|
|
1665
|
+
for (const l of lines) {
|
|
1666
|
+
lineStart.push(off);
|
|
1667
|
+
off += l.length + 1;
|
|
1668
|
+
}
|
|
1669
|
+
for (let li = 0; li < lines.length; li++) {
|
|
1670
|
+
const dm = lines[li].match(/^(\s*)def\s+([A-Za-z_][A-Za-z0-9_]*)\s*\(/);
|
|
1671
|
+
if (!dm)
|
|
1672
|
+
continue;
|
|
1673
|
+
const indent = dm[1].length;
|
|
1674
|
+
let endLine = lines.length - 1;
|
|
1675
|
+
for (let lj = li + 1; lj < lines.length; lj++) {
|
|
1676
|
+
const l = lines[lj];
|
|
1677
|
+
if (l.trim() === "")
|
|
1678
|
+
continue;
|
|
1679
|
+
const ind = l.match(/^\s*/)?.[0].length ?? 0;
|
|
1680
|
+
if (ind <= indent) {
|
|
1681
|
+
endLine = lj - 1;
|
|
1682
|
+
break;
|
|
1683
|
+
}
|
|
1684
|
+
}
|
|
1685
|
+
fns.push({ name: dm[2], bodyStart: lineStart[li], bodyEnd: lineStart[endLine] + lines[endLine].length });
|
|
1686
|
+
}
|
|
1687
|
+
const jsRe = /\bfunction\s+([A-Za-z_$][A-Za-z0-9_$]*)\s*\(|(?:^|[;\n])\s*(?:const|let|var)\s+([A-Za-z_$][A-Za-z0-9_$]*)\s*=\s*(?:async\s*)?(?:\([^)]*\)|[A-Za-z_$][A-Za-z0-9_$]*)\s*=>/g;
|
|
1688
|
+
let jm;
|
|
1689
|
+
while ((jm = jsRe.exec(text)) !== null) {
|
|
1690
|
+
const name = jm[1] ?? jm[2];
|
|
1691
|
+
if (!name)
|
|
1692
|
+
continue;
|
|
1693
|
+
const brace = text.indexOf("{", jm.index + jm[0].length);
|
|
1694
|
+
if (brace === -1)
|
|
1695
|
+
continue;
|
|
1696
|
+
fns.push({ name, bodyStart: jm.index, bodyEnd: consumeBalanced(text, brace) });
|
|
1697
|
+
}
|
|
1698
|
+
return fns;
|
|
1699
|
+
}
|
|
1700
|
+
/**
|
|
1701
|
+
* True when a locally defined function returns tainted data: a return
|
|
1702
|
+
* expression inside its body that references a credential atom or a
|
|
1703
|
+
* tainted variable at a genuine reference position (outside data and
|
|
1704
|
+
* authentication ranges), or returns the result of another local
|
|
1705
|
+
* function that does (one level of recursion, depth capped).
|
|
1243
1706
|
*/
|
|
1707
|
+
function fnReturnsTaint(fn, text, live, tainted, dataRanges, auth, fns, depth) {
|
|
1708
|
+
const body = text.slice(fn.bodyStart, fn.bodyEnd);
|
|
1709
|
+
for (const m of body.matchAll(/\breturn\b/g)) {
|
|
1710
|
+
const exprStart = fn.bodyStart + (m.index ?? 0) + m[0].length;
|
|
1711
|
+
let exprEnd = text.indexOf("\n", exprStart);
|
|
1712
|
+
if (exprEnd === -1)
|
|
1713
|
+
exprEnd = fn.bodyEnd;
|
|
1714
|
+
const semi = text.indexOf(";", exprStart);
|
|
1715
|
+
if (semi !== -1 && semi < exprEnd)
|
|
1716
|
+
exprEnd = semi;
|
|
1717
|
+
exprEnd = Math.min(exprEnd, fn.bodyEnd);
|
|
1718
|
+
const expr = text.slice(exprStart, exprEnd);
|
|
1719
|
+
for (const x of live) {
|
|
1720
|
+
if (x.start >= exprStart && x.start < exprEnd)
|
|
1721
|
+
return true;
|
|
1722
|
+
}
|
|
1723
|
+
for (const [v] of tainted) {
|
|
1724
|
+
if (refOutsideRanges(expr, v, exprStart, [...dataRanges, ...auth]))
|
|
1725
|
+
return true;
|
|
1726
|
+
}
|
|
1727
|
+
if (depth < 2) {
|
|
1728
|
+
for (const cm of expr.matchAll(/\b([A-Za-z_$][A-Za-z0-9_$]*)\s*\(/g)) {
|
|
1729
|
+
const inner = fns.find((f) => f.name === cm[1] && f !== fn);
|
|
1730
|
+
if (inner && fnReturnsTaint(inner, text, live, tainted, dataRanges, auth, fns, depth + 1))
|
|
1731
|
+
return true;
|
|
1732
|
+
}
|
|
1733
|
+
}
|
|
1734
|
+
}
|
|
1735
|
+
return false;
|
|
1736
|
+
}
|
|
1737
|
+
/**
|
|
1738
|
+
* The innermost plain call whose argument list contains `pos`
|
|
1739
|
+
* within the assignment right-hand side, when that call is to a
|
|
1740
|
+
* function defined in the same text. Returns the function, or null
|
|
1741
|
+
* when the position is not inside a local call's arguments.
|
|
1742
|
+
*/
|
|
1743
|
+
function localCallAt(text, rhsStart, rhsEnd, pos, fns) {
|
|
1744
|
+
const rhs = text.slice(rhsStart, rhsEnd);
|
|
1745
|
+
let best = null;
|
|
1746
|
+
for (const m of rhs.matchAll(/\b([A-Za-z_$][A-Za-z0-9_$]*)\s*\(/g)) {
|
|
1747
|
+
const nameStart = rhsStart + (m.index ?? 0);
|
|
1748
|
+
if (nameStart > 0 && text[nameStart - 1] === ".")
|
|
1749
|
+
continue;
|
|
1750
|
+
const fn = fns.find((f) => f.name === m[1]);
|
|
1751
|
+
if (!fn)
|
|
1752
|
+
continue;
|
|
1753
|
+
const open = nameStart + m[0].length - 1;
|
|
1754
|
+
const span = callSpan(text, open);
|
|
1755
|
+
if (pos > open && pos < span.end && (!best || open > best.open))
|
|
1756
|
+
best = { fn, open };
|
|
1757
|
+
}
|
|
1758
|
+
return best?.fn ?? null;
|
|
1759
|
+
}
|
|
1244
1760
|
function taintedVars(text, atoms, dataRanges, auth = []) {
|
|
1245
1761
|
const tainted = new Map();
|
|
1246
1762
|
const assignRe = /(?:^|[;\n,])\s*(?:export\s+|const\s+|let\s+|var\s+)?([A-Za-z_$][A-Za-z0-9_$]*)\s*=(?![=<>])\s*/g;
|
|
@@ -1254,18 +1770,59 @@ function taintedVars(text, atoms, dataRanges, auth = []) {
|
|
|
1254
1770
|
assigns.push({ name: m[1], rhsStart, rhsEnd });
|
|
1255
1771
|
}
|
|
1256
1772
|
const live = atoms.filter((a) => !a.inData);
|
|
1773
|
+
const fns = localFunctions(text);
|
|
1774
|
+
// Local helper taint (P-110). Passing a credential as an ARGUMENT
|
|
1775
|
+
// to a function defined in the same text does not taint that call's
|
|
1776
|
+
// result, unless the helper itself returns tainted data: the
|
|
1777
|
+
// helper's own body is part of the analyzed text, so a credential
|
|
1778
|
+
// it posts from inside is caught there directly, and a credential
|
|
1779
|
+
// it only presents as authentication (the davila7 and ticker
|
|
1780
|
+
// shapes: fetch_stargazers(token) and fetch_repos(token), whose
|
|
1781
|
+
// results are star dates and repo metadata) never becomes the
|
|
1782
|
+
// result's value. Calls to functions NOT defined in the text
|
|
1783
|
+
// (JSON.stringify, encodeURIComponent, library helpers) keep the
|
|
1784
|
+
// conservative rule: their result carries their arguments' taint.
|
|
1785
|
+
const consumedByLocalCall = (a, pos) => {
|
|
1786
|
+
if (fns.length === 0)
|
|
1787
|
+
return false;
|
|
1788
|
+
const fn = localCallAt(text, a.rhsStart, a.rhsEnd, pos, fns);
|
|
1789
|
+
if (!fn)
|
|
1790
|
+
return false;
|
|
1791
|
+
return !fnReturnsTaint(fn, text, live, tainted, dataRanges, auth, fns, 0);
|
|
1792
|
+
};
|
|
1257
1793
|
for (let pass = 0; pass < 4; pass++) {
|
|
1258
1794
|
for (const a of assigns) {
|
|
1259
1795
|
if (tainted.has(a.name))
|
|
1260
1796
|
continue;
|
|
1261
1797
|
const sources = [];
|
|
1798
|
+
// Destination positions inside the right-hand side do not
|
|
1799
|
+
// taint the RESULT of a call or command substitution either
|
|
1800
|
+
// (P-106): a variable assigned a send's result (a status code,
|
|
1801
|
+
// a response) does not carry the credential that was presented
|
|
1802
|
+
// in the send's URL. The skip applies only inside a call or
|
|
1803
|
+
// substitution span: a plain string or expression that
|
|
1804
|
+
// CONTAINS a destination atom (a capability URL built into a
|
|
1805
|
+
// variable) keeps its taint, because printing or posting that
|
|
1806
|
+
// variable would disclose the credential.
|
|
1807
|
+
const dest = destAuthRanges(text, { start: a.rhsStart, end: a.rhsEnd }, true)
|
|
1808
|
+
.filter((r) => {
|
|
1809
|
+
const rhs = text.slice(a.rhsStart, a.rhsEnd);
|
|
1810
|
+
for (const cm of rhs.matchAll(/\$\(|[A-Za-z_$][A-Za-z0-9_$]*\s*\(/g)) {
|
|
1811
|
+
const open = a.rhsStart + (cm.index ?? 0) + cm[0].length - 1;
|
|
1812
|
+
const span = callSpan(text, open);
|
|
1813
|
+
if (r.start >= span.start && r.start < span.end)
|
|
1814
|
+
return true;
|
|
1815
|
+
}
|
|
1816
|
+
return false;
|
|
1817
|
+
});
|
|
1262
1818
|
for (const x of live) {
|
|
1263
|
-
if (x.start >= a.rhsStart && x.start < a.rhsEnd)
|
|
1819
|
+
if (x.start >= a.rhsStart && x.start < a.rhsEnd && !consumedByLocalCall(a, x.start) && !inRanges(x.start, dest))
|
|
1264
1820
|
sources.push(x.pure === true);
|
|
1265
1821
|
}
|
|
1266
1822
|
const rhs = text.slice(a.rhsStart, a.rhsEnd);
|
|
1267
1823
|
for (const [v, vPure] of tainted) {
|
|
1268
|
-
|
|
1824
|
+
const positions = refPositions(rhs, v, a.rhsStart, [...dataRanges, ...auth]);
|
|
1825
|
+
if (positions.some((p) => !consumedByLocalCall(a, p) && !inRanges(p, dest)))
|
|
1269
1826
|
sources.push(vPure);
|
|
1270
1827
|
}
|
|
1271
1828
|
if (sources.length > 0)
|
|
@@ -1309,11 +1866,11 @@ function callSpan(text, openIdx) {
|
|
|
1309
1866
|
* A span carrying even one impure hit (a credential shaped read, a
|
|
1310
1867
|
* serialization of the whole environment) is an impure flow.
|
|
1311
1868
|
*/
|
|
1312
|
-
function spanHasTaint(text, span, atoms, tainted, dataRanges, auth = []) {
|
|
1869
|
+
function spanHasTaint(text, span, atoms, tainted, dataRanges, auth = [], extra = []) {
|
|
1313
1870
|
let found = false;
|
|
1314
1871
|
let pure = true;
|
|
1315
1872
|
for (const a of atoms) {
|
|
1316
|
-
if (!a.inData && a.start >= span.start && a.start < span.end) {
|
|
1873
|
+
if (!a.inData && a.start >= span.start && a.start < span.end && !inRanges(a.start, extra)) {
|
|
1317
1874
|
found = true;
|
|
1318
1875
|
if (a.pure !== true)
|
|
1319
1876
|
pure = false;
|
|
@@ -1322,7 +1879,7 @@ function spanHasTaint(text, span, atoms, tainted, dataRanges, auth = []) {
|
|
|
1322
1879
|
if (tainted.size > 0) {
|
|
1323
1880
|
const seg = text.slice(span.start, span.end);
|
|
1324
1881
|
for (const [v, vPure] of tainted) {
|
|
1325
|
-
if (refOutsideRanges(seg, v, span.start, [...dataRanges, ...auth])) {
|
|
1882
|
+
if (refOutsideRanges(seg, v, span.start, [...dataRanges, ...auth, ...extra])) {
|
|
1326
1883
|
found = true;
|
|
1327
1884
|
if (!vPure)
|
|
1328
1885
|
pure = false;
|
|
@@ -1331,6 +1888,222 @@ function spanHasTaint(text, span, atoms, tainted, dataRanges, auth = []) {
|
|
|
1331
1888
|
}
|
|
1332
1889
|
return { found, pure };
|
|
1333
1890
|
}
|
|
1891
|
+
/**
|
|
1892
|
+
* Body ranges inside one send span or statement: the values handed
|
|
1893
|
+
* to curl data flags (-d, --data*, -F, --form, --json) and to body,
|
|
1894
|
+
* data, json, or payload properties and keyword arguments. A
|
|
1895
|
+
* credential inside a body range is payload by construction; the
|
|
1896
|
+
* destination rules below never scrub inside a body range, which is
|
|
1897
|
+
* what makes the body test decisive (P-106): a capability URL shape
|
|
1898
|
+
* PLUS credential data in the body still fires.
|
|
1899
|
+
*/
|
|
1900
|
+
function bodyRanges(text, span) {
|
|
1901
|
+
const ranges = [];
|
|
1902
|
+
const seg = text.slice(span.start, span.end);
|
|
1903
|
+
for (const m of seg.matchAll(/(?:^|\s)(?:-d|--data(?:-raw|-binary|-urlencode|-ascii)?|--form(?:-string)?|-F|--json)(?:\s+|=)/g)) {
|
|
1904
|
+
let i = span.start + (m.index ?? 0) + m[0].length;
|
|
1905
|
+
while (i < span.end && /\s/.test(text[i]))
|
|
1906
|
+
i++;
|
|
1907
|
+
if (i >= span.end)
|
|
1908
|
+
continue;
|
|
1909
|
+
if (text[i] === "'" || text[i] === '"' || text[i] === "`") {
|
|
1910
|
+
ranges.push({ start: i, end: Math.min(span.end, consumeLiteral(text, i)) });
|
|
1911
|
+
}
|
|
1912
|
+
else {
|
|
1913
|
+
let end = i;
|
|
1914
|
+
while (end < span.end && !/[\s,;]/.test(text[end]))
|
|
1915
|
+
end++;
|
|
1916
|
+
ranges.push({ start: i, end });
|
|
1917
|
+
}
|
|
1918
|
+
}
|
|
1919
|
+
for (const m of seg.matchAll(/\b(?:body|data|json|payload)\s*[:=]\s*/g)) {
|
|
1920
|
+
const start = span.start + (m.index ?? 0) + m[0].length;
|
|
1921
|
+
const end = consumeValueExpr(text, start);
|
|
1922
|
+
if (end > start)
|
|
1923
|
+
ranges.push({ start, end: Math.min(end, span.end) });
|
|
1924
|
+
}
|
|
1925
|
+
return ranges;
|
|
1926
|
+
}
|
|
1927
|
+
const AUTH_PARAM_NAME = /^(?:.*[-_])?(?:token|api[-_]?key|access[-_]?token|auth|secret|signature|sig|key|session[-_]?id)$/i;
|
|
1928
|
+
/** True when a URL path segment is exactly one reference (a variable, an Actions expression, a template or f-string hole). */
|
|
1929
|
+
function segmentRefRange(seg, segGlobalStart) {
|
|
1930
|
+
const t = seg.replace(/^["'`(]+/, "").replace(/["'`),;]+$/, "");
|
|
1931
|
+
const lead = seg.length - seg.replace(/^["'`(]+/, "").length;
|
|
1932
|
+
const base = segGlobalStart + lead;
|
|
1933
|
+
if (/^\$\{\{[^}]*\}\}$/.test(t) || /^\$\{[^}]*\}$/.test(t) || /^\$[A-Za-z_][A-Za-z0-9_]*$/.test(t) || /^\{[A-Za-z_][A-Za-z0-9_.]*\}$/.test(t)) {
|
|
1934
|
+
return { start: base, end: base + t.length };
|
|
1935
|
+
}
|
|
1936
|
+
return null;
|
|
1937
|
+
}
|
|
1938
|
+
/**
|
|
1939
|
+
* Destination authentication ranges inside one send span or
|
|
1940
|
+
* statement (P-106, P-107). A credential PRESENTED in the URL of a
|
|
1941
|
+
* send authenticates that send to the destination: capability URLs
|
|
1942
|
+
* are credentials by design (a Netlify build hook URL, a preview
|
|
1943
|
+
* token as a path segment, a token query parameter), and the URL a
|
|
1944
|
+
* configuration expression expands to is the destination itself.
|
|
1945
|
+
* Recognized presentations:
|
|
1946
|
+
* - a reference that is an entire URL path segment;
|
|
1947
|
+
* - the value of an authentication named query parameter (token,
|
|
1948
|
+
* api_key, access_token, auth, secret, signature, key, and their
|
|
1949
|
+
* prefixed forms); a data named parameter (?t=, ?q=, ?payload=)
|
|
1950
|
+
* is NOT a presentation and keeps firing;
|
|
1951
|
+
* - a reference at the start of a URL token, in the origin position
|
|
1952
|
+
* (an expression or variable the rest of the URL is built on);
|
|
1953
|
+
* - the whole URL token when it is exactly one reference;
|
|
1954
|
+
* - in a curl or wget statement, a bare reference token in the URL
|
|
1955
|
+
* operand position (not a flag value): the Actions OIDC request
|
|
1956
|
+
* URL variable shape.
|
|
1957
|
+
* Nothing inside a body range is scrubbed here, and a reference in
|
|
1958
|
+
* any other URL position (a partial path segment, a data parameter)
|
|
1959
|
+
* is payload and keeps full exfil weight.
|
|
1960
|
+
*/
|
|
1961
|
+
function destAuthRanges(text, span, isStatement) {
|
|
1962
|
+
const ranges = [];
|
|
1963
|
+
const bodies = bodyRanges(text, span);
|
|
1964
|
+
const inBody = (r) => bodies.some((b) => r.start >= b.start && r.start < b.end);
|
|
1965
|
+
const seg = text.slice(span.start, span.end);
|
|
1966
|
+
const tokenRe = /[^\s]+/g;
|
|
1967
|
+
let tm;
|
|
1968
|
+
while ((tm = tokenRe.exec(seg)) !== null) {
|
|
1969
|
+
const tokStart = span.start + (tm.index ?? 0);
|
|
1970
|
+
const tok = tm[0];
|
|
1971
|
+
const tokEnd = tokStart + tok.length;
|
|
1972
|
+
if (inBody({ start: tokStart, end: tokEnd }))
|
|
1973
|
+
continue;
|
|
1974
|
+
const schemeAt = tok.indexOf("://");
|
|
1975
|
+
if (schemeAt !== -1) {
|
|
1976
|
+
// Authority ends at the first path, query, or fragment char.
|
|
1977
|
+
let authEnd = tok.length;
|
|
1978
|
+
for (let i = schemeAt + 3; i < tok.length; i++) {
|
|
1979
|
+
if (tok[i] === "/" || tok[i] === "?" || tok[i] === "#") {
|
|
1980
|
+
authEnd = i;
|
|
1981
|
+
break;
|
|
1982
|
+
}
|
|
1983
|
+
}
|
|
1984
|
+
// Path segments that are exactly one reference.
|
|
1985
|
+
let p = authEnd;
|
|
1986
|
+
while (p < tok.length && tok[p] === "/") {
|
|
1987
|
+
let q = p + 1;
|
|
1988
|
+
while (q < tok.length && tok[q] !== "/" && tok[q] !== "?" && tok[q] !== "#")
|
|
1989
|
+
q++;
|
|
1990
|
+
const r = segmentRefRange(tok.slice(p + 1, q), tokStart + p + 1);
|
|
1991
|
+
if (r)
|
|
1992
|
+
ranges.push(r);
|
|
1993
|
+
p = q;
|
|
1994
|
+
if (tok[p] === "?" || tok[p] === "#")
|
|
1995
|
+
break;
|
|
1996
|
+
}
|
|
1997
|
+
// Authentication named query parameters.
|
|
1998
|
+
const qAt = tok.indexOf("?");
|
|
1999
|
+
if (qAt !== -1) {
|
|
2000
|
+
let qs = tok.slice(qAt + 1);
|
|
2001
|
+
const hash = qs.indexOf("#");
|
|
2002
|
+
if (hash !== -1)
|
|
2003
|
+
qs = qs.slice(0, hash);
|
|
2004
|
+
let off = qAt + 1;
|
|
2005
|
+
for (const part of qs.split("&")) {
|
|
2006
|
+
const eq = part.indexOf("=");
|
|
2007
|
+
if (eq > 0 && AUTH_PARAM_NAME.test(part.slice(0, eq))) {
|
|
2008
|
+
ranges.push({ start: tokStart + off + eq + 1, end: tokStart + off + part.length });
|
|
2009
|
+
}
|
|
2010
|
+
off += part.length + 1;
|
|
2011
|
+
}
|
|
2012
|
+
}
|
|
2013
|
+
continue;
|
|
2014
|
+
}
|
|
2015
|
+
// Expression or variable led token: the leading reference in the
|
|
2016
|
+
// origin position, or the whole token as one reference.
|
|
2017
|
+
const lead = tok.replace(/^["'`(]+/, "");
|
|
2018
|
+
const leadOff = tok.length - lead.length;
|
|
2019
|
+
const ref = lead.match(/^\$\{\{[^}]*\}\}|^\$\{[^}]*\}|^\$[A-Za-z_][A-Za-z0-9_]*/);
|
|
2020
|
+
if (ref) {
|
|
2021
|
+
const after = lead[ref[0].length] ?? "";
|
|
2022
|
+
if (after === "" || after === "/" || after === "?" || after === "#") {
|
|
2023
|
+
ranges.push({ start: tokStart + leadOff, end: tokStart + leadOff + ref[0].length });
|
|
2024
|
+
}
|
|
2025
|
+
continue;
|
|
2026
|
+
}
|
|
2027
|
+
// A bare reference token in a curl statement's operand position.
|
|
2028
|
+
if (isStatement) {
|
|
2029
|
+
const bare = tok.replace(/^["'`(]+/, "").replace(/["'`),;]+$/, "");
|
|
2030
|
+
if (/^\{[A-Za-z_][A-Za-z0-9_.]*\}$/.test(bare)) {
|
|
2031
|
+
ranges.push({ start: tokStart, end: tokEnd });
|
|
2032
|
+
}
|
|
2033
|
+
}
|
|
2034
|
+
}
|
|
2035
|
+
if (isStatement) {
|
|
2036
|
+
// Bare operand variables: tokenize with flag value awareness.
|
|
2037
|
+
const VALUE_FLAGS = new Set(["-d", "--data", "--data-raw", "--data-binary", "--data-urlencode", "--data-ascii", "-F", "--form", "--form-string", "-H", "--header", "-u", "--user", "-o", "--output", "-X", "--request", "-e", "--referer", "-A", "--user-agent", "-b", "--cookie", "-c", "--cookie-jar", "--cert", "--key", "--cacert", "--connect-timeout", "--max-time", "--retry", "--url", "-T", "--upload-file", "--json", "--oauth2-bearer", "-m", "-E"]);
|
|
2038
|
+
const toks = Array.from(seg.matchAll(/[^\s]+/g));
|
|
2039
|
+
let prevWasValueFlag = false;
|
|
2040
|
+
for (const t of toks) {
|
|
2041
|
+
const raw = t[0];
|
|
2042
|
+
const start = span.start + (t.index ?? 0);
|
|
2043
|
+
const clean = raw.replace(/^["'`(]+/, "").replace(/["'`),;]+$/, "");
|
|
2044
|
+
if (!prevWasValueFlag && /^(?:\$[A-Za-z_][A-Za-z0-9_]*|\$\{[^}]*\}|\$\{\{[^}]*\}\})$/.test(clean) && !inBody({ start, end: start + raw.length })) {
|
|
2045
|
+
ranges.push({ start, end: start + raw.length });
|
|
2046
|
+
}
|
|
2047
|
+
if (raw.includes("=") && raw.startsWith("--"))
|
|
2048
|
+
prevWasValueFlag = false;
|
|
2049
|
+
else if (VALUE_FLAGS.has(raw))
|
|
2050
|
+
prevWasValueFlag = true;
|
|
2051
|
+
else
|
|
2052
|
+
prevWasValueFlag = false;
|
|
2053
|
+
}
|
|
2054
|
+
}
|
|
2055
|
+
return ranges;
|
|
2056
|
+
}
|
|
2057
|
+
/** The first argument span of a call whose "(" is at openIdx. */
|
|
2058
|
+
function firstArgSpan(text, openIdx) {
|
|
2059
|
+
let depth = 0;
|
|
2060
|
+
let quote = "";
|
|
2061
|
+
for (let j = openIdx; j < text.length; j++) {
|
|
2062
|
+
const c = text[j];
|
|
2063
|
+
if (quote) {
|
|
2064
|
+
if (c === "\\") {
|
|
2065
|
+
j++;
|
|
2066
|
+
continue;
|
|
2067
|
+
}
|
|
2068
|
+
if (c === quote)
|
|
2069
|
+
quote = "";
|
|
2070
|
+
continue;
|
|
2071
|
+
}
|
|
2072
|
+
if (c === "'" || c === '"' || c === "`") {
|
|
2073
|
+
quote = c;
|
|
2074
|
+
continue;
|
|
2075
|
+
}
|
|
2076
|
+
if (c === "(" || c === "[" || c === "{")
|
|
2077
|
+
depth++;
|
|
2078
|
+
else if (c === ")" || c === "]" || c === "}") {
|
|
2079
|
+
depth--;
|
|
2080
|
+
if (depth === 0)
|
|
2081
|
+
return { start: openIdx + 1, end: j };
|
|
2082
|
+
}
|
|
2083
|
+
else if (c === "," && depth === 1)
|
|
2084
|
+
return { start: openIdx + 1, end: j };
|
|
2085
|
+
}
|
|
2086
|
+
return { start: openIdx + 1, end: text.length };
|
|
2087
|
+
}
|
|
2088
|
+
/**
|
|
2089
|
+
* Aggregate ranges inside a printed argument (P-110): a length or
|
|
2090
|
+
* count of a value is metadata about the value, not the value (the
|
|
2091
|
+
* P-020 principle: ${#NAME} is a length, not a read). References
|
|
2092
|
+
* inside len(...), count(...), or size(...) calls, and the object of
|
|
2093
|
+
* a .length or .size property, do not count as print disclosure.
|
|
2094
|
+
*/
|
|
2095
|
+
function aggregateRanges(argText, argStart) {
|
|
2096
|
+
const ranges = [];
|
|
2097
|
+
for (const m of argText.matchAll(/\b(?:len|count|size)\s*\(/g)) {
|
|
2098
|
+
const openLocal = (m.index ?? 0) + m[0].length - 1;
|
|
2099
|
+
const local = callSpan(argText, openLocal);
|
|
2100
|
+
ranges.push({ start: argStart + (m.index ?? 0), end: argStart + local.end });
|
|
2101
|
+
}
|
|
2102
|
+
for (const m of argText.matchAll(/\b([A-Za-z_$][A-Za-z0-9_$]*)\s*\.\s*(?:length|size)\b/g)) {
|
|
2103
|
+
ranges.push({ start: argStart + (m.index ?? 0), end: argStart + (m.index ?? 0) + m[1].length });
|
|
2104
|
+
}
|
|
2105
|
+
return ranges.filter((r) => r.end > r.start);
|
|
2106
|
+
}
|
|
1334
2107
|
/**
|
|
1335
2108
|
* Exfil value flow (Round 2, P-018). ENV-EXFIL used to fire on mere
|
|
1336
2109
|
* co-occurrence: a credential shaped name and any URL inside one finding.
|
|
@@ -1356,7 +2129,61 @@ function spanHasTaint(text, span, atoms, tainted, dataRanges, auth = []) {
|
|
|
1356
2129
|
function analyzeExfil(text, views, lang) {
|
|
1357
2130
|
const dataRanges = views.dataRanges;
|
|
1358
2131
|
const auth = authRanges(text);
|
|
1359
|
-
const
|
|
2132
|
+
const rawAtoms = findAtoms(text, dataRanges).filter((a) => !inRanges(a.start, auth));
|
|
2133
|
+
// Bound-clean shell variables (P-107, the n8n OIDC probe shape):
|
|
2134
|
+
// a $NAME occurrence is a credential atom by NAME, but when the
|
|
2135
|
+
// same text assigns that name from a value that contains no
|
|
2136
|
+
// credential atom at all, the name is bound to a known
|
|
2137
|
+
// non-credential value in this scope: ID_TOKEN_RESPONSE bound to
|
|
2138
|
+
// "$RUNNER_TEMP/npm-oidc-id-token.json" is a response FILE PATH
|
|
2139
|
+
// (used as a curl --output sink), not a token. A name bound from a
|
|
2140
|
+
// credential (TOKEN2="$API_TOKEN") keeps its atom, and a name never
|
|
2141
|
+
// assigned in the text (a runner or environment provided value)
|
|
2142
|
+
// keeps it too.
|
|
2143
|
+
const boundClean = new Set();
|
|
2144
|
+
{
|
|
2145
|
+
const assignRe = /(?:^|[;\n,])\s*(?:export\s+)?([A-Za-z_][A-Za-z0-9_]*)\s*=(?![=<>])\s*/g;
|
|
2146
|
+
let am;
|
|
2147
|
+
while ((am = assignRe.exec(text)) !== null) {
|
|
2148
|
+
const rhsStart = am.index + am[0].length;
|
|
2149
|
+
let rhsEnd = rhsStart;
|
|
2150
|
+
while (rhsEnd < text.length && text[rhsEnd] !== ";" && text[rhsEnd] !== "\n")
|
|
2151
|
+
rhsEnd++;
|
|
2152
|
+
// Atoms of names already proven bound-clean (a literal path
|
|
2153
|
+
// variable used as an output sink) are not credential atoms
|
|
2154
|
+
// for this test either: cleanliness is transitive.
|
|
2155
|
+
const inRhs = rawAtoms.filter((x) => {
|
|
2156
|
+
if (x.start < rhsStart || x.start >= rhsEnd)
|
|
2157
|
+
return false;
|
|
2158
|
+
const nm = text.slice(x.start, x.end).match(/^\$\{?([A-Za-z_][A-Za-z0-9_]*)\}?$/);
|
|
2159
|
+
return !(nm && boundClean.has(nm[1]));
|
|
2160
|
+
});
|
|
2161
|
+
if (inRhs.length === 0) {
|
|
2162
|
+
boundClean.add(am[1]);
|
|
2163
|
+
continue;
|
|
2164
|
+
}
|
|
2165
|
+
// A name bound to the RESULT of a command substitution whose
|
|
2166
|
+
// only credential atoms sit in destination positions of the
|
|
2167
|
+
// substituted send is bound to that result (the OIDC probe's
|
|
2168
|
+
// id_token_status is an HTTP status code), not to the
|
|
2169
|
+
// credential presented in the URL. A name bound to a plain
|
|
2170
|
+
// string or expression that CONTAINS a destination atom (a
|
|
2171
|
+
// capability URL held in a variable) is not clean: printing
|
|
2172
|
+
// that variable would print the credential.
|
|
2173
|
+
const rhsText = text.slice(rhsStart, rhsEnd);
|
|
2174
|
+
if (rhsText.includes("$(")) {
|
|
2175
|
+
const dest = destAuthRanges(text, { start: rhsStart, end: rhsEnd }, true);
|
|
2176
|
+
if (inRhs.every((x) => inRanges(x.start, dest)))
|
|
2177
|
+
boundClean.add(am[1]);
|
|
2178
|
+
}
|
|
2179
|
+
}
|
|
2180
|
+
}
|
|
2181
|
+
const atoms = rawAtoms.filter((a) => {
|
|
2182
|
+
if (a.kind !== "cred")
|
|
2183
|
+
return true;
|
|
2184
|
+
const m = text.slice(a.start, a.end).match(/^\$\{?([A-Za-z_][A-Za-z0-9_]*)\}?$/);
|
|
2185
|
+
return !(m && boundClean.has(m[1]));
|
|
2186
|
+
});
|
|
1360
2187
|
// Inheritance purity (P-091): a bulk atom inside an inheritance
|
|
1361
2188
|
// expression passed as a subprocess env= is marked pure. Flows are
|
|
1362
2189
|
// accumulated with their purity instead of returning at the first
|
|
@@ -1385,6 +2212,26 @@ function analyzeExfil(text, views, lang) {
|
|
|
1385
2212
|
sawImpureFlow = true;
|
|
1386
2213
|
};
|
|
1387
2214
|
const has = (span) => note(spanHasTaint(text, span, atoms, tainted, dataRanges, auth));
|
|
2215
|
+
// Destination aware variant (P-106): taint that sits only in a
|
|
2216
|
+
// destination position (a capability URL presentation, the URL
|
|
2217
|
+
// operand, or the destination first argument of a fetch style
|
|
2218
|
+
// call) is authentication, not a flow into a send. Taint anywhere
|
|
2219
|
+
// else in the span - above all in a body range - fires as before.
|
|
2220
|
+
const hasDest = (span, isStatement, firstArg) => {
|
|
2221
|
+
const extra = destAuthRanges(text, span, isStatement);
|
|
2222
|
+
if (firstArg) {
|
|
2223
|
+
const open = text.indexOf("(", span.start);
|
|
2224
|
+
if (open !== -1 && open < span.end) {
|
|
2225
|
+
const fa = firstArgSpan(text, open);
|
|
2226
|
+
const faText = text.slice(fa.start, fa.end).trim();
|
|
2227
|
+
const bodies = bodyRanges(text, span);
|
|
2228
|
+
const faHasBody = bodies.some((b) => b.start >= fa.start && b.start < fa.end);
|
|
2229
|
+
if (!faText.startsWith("{") && !faHasBody)
|
|
2230
|
+
extra.push(fa);
|
|
2231
|
+
}
|
|
2232
|
+
}
|
|
2233
|
+
note(spanHasTaint(text, span, atoms, tainted, dataRanges, auth, extra));
|
|
2234
|
+
};
|
|
1388
2235
|
const callRes = [
|
|
1389
2236
|
/\bfetch\s*\(/g,
|
|
1390
2237
|
/\baxios(?:\s*\.\s*(?:post|put|patch|get|request|delete))?\s*\(/g,
|
|
@@ -1397,7 +2244,14 @@ function analyzeExfil(text, views, lang) {
|
|
|
1397
2244
|
for (const re of callRes) {
|
|
1398
2245
|
let m;
|
|
1399
2246
|
while ((m = re.exec(text)) !== null) {
|
|
1400
|
-
|
|
2247
|
+
const span = callSpan(text, m.index + m[0].length - 1);
|
|
2248
|
+
// XHR .send( takes the BODY as its argument, so it keeps the
|
|
2249
|
+
// plain rule; every other send call takes its destination
|
|
2250
|
+
// first, so its first argument is a destination position.
|
|
2251
|
+
if (re.source.includes("send"))
|
|
2252
|
+
has(span);
|
|
2253
|
+
else
|
|
2254
|
+
hasDest(span, false, true);
|
|
1401
2255
|
}
|
|
1402
2256
|
}
|
|
1403
2257
|
// Fetch positions run on the code view with data blanked (indices
|
|
@@ -1416,7 +2270,7 @@ function analyzeExfil(text, views, lang) {
|
|
|
1416
2270
|
}
|
|
1417
2271
|
if (!isFetchStmt)
|
|
1418
2272
|
continue;
|
|
1419
|
-
|
|
2273
|
+
hasDest(stmt, true, false);
|
|
1420
2274
|
}
|
|
1421
2275
|
for (const stmt of statements) {
|
|
1422
2276
|
const stmtText = text.slice(stmt.start, stmt.end);
|
|
@@ -1448,27 +2302,36 @@ function analyzeExfil(text, views, lang) {
|
|
|
1448
2302
|
if (hasSendConstruct) {
|
|
1449
2303
|
for (const m of text.matchAll(/(?:^|[;\n|&]\s*)(?:echo|printf)\s+([^\n;|]*)/g)) {
|
|
1450
2304
|
const span = { start: m.index, end: m.index + m[0].length };
|
|
2305
|
+
const argText = m[1] ?? "";
|
|
2306
|
+
const argStart = m.index + m[0].length - argText.length;
|
|
2307
|
+
const agg = aggregateRanges(argText, argStart);
|
|
1451
2308
|
for (const a of atoms) {
|
|
1452
|
-
if (a.start >= span.start && a.start < span.end)
|
|
2309
|
+
if (a.start >= span.start && a.start < span.end && !inRanges(a.start, agg))
|
|
1453
2310
|
note({ found: true, pure: a.pure === true });
|
|
1454
2311
|
}
|
|
1455
|
-
const argText = m[1] ?? "";
|
|
1456
|
-
const argStart = m.index + m[0].length - argText.length;
|
|
1457
2312
|
for (const [v, vPure] of tainted) {
|
|
1458
|
-
|
|
2313
|
+
// Shell echo arguments are the printed values themselves, so
|
|
2314
|
+
// a reference inside a quoted argument still counts; what
|
|
2315
|
+
// does not count is a reference inside an authentication or
|
|
2316
|
+
// masking position (the add-mask registration) or inside an
|
|
2317
|
+
// aggregate (a printed length or count).
|
|
2318
|
+
const ranges = lang === "shell" ? [...auth, ...agg] : [...dataRanges, ...auth, ...agg];
|
|
2319
|
+
if (refOutsideRanges(argText, v, argStart, ranges))
|
|
1459
2320
|
note({ found: true, pure: vPure });
|
|
1460
2321
|
}
|
|
1461
2322
|
}
|
|
1462
2323
|
for (const m of text.matchAll(/\b(?:console\.(?:log|info|debug)|print)\s*\(([^)]*)\)/g)) {
|
|
1463
2324
|
const span = { start: m.index, end: m.index + m[0].length };
|
|
2325
|
+
const argText = m[1] ?? "";
|
|
2326
|
+
const argStart = m.index + m[0].indexOf(argText);
|
|
2327
|
+
const agg = aggregateRanges(argText, argStart);
|
|
1464
2328
|
for (const a of atoms) {
|
|
1465
|
-
if (a.start >= span.start && a.start < span.end && (lang === "shell" || !a.inData))
|
|
2329
|
+
if (a.start >= span.start && a.start < span.end && (lang === "shell" || !a.inData) && !inRanges(a.start, agg))
|
|
1466
2330
|
note({ found: true, pure: a.pure === true });
|
|
1467
2331
|
}
|
|
1468
|
-
const argText = m[1] ?? "";
|
|
1469
|
-
const argStart = m.index + m[0].indexOf(argText);
|
|
1470
2332
|
for (const [v, vPure] of tainted) {
|
|
1471
|
-
|
|
2333
|
+
const ranges = lang === "shell" ? [...auth, ...agg] : [...dataRanges, ...auth, ...agg];
|
|
2334
|
+
if (refOutsideRanges(argText, v, argStart, ranges))
|
|
1472
2335
|
note({ found: true, pure: vPure });
|
|
1473
2336
|
}
|
|
1474
2337
|
}
|
|
@@ -1560,7 +2423,15 @@ export function classifyCommand(command, lang = "shell") {
|
|
|
1560
2423
|
continue;
|
|
1561
2424
|
}
|
|
1562
2425
|
if (rule.id === "CURL-PIPE-SH") {
|
|
1563
|
-
if (curlPipeIsCommandFetch(
|
|
2426
|
+
if (curlPipeIsCommandFetch(views.codeText, positions))
|
|
2427
|
+
fired.push({ id: rule.id, evidence: rule.evidence });
|
|
2428
|
+
continue;
|
|
2429
|
+
}
|
|
2430
|
+
if (rule.id === "DECODE-PIPE-SH") {
|
|
2431
|
+
// Same pipe target analysis as CURL-PIPE-SH (P-109d): decoded
|
|
2432
|
+
// content piped into an interpreter that is given its program
|
|
2433
|
+
// is data for that program, not executed code.
|
|
2434
|
+
if (decodePipeExecutesStdin(views.codeText))
|
|
1564
2435
|
fired.push({ id: rule.id, evidence: rule.evidence });
|
|
1565
2436
|
continue;
|
|
1566
2437
|
}
|
|
@@ -1823,46 +2694,121 @@ function escalateWithScriptChain(root, scripts, currentName, finding, baseAbs =
|
|
|
1823
2694
|
* estate verdicts REVIEW, never COMPROMISED-PATTERN.
|
|
1824
2695
|
*/
|
|
1825
2696
|
/**
|
|
1826
|
-
* Vendor installer pipe calibration (P-063, Wave B
|
|
1827
|
-
*
|
|
1828
|
-
*
|
|
1829
|
-
*
|
|
1830
|
-
* download-own-binary installers
|
|
1831
|
-
* at REVIEW: the pattern match
|
|
1832
|
-
*
|
|
1833
|
-
*
|
|
2697
|
+
* Vendor installer pipe calibration (P-063, Wave B; composition
|
|
2698
|
+
* completed in P-109). A CI workflow step whose ONLY severe evidence
|
|
2699
|
+
* is a curl pipe to a named vendor's official installer over HTTPS
|
|
2700
|
+
* (the uv installer at astral.sh, the opencode installer) is the same
|
|
2701
|
+
* family as the esbuild and puppeteer download-own-binary installers
|
|
2702
|
+
* the v0.1.2 calibration already holds at REVIEW: the pattern match
|
|
2703
|
+
* is literal and the finding stays RISKY, but nothing about the step
|
|
2704
|
+
* exfils, persists, or touches a credential. The narrowing is
|
|
2705
|
+
* deliberately tight; ALL of these must hold:
|
|
1834
2706
|
* - the finding is a GitHub workflow run step (CI context). The same
|
|
1835
2707
|
* pipe on an auto-exec hook surface (package.json script, Claude
|
|
1836
2708
|
* settings or plugin hook, folderOpen task) keeps the top verdict:
|
|
1837
2709
|
* a hook runs on a developer machine at open or install time,
|
|
1838
2710
|
* which is exactly the Shai-Hulud persistence shape;
|
|
1839
2711
|
* - CURL-PIPE-SH is the only severe rule, and every rule present is
|
|
1840
|
-
* part of the pipe shape itself
|
|
1841
|
-
*
|
|
1842
|
-
*
|
|
1843
|
-
*
|
|
1844
|
-
*
|
|
1845
|
-
*
|
|
2712
|
+
* part of the pipe shape itself or of the install's own footprint
|
|
2713
|
+
* in the same step: CURL-PIPE-SH, NET-FETCH, and REMOTE-URL are
|
|
2714
|
+
* the pipe; WRITE-OUTSIDE is the footprint (P-109c: the rollup
|
|
2715
|
+
* nvm step that sources the profile it just installed and
|
|
2716
|
+
* chmods the binary it just built; an installer writing under
|
|
2717
|
+
* $HOME is its shape, not a second signal). Any credential rule
|
|
2718
|
+
* (CRED-ACCESS, ENV-ACCESS, ENV-BULK, either exfil rule), any
|
|
2719
|
+
* obfuscation, or a second severe signal disqualifies the
|
|
2720
|
+
* finding on its own merits;
|
|
2721
|
+
* - the piped URL is HTTPS and installer shaped or same-repo
|
|
2722
|
+
* shaped (see installerUrlShape).
|
|
1846
2723
|
* A curl pipe to an unversioned or non-vendor source (payload.sh on an
|
|
1847
2724
|
* arbitrary host) never qualifies.
|
|
2725
|
+
*
|
|
2726
|
+
* Composition (P-109d): the calibration is a property of the single
|
|
2727
|
+
* finding, evaluated at the verdict layer per finding. A calibrated
|
|
2728
|
+
* pipe never drives the top verdict, and no other finding can
|
|
2729
|
+
* re-arm it; every other finding in the estate stands or falls on
|
|
2730
|
+
* its own merits (an unrelated ENV-EXFIL finding verdicts the repo
|
|
2731
|
+
* COMPROMISED-PATTERN by itself, a DOWNLOAD-EXEC only finding does
|
|
2732
|
+
* not, and neither outcome changes what the pipes contribute).
|
|
1848
2733
|
*/
|
|
1849
|
-
const KNOWN_INSTALLER_HOSTS = new Set(["sh.rustup.rs", "get.docker.com", "install.python-poetry.org", "astral.sh", "opencode.ai"]);
|
|
2734
|
+
const KNOWN_INSTALLER_HOSTS = new Set(["sh.rustup.rs", "get.docker.com", "install.python-poetry.org", "astral.sh", "opencode.ai", "pyenv.run"]);
|
|
2735
|
+
/**
|
|
2736
|
+
* Installer endpoints are conventionally served from a dedicated
|
|
2737
|
+
* subdomain of the vendor domain whose first label names the
|
|
2738
|
+
* installer role: get.docker.com, install.python-poetry.org,
|
|
2739
|
+
* sh.rustup.rs, and (P-109a) get.maestro.mobile.dev, the Maestro
|
|
2740
|
+
* installer host that verdicts meteor/meteor on 0.1.11. pyenv.run
|
|
2741
|
+
* sits in the known list above instead: it is the documented pyenv
|
|
2742
|
+
* installer endpoint (the pyenv README install command is
|
|
2743
|
+
* `curl https://pyenv.run | bash`) with no subdomain to generalize.
|
|
2744
|
+
*/
|
|
2745
|
+
const INSTALLER_SUBDOMAINS = new Set(["get", "install", "installer", "setup", "sh"]);
|
|
2746
|
+
const OWN_REPO_REF = /(?:\$\{\{\s*github\.repository\s*\}\}|\$\{\s*GITHUB_REPOSITORY\s*\}|\$GITHUB_REPOSITORY\b)/;
|
|
2747
|
+
const OWN_SHA_REF = /(?:\$\{\{\s*github\.sha\s*\}\}|\$\{\s*GITHUB_SHA\s*\}|\$GITHUB_SHA\b)/;
|
|
2748
|
+
/**
|
|
2749
|
+
* True when one piped URL is a calibrated download source:
|
|
2750
|
+
* - installer shaped (P-063, generalized in P-109a): any path
|
|
2751
|
+
* segment carries install, installer, or setup as a whole
|
|
2752
|
+
* hyphen, underscore, or dot delimited token (install.sh,
|
|
2753
|
+
* setup_lts.x, and astral's cargo-hawk-installer.sh, whose
|
|
2754
|
+
* segment is not exactly an installer word), or the host is a
|
|
2755
|
+
* documented installer host (the known list, or the installer
|
|
2756
|
+
* subdomain convention). A segment that merely CONTAINS an
|
|
2757
|
+
* installer word inside a larger token (uninstall.sh) is not an
|
|
2758
|
+
* installer shape;
|
|
2759
|
+
* - or the repo's own script at the immutable commit under test
|
|
2760
|
+
* (P-109b): the URL is on github.com or
|
|
2761
|
+
* raw.githubusercontent.com, names the repo through the
|
|
2762
|
+
* repository variable or expression, and pins the ref through
|
|
2763
|
+
* the SHA variable or expression (${GITHUB_SHA} or
|
|
2764
|
+
* ${{ github.sha }}). nodejs/node pipes its own start-ci and
|
|
2765
|
+
* release-proposal scripts this way; content at the commit
|
|
2766
|
+
* already being tested adds no trust surface beyond the code
|
|
2767
|
+
* already running. A literal other repo, or the same repo at a
|
|
2768
|
+
* mutable ref, is not verified same-repo content and does not
|
|
2769
|
+
* qualify.
|
|
2770
|
+
*/
|
|
2771
|
+
function installerUrlShape(url) {
|
|
2772
|
+
const m = url.match(/^https:\/\/([^\s"'`/:?#]+)([^\s"'`]*)/i);
|
|
2773
|
+
if (!m)
|
|
2774
|
+
return false;
|
|
2775
|
+
const host = m[1].toLowerCase();
|
|
2776
|
+
const path = (m[2] ?? "").toLowerCase();
|
|
2777
|
+
if ((host === "github.com" || host === "raw.githubusercontent.com") && OWN_REPO_REF.test(url) && OWN_SHA_REF.test(url))
|
|
2778
|
+
return true;
|
|
2779
|
+
for (const seg of path.split("/")) {
|
|
2780
|
+
if (!seg)
|
|
2781
|
+
continue;
|
|
2782
|
+
const tokens = seg.split(/[-_.]+/).filter(Boolean);
|
|
2783
|
+
if (tokens.some((t) => t === "install" || t === "installer" || t === "setup"))
|
|
2784
|
+
return true;
|
|
2785
|
+
}
|
|
2786
|
+
if (KNOWN_INSTALLER_HOSTS.has(host))
|
|
2787
|
+
return true;
|
|
2788
|
+
return INSTALLER_SUBDOMAINS.has(host.split(".")[0]);
|
|
2789
|
+
}
|
|
1850
2790
|
function isVendorInstallerPipeOnly(f) {
|
|
1851
2791
|
if (f.source !== "github-workflow")
|
|
1852
2792
|
return false;
|
|
1853
2793
|
if (!f.rules.includes("CURL-PIPE-SH"))
|
|
1854
2794
|
return false;
|
|
1855
|
-
const allowed = new Set(["CURL-PIPE-SH", "NET-FETCH", "REMOTE-URL"]);
|
|
2795
|
+
const allowed = new Set(["CURL-PIPE-SH", "NET-FETCH", "REMOTE-URL", "WRITE-OUTSIDE"]);
|
|
1856
2796
|
if (!f.rules.every((r) => allowed.has(r)))
|
|
1857
2797
|
return false;
|
|
1858
|
-
|
|
1859
|
-
|
|
1860
|
-
|
|
1861
|
-
|
|
1862
|
-
|
|
1863
|
-
|
|
1864
|
-
|
|
1865
|
-
|
|
2798
|
+
// The piped URL is the URL inside a curl pipe segment of the
|
|
2799
|
+
// command. When the pipe segment holds no literal URL (the URL is
|
|
2800
|
+
// in a variable assigned earlier in the step), fall back to the
|
|
2801
|
+
// URLs elsewhere in the command, as before P-109. Actions
|
|
2802
|
+
// expressions are whitespace collapsed first, the same
|
|
2803
|
+
// normalization classification applies, so a URL spelled with
|
|
2804
|
+
// ${{ github.repository }} and ${{ github.sha }} parses whole.
|
|
2805
|
+
const cmd = f.command.replace(/\$\{\{([\s\S]*?)\}\}/g, (_m, inner) => "${{" + inner.replace(/\s+/g, "") + "}}");
|
|
2806
|
+
const pipedUrls = [];
|
|
2807
|
+
for (const m of cmd.matchAll(/\b(?:curl|wget)\b[^|]*\|/g)) {
|
|
2808
|
+
pipedUrls.push(...(m[0].match(/https:\/\/[^\s"'`]+/gi) ?? []));
|
|
2809
|
+
}
|
|
2810
|
+
const candidates = pipedUrls.length > 0 ? pipedUrls : (cmd.match(/https:\/\/[^\s"'`]+/gi) ?? []);
|
|
2811
|
+
return candidates.some((u) => installerUrlShape(u));
|
|
1866
2812
|
}
|
|
1867
2813
|
export function computeVerdict(findings) {
|
|
1868
2814
|
let review = false;
|
|
@@ -1872,8 +2818,10 @@ export function computeVerdict(findings) {
|
|
|
1872
2818
|
const hasSevere = metas.some((x) => x.severe);
|
|
1873
2819
|
const hasCredStore = f.rules.includes("CRED-ACCESS");
|
|
1874
2820
|
const hasNet = metas.some((x) => x.remote);
|
|
1875
|
-
// P-063
|
|
1876
|
-
//
|
|
2821
|
+
// P-063 and P-109: the installer pipe calibration is per
|
|
2822
|
+
// finding. A calibrated pipe is strong pattern evidence but
|
|
2823
|
+
// not, on its own, a compromise pattern; it neither drives
|
|
2824
|
+
// the top verdict nor is re-armed by any other finding.
|
|
1877
2825
|
const vendorPipeOnly = isVendorInstallerPipeOnly(f);
|
|
1878
2826
|
if ((hasSevere && !vendorPipeOnly) || (hasCredStore && hasNet))
|
|
1879
2827
|
return "COMPROMISED-PATTERN";
|