iamsecure 0.0.0-stage → 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +127 -2
- package/bin/iamsecure.js +136 -0
- package/package.json +36 -4
- package/src/ai/index.js +170 -0
- package/src/ai/providers.js +161 -0
- package/src/code/context.js +123 -0
- package/src/code/engine.js +117 -0
- package/src/code/files.js +85 -0
- package/src/code/index.js +30 -0
- package/src/code/rules.js +545 -0
- package/src/code/secrets.js +123 -0
- package/src/code/strip.js +51 -0
- package/src/deps/cvss.js +59 -0
- package/src/deps/graph.js +88 -0
- package/src/deps/index.js +144 -0
- package/src/deps/lockfiles/composer.js +31 -0
- package/src/deps/lockfiles/npm.js +77 -0
- package/src/deps/lockfiles/pnpm.js +56 -0
- package/src/deps/lockfiles/python.js +101 -0
- package/src/deps/lockfiles/yarn.js +93 -0
- package/src/deps/osv.js +106 -0
- package/src/project.js +102 -0
- package/src/report/sarif.js +58 -0
- package/src/report/terminal.js +253 -0
- package/src/risk/exploit.js +53 -0
- package/src/risk/score.js +215 -0
- package/src/scan.js +91 -0
- package/src/ui/index.html +980 -0
- package/src/ui/server.js +97 -0
- package/src/util/cache.js +35 -0
- package/src/util/http.js +43 -0
- package/src/util/semver.js +23 -0
- package/src/util/toml.js +129 -0
- package/src/util/yaml.js +85 -0
|
@@ -0,0 +1,980 @@
|
|
|
1
|
+
<!doctype html>
|
|
2
|
+
<html lang="en">
|
|
3
|
+
<head>
|
|
4
|
+
<meta charset="utf-8">
|
|
5
|
+
<meta name="viewport" content="width=device-width, initial-scale=1">
|
|
6
|
+
<title>iamsecure report</title>
|
|
7
|
+
<link rel="icon" href="data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24'%3E%3Cpath fill='%23143d59' d='M12 2 4 5v6c0 5 3.4 9.7 8 11 4.6-1.3 8-6 8-11V5l-8-3z'/%3E%3C/svg%3E">
|
|
8
|
+
<style>
|
|
9
|
+
:root {
|
|
10
|
+
--bg: #ffffff; --strip: #0f2b3d; --text: #142b3b; --muted: #5b6b78; --faint: #8795a1;
|
|
11
|
+
--panel: #f6f7f8; --frame: #e3e7eb; --tile: #ffffff; --line: #d8dde2; --link: #142b3b;
|
|
12
|
+
--accent: #1f6feb; --zero: #c3c9cf;
|
|
13
|
+
--crit: #d93a2b; --high: #ea7317; --med: #f2b705; --low: #2f7fd8; --ok: #12a03b;
|
|
14
|
+
--chip-bg: #eef1f4; --code-bg: #f1f3f5; --shadow: 0 1px 2px rgba(15, 43, 61, .06);
|
|
15
|
+
}
|
|
16
|
+
@media (prefers-color-scheme: dark) {
|
|
17
|
+
:root:not([data-theme="light"]) {
|
|
18
|
+
--bg: #0f1418; --strip: #05090c; --text: #e4e9ee; --muted: #a2afba; --faint: #7d8a95;
|
|
19
|
+
--panel: #172027; --frame: #212c35; --tile: #1b252d; --line: #2a3640; --link: #e4e9ee;
|
|
20
|
+
--accent: #6aa5ff; --zero: #46535e; --chip-bg: #212c35; --code-bg: #121a20; --shadow: none;
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
:root[data-theme="dark"] {
|
|
24
|
+
--bg: #0f1418; --strip: #05090c; --text: #e4e9ee; --muted: #a2afba; --faint: #7d8a95;
|
|
25
|
+
--panel: #172027; --frame: #212c35; --tile: #1b252d; --line: #2a3640; --link: #e4e9ee;
|
|
26
|
+
--accent: #6aa5ff; --zero: #46535e; --chip-bg: #212c35; --code-bg: #121a20; --shadow: none;
|
|
27
|
+
}
|
|
28
|
+
* { box-sizing: border-box; }
|
|
29
|
+
html { -webkit-text-size-adjust: 100%; }
|
|
30
|
+
body { margin: 0; background: var(--bg); color: var(--text); font: 15px/1.6 "Avenir Next", Avenir, "Segoe UI", system-ui, -apple-system, Roboto, sans-serif; }
|
|
31
|
+
a { color: var(--link); }
|
|
32
|
+
button, select, input { font: inherit; color: inherit; }
|
|
33
|
+
pre { white-space: pre-wrap; overflow-wrap: anywhere; max-width: 100%; }
|
|
34
|
+
code, pre, .mono { font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; font-size: 13px; }
|
|
35
|
+
|
|
36
|
+
/* ── header ─────────────────────────────────────────── */
|
|
37
|
+
.strip { height: 6px; background: var(--strip); }
|
|
38
|
+
header.top { border-bottom: 1px solid var(--line); }
|
|
39
|
+
.top-in { max-width: 1000px; margin: 0 auto; padding: 14px 24px; display: flex; align-items: center; gap: 24px; }
|
|
40
|
+
.brand { display: flex; align-items: center; gap: 9px; font-size: 19px; letter-spacing: -.01em; }
|
|
41
|
+
.brand b { font-weight: 800; }
|
|
42
|
+
.brand svg { width: 26px; height: 26px; }
|
|
43
|
+
.nav { margin-left: auto; display: flex; gap: 26px; }
|
|
44
|
+
.nav button { background: none; border: 0; padding: 4px 0; cursor: pointer; font-size: 13px; font-weight: 600; letter-spacing: .06em; text-transform: uppercase; color: var(--text); }
|
|
45
|
+
.nav button:hover { color: var(--accent); }
|
|
46
|
+
.nav button:disabled { opacity: .55; cursor: progress; }
|
|
47
|
+
|
|
48
|
+
main { max-width: 1000px; margin: 0 auto; padding: 0 24px 64px; }
|
|
49
|
+
|
|
50
|
+
/* ── breadcrumb + counter tiles ─────────────────────── */
|
|
51
|
+
.hero { display: flex; align-items: flex-start; justify-content: space-between; gap: 24px; }
|
|
52
|
+
.crumb { padding-top: 46px; font-size: 14px; color: var(--muted); }
|
|
53
|
+
.crumb a { color: var(--text); }
|
|
54
|
+
.crumb .sep { margin: 0 8px; }
|
|
55
|
+
.counters { display: flex; gap: 8px; padding: 8px; background: var(--frame); border-radius: 0 0 6px 6px; }
|
|
56
|
+
.counter { width: 84px; padding: 10px 4px 8px; background: var(--tile); text-align: center; border: 0; cursor: pointer; border-radius: 3px; }
|
|
57
|
+
.counter .n { width: 38px; height: 38px; margin: 0 auto 4px; border-radius: 50%; display: grid; place-items: center; font-weight: 700; font-size: 15px; color: #fff; background: var(--zero); }
|
|
58
|
+
.counter .l { font-size: 11px; font-weight: 700; letter-spacing: .06em; text-transform: uppercase; }
|
|
59
|
+
.counter.critical.has .n { background: var(--crit); }
|
|
60
|
+
.counter.high.has .n { background: var(--high); }
|
|
61
|
+
.counter.medium.has .n { background: var(--med); color: #2b2100; }
|
|
62
|
+
.counter.low.has .n { background: var(--low); }
|
|
63
|
+
.counter.on { outline: 2px solid var(--accent); outline-offset: -2px; }
|
|
64
|
+
|
|
65
|
+
h1 { display: flex; align-items: center; gap: 10px; margin: 26px 0 0; font-size: 26px; font-weight: 600; letter-spacing: -.01em; }
|
|
66
|
+
h1 svg { width: 26px; height: 26px; flex: none; }
|
|
67
|
+
.sub { color: var(--muted); font-size: 14px; margin: 2px 0 0 36px; overflow-wrap: anywhere; }
|
|
68
|
+
.prose, .desc, .firstfix li, h1 span { overflow-wrap: anywhere; min-width: 0; }
|
|
69
|
+
.label { margin: 28px 0 10px; font-size: 14px; color: var(--muted); }
|
|
70
|
+
|
|
71
|
+
/* ── status dots (like Up / Affected / Down) ────────── */
|
|
72
|
+
.dot { display: inline-block; width: 14px; height: 14px; border-radius: 50%; position: relative; vertical-align: -2px; flex: none; }
|
|
73
|
+
.dot.critical { background: var(--crit); }
|
|
74
|
+
.dot.critical::after { content: ""; position: absolute; left: 3px; right: 3px; top: 6px; height: 2px; border-radius: 1px; background: #fff; }
|
|
75
|
+
.dot.high { background: var(--high); }
|
|
76
|
+
.dot.medium { background: var(--med); }
|
|
77
|
+
.dot.medium::after { content: ""; position: absolute; inset: 4px; border-radius: 50%; background: #2b2100; }
|
|
78
|
+
.dot.low { background: var(--low); }
|
|
79
|
+
.dot.ok { background: var(--ok); }
|
|
80
|
+
.dot.muted, .dot.none { background: var(--zero); }
|
|
81
|
+
.trio { display: flex; gap: 30px; margin-top: 10px; font-size: 14px; }
|
|
82
|
+
.trio > div { min-width: 0; }
|
|
83
|
+
.trio small { display: block; font-size: 13.5px; color: var(--text); }
|
|
84
|
+
.trio .v { display: inline-flex; align-items: center; gap: 8px; font-weight: 700; white-space: nowrap; }
|
|
85
|
+
|
|
86
|
+
/* ── latest scan panel ─────────────────────────────── */
|
|
87
|
+
.panel { background: var(--panel); border-radius: 10px; padding: 28px 32px 30px; display: grid; grid-template-columns: 340px minmax(0, 1fr); gap: 28px 40px; }
|
|
88
|
+
.status { display: flex; align-items: baseline; gap: 12px; font-size: 16px; }
|
|
89
|
+
.status .ico { width: 18px; text-align: center; }
|
|
90
|
+
.status b { font-weight: 700; }
|
|
91
|
+
.status .kind { font-weight: 600; letter-spacing: .02em; }
|
|
92
|
+
.when { margin-left: 30px; font-size: 14px; color: var(--muted); font-weight: 600; }
|
|
93
|
+
.panel .trio { margin-left: 30px; }
|
|
94
|
+
.panel .rawsev { margin: 12px 0 0 30px; font-size: 13px; color: var(--muted); }
|
|
95
|
+
.prose p { margin: 0 0 14px; }
|
|
96
|
+
.groups { list-style: none; padding: 0; margin: 18px 0 0; display: grid; gap: 10px; }
|
|
97
|
+
.groups li { display: flex; align-items: center; gap: 14px; font-weight: 600; }
|
|
98
|
+
.groups li span.d { font-weight: 400; color: var(--muted); }
|
|
99
|
+
.firstfix { margin: 18px 0 0; padding: 0; list-style: none; font-size: 14px; }
|
|
100
|
+
.firstfix li { margin: 4px 0; }
|
|
101
|
+
.firstfix a { cursor: pointer; text-decoration: underline; text-underline-offset: 2px; }
|
|
102
|
+
.firstfix a:hover { color: var(--accent); }
|
|
103
|
+
.warn { margin: 14px 0 0; padding: 10px 14px; border-left: 3px solid var(--med); background: var(--panel); font-size: 14px; }
|
|
104
|
+
|
|
105
|
+
/* ── toolbar ───────────────────────────────────────── */
|
|
106
|
+
.bar { display: flex; flex-wrap: wrap; align-items: center; gap: 10px 18px; margin: 36px 0 6px; }
|
|
107
|
+
.bar .label { margin: 0; }
|
|
108
|
+
.tabs { display: flex; gap: 22px; margin-right: auto; }
|
|
109
|
+
.tabs button { background: none; border: 0; padding: 4px 0; cursor: pointer; color: var(--muted); font-size: 14px; border-bottom: 2px solid transparent; }
|
|
110
|
+
.tabs button.on { color: var(--text); border-color: var(--text); font-weight: 600; }
|
|
111
|
+
.filters { display: flex; flex-wrap: wrap; gap: 10px; align-items: center; margin: 4px 0 4px; }
|
|
112
|
+
.seg { display: inline-flex; border: 1px solid var(--line); border-radius: 999px; overflow: hidden; }
|
|
113
|
+
.seg button { border: 0; background: none; padding: 5px 13px; cursor: pointer; color: var(--muted); font-size: 13.5px; }
|
|
114
|
+
.seg button.on { background: var(--text); color: var(--bg); }
|
|
115
|
+
select, input[type=search] { border: 1px solid var(--line); background: var(--bg); border-radius: 999px; padding: 5px 13px; font-size: 13.5px; }
|
|
116
|
+
input[type=search] { flex: 1; min-width: 200px; }
|
|
117
|
+
label.chk { display: inline-flex; align-items: center; gap: 6px; font-size: 13.5px; color: var(--muted); }
|
|
118
|
+
.hint { color: var(--muted); font-size: 13px; margin: 6px 0 0; }
|
|
119
|
+
|
|
120
|
+
/* ── findings, like a notification history ─────────── */
|
|
121
|
+
.entry { display: grid; grid-template-columns: 340px minmax(0, 1fr); gap: 12px 40px; padding: 28px 0; border-bottom: 1px solid var(--line); }
|
|
122
|
+
.entry .head { display: flex; align-items: baseline; gap: 12px; }
|
|
123
|
+
.entry .head .ico { width: 18px; flex: none; color: var(--muted); }
|
|
124
|
+
.entry .head .ico svg { width: 18px; height: 18px; vertical-align: -3px; }
|
|
125
|
+
.entry .lvl { font-weight: 700; letter-spacing: .02em; text-transform: uppercase; font-size: 14px; }
|
|
126
|
+
.entry .lvl.critical { color: var(--crit); } .entry .lvl.high { color: var(--high); }
|
|
127
|
+
.entry .lvl.medium { color: #b38600; } .entry .lvl.low { color: var(--low); }
|
|
128
|
+
:root[data-theme="dark"] .entry .lvl.medium { color: var(--med); }
|
|
129
|
+
@media (prefers-color-scheme: dark) { :root:not([data-theme="light"]) .entry .lvl.medium { color: var(--med); } }
|
|
130
|
+
.entry .kind { font-weight: 600; font-size: 15px; }
|
|
131
|
+
.entry .subject { margin: 2px 0 0 30px; font-weight: 600; font-size: 14px; color: var(--muted); word-break: break-all; }
|
|
132
|
+
.entry .trio { margin-left: 30px; gap: 26px; }
|
|
133
|
+
.entry .tags { margin: 10px 0 0 30px; display: flex; flex-wrap: wrap; gap: 6px; }
|
|
134
|
+
.tag { font-size: 12px; padding: 1px 9px; border-radius: 999px; background: var(--chip-bg); color: var(--muted); }
|
|
135
|
+
.tag.red { background: var(--crit); color: #fff; font-weight: 600; }
|
|
136
|
+
.tag.fix { background: transparent; color: var(--ok); border: 1px solid var(--ok); }
|
|
137
|
+
.desc .ttl { margin: 0 0 8px; font-weight: 600; }
|
|
138
|
+
.desc ul { margin: 0 0 10px; padding-left: 18px; }
|
|
139
|
+
.desc li { margin: 2px 0; }
|
|
140
|
+
.desc .fix { margin: 0 0 6px; }
|
|
141
|
+
.desc .fix b { color: var(--ok); }
|
|
142
|
+
.desc pre { margin: 0 0 10px; padding: 10px 12px; background: var(--code-bg); border-radius: 6px; white-space: pre-wrap; word-break: break-word; max-height: 320px; overflow: auto; }
|
|
143
|
+
.readall { display: inline-block; margin-top: 2px; font-size: 14px; color: var(--text); cursor: pointer; background: none; border: 0; padding: 0; }
|
|
144
|
+
.readall span { text-decoration: underline; }
|
|
145
|
+
.more { margin-top: 14px; display: grid; gap: 14px; }
|
|
146
|
+
.more h4 { margin: 0 0 4px; font-size: 12px; font-weight: 700; letter-spacing: .06em; text-transform: uppercase; color: var(--muted); }
|
|
147
|
+
.path { display: flex; flex-wrap: wrap; align-items: center; gap: 6px; font-size: 13px; }
|
|
148
|
+
.path code { padding: 1px 7px; border-radius: 5px; background: var(--chip-bg); }
|
|
149
|
+
.path code:last-child { background: var(--crit); color: #fff; }
|
|
150
|
+
.ai { border-left: 3px solid var(--accent); padding: 4px 0 4px 14px; }
|
|
151
|
+
.ai .v { font-weight: 700; text-transform: capitalize; }
|
|
152
|
+
.btn { border: 1px solid var(--text); background: none; border-radius: 999px; padding: 6px 16px; cursor: pointer; font-size: 13.5px; font-weight: 600; }
|
|
153
|
+
.btn:hover { background: var(--text); color: var(--bg); }
|
|
154
|
+
.btn:disabled { opacity: .55; cursor: progress; }
|
|
155
|
+
.empty { padding: 48px 0; color: var(--muted); text-align: center; }
|
|
156
|
+
.showmore { text-align: center; margin: 28px 0 0; }
|
|
157
|
+
|
|
158
|
+
/* ── fix list: one plain-language card per action ─── */
|
|
159
|
+
.listctl { display: flex; justify-content: flex-end; gap: 16px; margin: 4px 0 6px; }
|
|
160
|
+
.listctl button { background: none; border: 0; padding: 0; cursor: pointer; font-size: 13px; color: var(--muted); text-decoration: underline; text-underline-offset: 2px; }
|
|
161
|
+
.card { margin: 8px 0; border-radius: 8px; }
|
|
162
|
+
.fbar { width: 100%; display: grid; grid-template-columns: 150px minmax(0, 1fr) auto 14px; gap: 16px; align-items: center; padding: 13px 18px; border: 0; border-radius: 8px; cursor: pointer; text-align: left; color: #fff; background: var(--zero); font-size: 15px; }
|
|
163
|
+
.fbar:hover { filter: brightness(1.06); }
|
|
164
|
+
.fbar:focus-visible { outline: 3px solid var(--accent); outline-offset: 2px; }
|
|
165
|
+
.fbar.critical { background: var(--crit); }
|
|
166
|
+
.fbar.high { background: var(--high); }
|
|
167
|
+
.fbar.medium { background: var(--med); color: #2b2100; }
|
|
168
|
+
.fbar.low { background: var(--low); }
|
|
169
|
+
.bar-lvl { font-weight: 800; font-size: 13px; letter-spacing: .03em; white-space: nowrap; }
|
|
170
|
+
.bar-title { font-weight: 600; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
|
171
|
+
.bar-meta { font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; font-size: 12.5px; opacity: .92; white-space: nowrap; max-width: 260px; overflow: hidden; text-overflow: ellipsis; }
|
|
172
|
+
.chev { display: inline-block; font-size: 20px; line-height: 1; transition: transform .15s; }
|
|
173
|
+
.card.open .chev { transform: rotate(90deg); }
|
|
174
|
+
.card.open .fbar { border-radius: 8px 8px 0 0; }
|
|
175
|
+
.body { border: 1px solid var(--line); border-top: 0; border-radius: 0 0 8px 8px; padding: 18px 22px 20px; background: var(--bg); }
|
|
176
|
+
.card .pills { display: flex; flex-wrap: wrap; gap: 6px; align-items: center; }
|
|
177
|
+
.pill { display: inline-block; font-size: 12px; font-weight: 700; letter-spacing: .02em; padding: 2px 10px; border-radius: 999px; color: #fff; background: var(--zero); white-space: nowrap; }
|
|
178
|
+
.pill.critical { background: var(--crit); } .pill.high { background: var(--high); }
|
|
179
|
+
.pill.medium { background: var(--med); color: #2b2100; } .pill.low { background: var(--low); }
|
|
180
|
+
.card h3 { margin: 10px 0 2px; font-size: 18px; font-weight: 600; line-height: 1.4; overflow-wrap: anywhere; }
|
|
181
|
+
.card .loc { color: var(--muted); font-size: 13.5px; overflow-wrap: anywhere; }
|
|
182
|
+
.rows { display: grid; grid-template-columns: 150px minmax(0, 1fr); gap: 10px 18px; margin-top: 16px; font-size: 14.5px; }
|
|
183
|
+
.rows .k { color: var(--muted); font-weight: 600; }
|
|
184
|
+
.rows > div { min-width: 0; }
|
|
185
|
+
.rows pre { margin: 0; padding: 10px 12px; background: var(--code-bg); border-radius: 6px; white-space: pre-wrap; overflow-wrap: anywhere; word-break: break-word; max-width: 100%; }
|
|
186
|
+
.rows code { background: var(--chip-bg); padding: 1px 6px; border-radius: 4px; overflow-wrap: anywhere; }
|
|
187
|
+
.toggle { margin-top: 14px; background: none; border: 0; padding: 0; cursor: pointer; font-size: 14px; font-weight: 600; color: var(--accent); }
|
|
188
|
+
.details { margin-top: 14px; padding-top: 14px; border-top: 1px dashed var(--line); display: grid; gap: 14px; }
|
|
189
|
+
.details[hidden], .more[hidden] { display: none; }
|
|
190
|
+
.details h4 { margin: 0 0 4px; font-size: 12px; font-weight: 700; letter-spacing: .06em; text-transform: uppercase; color: var(--muted); }
|
|
191
|
+
.details ul { margin: 0; padding-left: 18px; }
|
|
192
|
+
.advs { width: 100%; border-collapse: collapse; font-size: 14px; }
|
|
193
|
+
.advs td { padding: 9px 8px 9px 0; border-top: 1px solid var(--line); vertical-align: top; }
|
|
194
|
+
.advs .nowrap { white-space: nowrap; color: var(--muted); }
|
|
195
|
+
.advs td:last-child { white-space: nowrap; }
|
|
196
|
+
@media (max-width: 820px) {
|
|
197
|
+
.fbar { grid-template-columns: minmax(0, 1fr) 14px; gap: 2px 10px; padding: 11px 14px; }
|
|
198
|
+
.bar-lvl { grid-column: 1; }
|
|
199
|
+
.bar-title { grid-column: 1; white-space: normal; }
|
|
200
|
+
.bar-meta { display: none; }
|
|
201
|
+
.chev { grid-column: 2; grid-row: 1 / span 2; }
|
|
202
|
+
.body { padding: 14px 16px 16px; }
|
|
203
|
+
.rows { grid-template-columns: 1fr; gap: 2px; }
|
|
204
|
+
.rows .k { margin-top: 10px; }
|
|
205
|
+
.advs td:nth-child(3) { display: none; }
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
/* ── dependency tree ───────────────────────────────── */
|
|
209
|
+
.tv-head { display: flex; flex-wrap: wrap; align-items: center; gap: 10px 14px; margin: 6px 0 4px; }
|
|
210
|
+
.tv-head input[type=search] { flex: 1; min-width: 240px; }
|
|
211
|
+
.tv-sum { margin: 14px 0 6px; font-size: 14.5px; }
|
|
212
|
+
.tv-sum b { font-weight: 700; }
|
|
213
|
+
.tv-file { margin: 22px 0 8px; font-size: 13px; color: var(--muted); font-weight: 600; letter-spacing: .04em; text-transform: uppercase; }
|
|
214
|
+
.tv, .tv ul { list-style: none; margin: 0; padding: 0; }
|
|
215
|
+
.tv-root { margin: 8px 0; border: 1px solid var(--line); border-left: 6px solid var(--ok); border-radius: 8px; background: var(--bg); }
|
|
216
|
+
.tv-root.critical { border-left-color: var(--crit); } .tv-root.high { border-left-color: var(--high); }
|
|
217
|
+
.tv-root.medium { border-left-color: var(--med); } .tv-root.low { border-left-color: var(--low); }
|
|
218
|
+
.tv-root > .tv-node { padding: 12px 16px; font-size: 15px; }
|
|
219
|
+
.tv-root > ul { padding: 0 16px 14px 30px; }
|
|
220
|
+
.tv ul ul { padding-left: 26px; }
|
|
221
|
+
.tv ul li { position: relative; }
|
|
222
|
+
.tv ul li::before { content: ""; position: absolute; left: -14px; top: 0; bottom: 0; border-left: 1px solid var(--line); }
|
|
223
|
+
.tv ul li:last-child::before { bottom: auto; height: 17px; }
|
|
224
|
+
.tv ul li::after { content: ""; position: absolute; left: -14px; top: 17px; width: 11px; border-top: 1px solid var(--line); }
|
|
225
|
+
.tv-node { display: flex; flex-wrap: wrap; align-items: center; gap: 6px 9px; padding: 5px 0; min-height: 34px; }
|
|
226
|
+
.tv-tog { width: 20px; height: 20px; flex: none; display: grid; place-items: center; border: 1px solid var(--line); border-radius: 5px; background: var(--bg); cursor: pointer; font-size: 13px; line-height: 1; color: var(--muted); padding: 0; transition: transform .12s; }
|
|
227
|
+
.tv-tog[aria-expanded="true"] { transform: rotate(90deg); }
|
|
228
|
+
.tv-leaf { width: 20px; flex: none; }
|
|
229
|
+
.tv-name { font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; font-size: 13.5px; font-weight: 600; overflow-wrap: anywhere; }
|
|
230
|
+
.tv-ver { font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; font-size: 13px; color: var(--muted); margin-left: -8px; }
|
|
231
|
+
.tv-node.clean .tv-name { font-weight: 400; }
|
|
232
|
+
.tv-below { font-size: 12.5px; color: var(--muted); }
|
|
233
|
+
.tv-fix { font-size: 13px; color: var(--accent); cursor: pointer; }
|
|
234
|
+
.tv-more { font-size: 13px; color: var(--muted); padding: 4px 0 4px 29px; }
|
|
235
|
+
.why { margin: 10px 0; padding: 14px 16px; border: 1px solid var(--line); border-radius: 8px; }
|
|
236
|
+
.why .tv-node { padding: 0 0 6px; }
|
|
237
|
+
.why ol { margin: 4px 0 0; padding-left: 22px; font-size: 13.5px; }
|
|
238
|
+
.why li { margin: 4px 0; }
|
|
239
|
+
.chain { display: inline-flex; flex-wrap: wrap; align-items: center; gap: 4px; }
|
|
240
|
+
.chain code { padding: 1px 7px; border-radius: 5px; background: var(--chip-bg); }
|
|
241
|
+
.chain code:last-child { background: var(--text); color: var(--bg); }
|
|
242
|
+
|
|
243
|
+
@media (max-width: 820px) {
|
|
244
|
+
.top-in { padding: 12px 16px; gap: 12px; }
|
|
245
|
+
.nav { gap: 14px; }
|
|
246
|
+
.nav button { font-size: 11.5px; letter-spacing: .04em; }
|
|
247
|
+
.nav .long { display: none; }
|
|
248
|
+
h1 { font-size: 22px; align-items: flex-start; }
|
|
249
|
+
h1 svg { margin-top: 3px; }
|
|
250
|
+
.sub { margin-left: 0; }
|
|
251
|
+
.counter { padding: 8px 2px 6px; }
|
|
252
|
+
.counter .l { font-size: 10px; letter-spacing: .03em; }
|
|
253
|
+
main { padding: 0 16px 48px; }
|
|
254
|
+
.hero { flex-direction: column-reverse; gap: 0; }
|
|
255
|
+
.crumb { padding-top: 18px; }
|
|
256
|
+
.counters { width: 100%; justify-content: space-between; border-radius: 0 0 6px 6px; }
|
|
257
|
+
.counter { flex: 1; width: auto; }
|
|
258
|
+
.panel, .entry { grid-template-columns: 1fr; }
|
|
259
|
+
.panel { padding: 22px 18px; }
|
|
260
|
+
.trio { gap: 18px; flex-wrap: wrap; }
|
|
261
|
+
}
|
|
262
|
+
</style>
|
|
263
|
+
</head>
|
|
264
|
+
<body>
|
|
265
|
+
<div class="strip"></div>
|
|
266
|
+
<header class="top">
|
|
267
|
+
<div class="top-in">
|
|
268
|
+
<div class="brand"><svg viewBox="0 0 24 24" aria-hidden="true"><path fill="currentColor" d="M12 2 4 5v6c0 5 3.4 9.7 8 11 4.6-1.3 8-6 8-11V5l-8-3z"/><path fill="none" stroke="var(--bg)" stroke-width="2.2" d="m8.5 12 2.5 2.5 4.5-5"/></svg><span><b>iam</b>secure</span></div>
|
|
269
|
+
<nav class="nav">
|
|
270
|
+
<button id="rescan">Rescan</button>
|
|
271
|
+
<button id="download"><span class="long">Download </span>JSON</button>
|
|
272
|
+
<button id="theme" aria-label="Toggle theme">Theme</button>
|
|
273
|
+
</nav>
|
|
274
|
+
</div>
|
|
275
|
+
</header>
|
|
276
|
+
<main>
|
|
277
|
+
<div class="hero">
|
|
278
|
+
<div class="crumb" id="crumb"><span>Projects</span><span class="sep">›</span><span>…</span></div>
|
|
279
|
+
<div class="counters" id="counters" aria-label="Findings by real risk"></div>
|
|
280
|
+
</div>
|
|
281
|
+
<h1 id="title"></h1>
|
|
282
|
+
<div class="sub" id="sub"></div>
|
|
283
|
+
|
|
284
|
+
<div class="label">Latest scan:</div>
|
|
285
|
+
<section class="panel" id="latest"></section>
|
|
286
|
+
<div id="warnings"></div>
|
|
287
|
+
|
|
288
|
+
<div class="bar">
|
|
289
|
+
<div class="tabs" role="tablist">
|
|
290
|
+
<button class="on" data-tab="fix">Fix list</button>
|
|
291
|
+
<button data-tab="all">All findings</button>
|
|
292
|
+
<button data-tab="tree">Dependency tree</button>
|
|
293
|
+
</div>
|
|
294
|
+
</div>
|
|
295
|
+
<section id="tab-findings">
|
|
296
|
+
<div class="filters">
|
|
297
|
+
<div class="seg" id="sort" role="group" aria-label="Sort">
|
|
298
|
+
<button data-v="risk" class="on">Worst first</button><button data-v="severity">High → low</button>
|
|
299
|
+
</div>
|
|
300
|
+
<div class="seg" id="kind" role="group" aria-label="Type">
|
|
301
|
+
<button data-v="" class="on">All</button><button data-v="dependency">Dependencies</button><button data-v="code">Code</button><button data-v="secret">Secrets</button>
|
|
302
|
+
</div>
|
|
303
|
+
<select id="exposure" aria-label="Where it runs">
|
|
304
|
+
<option value="">Runs anywhere</option><option value="server">Server</option><option value="universal">Server + browser</option>
|
|
305
|
+
<option value="client">Browser only</option><option value="build">Build/test only</option><option value="unused">Not imported</option>
|
|
306
|
+
</select>
|
|
307
|
+
<label class="chk"><input type="checkbox" id="hideDev"> Hide build/dev-only</label>
|
|
308
|
+
<input type="search" id="q" placeholder="Search package, file, CVE, rule…">
|
|
309
|
+
</div>
|
|
310
|
+
<p class="hint" id="hint"></p>
|
|
311
|
+
<div id="list"></div>
|
|
312
|
+
</section>
|
|
313
|
+
<section id="tab-tree" hidden></section>
|
|
314
|
+
</main>
|
|
315
|
+
<script>
|
|
316
|
+
const TOKEN = '__IAMSECURE_TOKEN__';
|
|
317
|
+
const AI = __IAMSECURE_AI__;
|
|
318
|
+
const LEVELS = ['critical', 'high', 'medium', 'low'];
|
|
319
|
+
const PAGE = 100;
|
|
320
|
+
const state = { report: null, view: 'fix', sort: 'risk', kind: '', level: '', exposure: '', hideDev: false, q: '', shown: PAGE, groupMap: new Map() };
|
|
321
|
+
|
|
322
|
+
// Plain-language explanations, keyed by the vulnerability class the scanner assigns.
|
|
323
|
+
const IMPACT = {
|
|
324
|
+
malicious: 'This package contains malware. It can steal secrets or tamper with every machine that installs it.',
|
|
325
|
+
secret: 'Anyone who can read this file, or your shipped JavaScript, can use this credential to get into your accounts.',
|
|
326
|
+
rce: 'An attacker could run their own commands or code on your server and take it over.',
|
|
327
|
+
sqli: 'An attacker could read, change or delete the data in your database.',
|
|
328
|
+
auth: 'An attacker could get past login or permission checks and act as someone else.',
|
|
329
|
+
ssrf: 'An attacker could make your server send requests to internal systems (admin panels, cloud metadata, private APIs).',
|
|
330
|
+
path: 'An attacker could read or overwrite files on your server, such as configuration files and keys.',
|
|
331
|
+
xss: "An attacker could run JavaScript in your visitors' browsers and steal their sessions or data.",
|
|
332
|
+
proto: 'An attacker could tamper with built-in objects, which can cause crashes, security bypasses or code execution.',
|
|
333
|
+
redirect: 'An attacker could use your domain to send people to a phishing site.',
|
|
334
|
+
dos: 'An attacker could crash or freeze your app with a specially crafted request.',
|
|
335
|
+
leak: 'Private data such as tokens, cookies or user details could leak to an attacker.',
|
|
336
|
+
crypto: 'Weak cryptography could let an attacker guess tokens or forge data.',
|
|
337
|
+
other: 'A known security weakness that an attacker may be able to use.',
|
|
338
|
+
};
|
|
339
|
+
const ACTION = { critical: 'Fix now', high: 'Fix soon', medium: 'Plan a fix', low: 'Low priority', none: 'Info' };
|
|
340
|
+
const WHERE = {
|
|
341
|
+
server: 'On your server, reachable by attackers over the internet',
|
|
342
|
+
universal: "On your server and in visitors' browsers",
|
|
343
|
+
client: "Only in visitors' browsers",
|
|
344
|
+
build: 'Only while building or testing; not shipped to users',
|
|
345
|
+
unused: 'Installed, but your code never imports it',
|
|
346
|
+
unknown: 'Could not tell where this runs',
|
|
347
|
+
};
|
|
348
|
+
const cap = (s) => s[0].toUpperCase() + s.slice(1);
|
|
349
|
+
const lvl = (f) => (state.sort === 'severity' ? f.severity : f.risk);
|
|
350
|
+
function cmpVer(a, b) {
|
|
351
|
+
const p = (v) => String(v).replace(/^v/, '').split(/[.+-]/).map((x) => (/^\d+$/.test(x) ? Number(x) : x));
|
|
352
|
+
const A = p(a), B = p(b);
|
|
353
|
+
for (let i = 0; i < Math.max(A.length, B.length); i++) {
|
|
354
|
+
const x = A[i] ?? 0, y = B[i] ?? 0;
|
|
355
|
+
if (x === y) continue;
|
|
356
|
+
if (typeof x === typeof y) return x > y ? 1 : -1;
|
|
357
|
+
return typeof x === 'number' ? 1 : -1;
|
|
358
|
+
}
|
|
359
|
+
return 0;
|
|
360
|
+
}
|
|
361
|
+
const EXPO = { server: ['critical', 'Server'], universal: ['critical', 'Server + browser'], client: ['medium', 'Browser only'], build: ['low', 'Build/test'], unused: ['muted', 'Not imported'], unknown: ['muted', 'Unknown'] };
|
|
362
|
+
const KIND = { dependency: 'Dependency', code: 'Code', secret: 'Secret' };
|
|
363
|
+
const ICON = {
|
|
364
|
+
dependency: '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8"><path d="M12 3 4 7v10l8 4 8-4V7l-8-4z"/><path d="m4 7 8 4 8-4M12 11v10"/></svg>',
|
|
365
|
+
code: '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8"><path d="m8 7-5 5 5 5M16 7l5 5-5 5"/></svg>',
|
|
366
|
+
secret: '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8"><circle cx="8" cy="15" r="4"/><path d="m11 12 9-9M17 6l3 3M15 8l2 2"/></svg>',
|
|
367
|
+
};
|
|
368
|
+
|
|
369
|
+
const $ = (s) => document.querySelector(s);
|
|
370
|
+
const esc = (s) => String(s ?? '').replace(/[&<>"']/g, (c) => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' })[c]);
|
|
371
|
+
const safeUrl = (u) => (/^https?:\/\//i.test(u) ? esc(u) : '#');
|
|
372
|
+
const host = (u) => { try { return new URL(u).hostname; } catch { return 'link'; } };
|
|
373
|
+
const dot = (tone) => `<span class="dot ${tone}"></span>`;
|
|
374
|
+
const api = (path, opts = {}) => fetch(path, { ...opts, headers: { 'x-iamsecure-token': TOKEN } }).then(async (r) => {
|
|
375
|
+
const body = await r.json();
|
|
376
|
+
if (!r.ok) throw new Error(body.error || r.statusText);
|
|
377
|
+
return body;
|
|
378
|
+
});
|
|
379
|
+
|
|
380
|
+
try { const t = localStorage.getItem('iamsecure-theme'); if (t) document.documentElement.dataset.theme = t; } catch {}
|
|
381
|
+
$('#theme').onclick = () => {
|
|
382
|
+
const dark = matchMedia('(prefers-color-scheme: dark)').matches;
|
|
383
|
+
const next = (document.documentElement.dataset.theme || (dark ? 'dark' : 'light')) === 'dark' ? 'light' : 'dark';
|
|
384
|
+
document.documentElement.dataset.theme = next;
|
|
385
|
+
try { localStorage.setItem('iamsecure-theme', next); } catch {}
|
|
386
|
+
};
|
|
387
|
+
$('#download').onclick = () => {
|
|
388
|
+
const a = document.createElement('a');
|
|
389
|
+
a.href = URL.createObjectURL(new Blob([JSON.stringify(state.report, null, 2)], { type: 'application/json' }));
|
|
390
|
+
a.download = `iamsecure-${state.report.project.name.replace(/[^\w.-]+/g, '_')}.json`;
|
|
391
|
+
a.click();
|
|
392
|
+
setTimeout(() => URL.revokeObjectURL(a.href), 1000);
|
|
393
|
+
};
|
|
394
|
+
|
|
395
|
+
function overall(s) {
|
|
396
|
+
if (s.byRisk.critical) return { tone: 'critical', word: 'ACTION REQUIRED', detail: `${s.byRisk.critical} critical`, icon: '✕' };
|
|
397
|
+
if (s.byRisk.high) return { tone: 'high', word: 'ATTENTION', detail: `${s.byRisk.high} high risk`, icon: '▲' };
|
|
398
|
+
if (s.total) return { tone: 'medium', word: 'MONITOR', detail: 'Minor issues', icon: '●' };
|
|
399
|
+
return { tone: 'ok', word: 'ALL CLEAR', detail: 'No known issues', icon: '✓' };
|
|
400
|
+
}
|
|
401
|
+
|
|
402
|
+
function groupRow(name, list) {
|
|
403
|
+
if (!list.length) return `<li>${dot('ok')}<span>${esc(name)} – Clear</span></li>`;
|
|
404
|
+
const worst = LEVELS.find((l) => list.some((f) => f.risk === l)) || 'muted';
|
|
405
|
+
const counts = LEVELS.map((l) => [l, list.filter((f) => f.risk === l).length]).filter(([, n]) => n);
|
|
406
|
+
return `<li>${dot(worst)}<span>${esc(name)} – ${counts[0][1]} ${counts[0][0]}</span><span class="d">${counts.slice(1).map(([l, n]) => `${n} ${l}`).join(' · ')}</span></li>`;
|
|
407
|
+
}
|
|
408
|
+
|
|
409
|
+
function header(r) {
|
|
410
|
+
const s = r.summary;
|
|
411
|
+
const p = r.stats.packages;
|
|
412
|
+
$('#crumb').innerHTML = `<span>Projects</span><span class="sep">›</span><span>${esc(r.project.name)}</span>`;
|
|
413
|
+
$('#counters').innerHTML = LEVELS.map((l) => `
|
|
414
|
+
<button class="counter ${l} ${s.byRisk[l] ? 'has' : ''} ${state.level === l ? 'on' : ''}" data-level="${l}" title="Show only ${l} (real risk)">
|
|
415
|
+
<div class="n">${s.byRisk[l]}</div><div class="l">${l}</div>
|
|
416
|
+
</button>`).join('');
|
|
417
|
+
document.querySelectorAll('.counter').forEach((b) => (b.onclick = () => { state.level = state.level === b.dataset.level ? '' : b.dataset.level; state.shown = PAGE; render(); }));
|
|
418
|
+
|
|
419
|
+
const st = overall(s);
|
|
420
|
+
const shield = st.tone === 'ok'
|
|
421
|
+
? '<svg viewBox="0 0 24 24"><path fill="var(--ok)" d="M12 2 4 5v6c0 5 3.4 9.7 8 11 4.6-1.3 8-6 8-11V5l-8-3z"/><path fill="none" stroke="#fff" stroke-width="2.2" d="m8.5 12 2.5 2.5 4.5-5"/></svg>'
|
|
422
|
+
: '<svg viewBox="0 0 24 24"><path fill="var(--muted)" d="M12 2 1 21h22L12 2z"/><path stroke="var(--bg)" stroke-width="2.2" d="M12 9v5M12 16.5v2"/></svg>';
|
|
423
|
+
$('#title').innerHTML = `${shield}<span>Security Report — ${esc(r.project.name)}</span>`;
|
|
424
|
+
$('#sub').textContent = `${r.project.framework} · ${r.project.root}`;
|
|
425
|
+
|
|
426
|
+
const kinds = { dependency: [], code: [], secret: [] };
|
|
427
|
+
r.findings.forEach((f) => kinds[f.kind].push(f));
|
|
428
|
+
const top = r.findings.filter((f) => f.risk === 'critical' || f.risk === 'high').slice(0, 3);
|
|
429
|
+
$('#latest').innerHTML = `
|
|
430
|
+
<div>
|
|
431
|
+
<div class="status"><span class="ico">${dot(st.tone)}</span><span><b class="kind">${st.word} –</b> <b>${esc(st.detail)}</b></span></div>
|
|
432
|
+
<div class="when">${esc(new Date(r.scannedAt).toLocaleString())} · ${(r.durationMs / 1000).toFixed(1)}s</div>
|
|
433
|
+
<div class="trio">${LEVELS.map((l) => `<div><small>${l[0].toUpperCase() + l.slice(1)}:</small><span class="v">${dot(s.byRisk[l] ? l : 'muted')} ${s.byRisk[l]}</span></div>`).join('')}</div>
|
|
434
|
+
<div class="rawsev">By raw severity: ${LEVELS.map((l) => `${s.bySeverity[l]} ${l}`).join(' · ')}</div>
|
|
435
|
+
</div>
|
|
436
|
+
<div class="prose">
|
|
437
|
+
<p>Scanned <b>${p.total}</b> packages (${p.direct} direct, ${p.transitive} transitive)${r.stats.files != null ? ` and <b>${r.stats.files}</b> source files` : ''}.
|
|
438
|
+
Found ${s.total} issue${s.total === 1 ? '' : 's'}: ${kinds.dependency.length} in dependencies, ${kinds.code.length} in your code and ${kinds.secret.length} secret${kinds.secret.length === 1 ? '' : 's'}.</p>
|
|
439
|
+
${s.total ? `<p>After weighing where each issue runs, whether your code reaches it and whether it is exploited in the wild, <b>${s.byRisk.critical} ${s.byRisk.critical === 1 ? 'is' : 'are'} critical</b> for this codebase — raw severity alone says ${s.bySeverity.critical}.${AI ? ` AI validation: <b>${esc(AI)}</b>.` : ''}</p>` : ''}
|
|
440
|
+
${top.length ? `<p style="margin:0">Fix first:</p><ol class="firstfix">${top.map((f) => `<li><a href="#" data-jump="${esc(f.id)}">${esc(f.kind === 'dependency' ? `${f.package.name}@${f.package.version}` : `${f.location.file}:${f.location.line}`)}</a> — ${esc(f.title)}</li>`).join('')}</ol>` : ''}
|
|
441
|
+
<ul class="groups">${groupRow('Dependencies', kinds.dependency)}${groupRow('Your code', kinds.code)}${groupRow('Secrets', kinds.secret)}</ul>
|
|
442
|
+
</div>`;
|
|
443
|
+
document.querySelectorAll('[data-jump]').forEach((a) => (a.onclick = (e) => { e.preventDefault(); jump(a.dataset.jump); }));
|
|
444
|
+
$('#warnings').innerHTML = r.warnings.map((w) => `<div class="warn">${esc(w)}</div>`).join('');
|
|
445
|
+
}
|
|
446
|
+
|
|
447
|
+
function jump(id) {
|
|
448
|
+
Object.assign(state, { kind: '', level: '', exposure: '', hideDev: false, q: '' });
|
|
449
|
+
$('#q').value = ''; $('#exposure').value = ''; $('#hideDev').checked = false;
|
|
450
|
+
if (state.view === 'fix') {
|
|
451
|
+
const groups = buildGroups(filtered());
|
|
452
|
+
const gi = groups.findIndex((g) => g.findings.some((f) => f.id === id));
|
|
453
|
+
if (gi >= state.shown) state.shown = gi + 1;
|
|
454
|
+
render();
|
|
455
|
+
const card = gi === -1 ? null : document.querySelector(`.card[data-key="${CSS.escape(groups[gi].key)}"]`);
|
|
456
|
+
if (card) { openCard(card, true); card.scrollIntoView({ behavior: 'smooth', block: 'start' }); }
|
|
457
|
+
return;
|
|
458
|
+
}
|
|
459
|
+
const idx = filtered().findIndex((f) => f.id === id);
|
|
460
|
+
if (idx >= state.shown) state.shown = idx + 1;
|
|
461
|
+
render();
|
|
462
|
+
const el = document.querySelector(`[data-id="${CSS.escape(id)}"]`);
|
|
463
|
+
if (el) { el.scrollIntoView({ behavior: 'smooth', block: 'start' }); toggleMore(el, true); }
|
|
464
|
+
}
|
|
465
|
+
|
|
466
|
+
function filtered() {
|
|
467
|
+
const q = state.q.toLowerCase();
|
|
468
|
+
const lf = state.sort === 'severity' ? 'severity' : 'risk';
|
|
469
|
+
const a = state.sort === 'severity' ? 'severityScore' : 'riskScore';
|
|
470
|
+
const b = state.sort === 'severity' ? 'riskScore' : 'severityScore';
|
|
471
|
+
return state.report.findings
|
|
472
|
+
.filter((f) => !state.kind || f.kind === state.kind)
|
|
473
|
+
.filter((f) => !state.level || f[lf] === state.level)
|
|
474
|
+
.filter((f) => !state.exposure || f.exposure?.context === state.exposure)
|
|
475
|
+
.filter((f) => !state.hideDev || !['build', 'unused'].includes(f.exposure?.context))
|
|
476
|
+
.filter((f) => !q || JSON.stringify([f.title, f.ruleId, f.package?.name, f.location?.file, f.advisory?.aliases, f.advisory?.id]).toLowerCase().includes(q))
|
|
477
|
+
.sort((x, y) => y[a] - x[a] || y[b] - x[b]);
|
|
478
|
+
}
|
|
479
|
+
|
|
480
|
+
function tagsFor(f) {
|
|
481
|
+
const t = [];
|
|
482
|
+
if (f.advisory?.malicious) t.push('<span class="tag red">Malicious package</span>');
|
|
483
|
+
if (f.exploit?.kev) t.push('<span class="tag red">Exploited in the wild</span>');
|
|
484
|
+
if (f.tainted) t.push('<span class="tag red">User input reaches it</span>');
|
|
485
|
+
if (f.kind === 'dependency') {
|
|
486
|
+
t.push(`<span class="tag">${f.package.direct ? 'Direct' : 'Transitive'}</span>`);
|
|
487
|
+
if (!f.package.prod) t.push('<span class="tag">Dev dependency</span>');
|
|
488
|
+
t.push(f.package.fixedIn ? `<span class="tag fix">Fix → ${esc(f.package.fixedIn)}</span>` : '<span class="tag">No fix yet</span>');
|
|
489
|
+
} else if (f.confidence) t.push(`<span class="tag">${esc(f.confidence)} confidence</span>`);
|
|
490
|
+
if (f.ai) t.push(`<span class="tag">AI: ${esc(f.ai.verdict.replace('_', ' '))}</span>`);
|
|
491
|
+
return t.join('');
|
|
492
|
+
}
|
|
493
|
+
|
|
494
|
+
function aiBlock(f) {
|
|
495
|
+
if (f.ai) {
|
|
496
|
+
return `<div class="ai"><h4>AI review · ${esc(f.ai.provider)}</h4>
|
|
497
|
+
<div><span class="v">${esc(f.ai.verdict.replace('_', ' '))}</span> · ${Math.round((f.ai.confidence ?? 0) * 100)}% confidence</div>
|
|
498
|
+
<p style="margin:6px 0">${esc(f.ai.reasoning)}</p>${f.ai.fix ? `<pre>${esc(f.ai.fix)}</pre>` : ''}</div>`;
|
|
499
|
+
}
|
|
500
|
+
if (!AI) return '<div class="hint">Run with <code>--ai claude-cli</code> (or anthropic / openai / ollama) to have AI check whether this is exploitable here.</div>';
|
|
501
|
+
return `<div><button class="btn" data-analyze="${esc(f.id)}">Ask AI whether this is exploitable here</button></div>`;
|
|
502
|
+
}
|
|
503
|
+
|
|
504
|
+
function moreHtml(f) {
|
|
505
|
+
const parts = [];
|
|
506
|
+
if (f.kind === 'dependency') {
|
|
507
|
+
const a = f.advisory;
|
|
508
|
+
if (f.package.path.length > 1) parts.push(`<div><h4>Why it is installed</h4><div class="path">${f.package.path.map((k) => `<code>${esc(k)}</code>`).join('<span>›</span>')}</div></div>`);
|
|
509
|
+
const intel = [];
|
|
510
|
+
if (a.cvss) intel.push(`CVSS ${esc(a.cvss.score ?? '')} <code>${esc(a.cvss.vector)}</code>`);
|
|
511
|
+
if (a.cwes?.length) intel.push(a.cwes.map(esc).join(', '));
|
|
512
|
+
if (f.exploit?.epss) intel.push(`EPSS ${(f.exploit.epss.epss * 100).toFixed(2)}%`);
|
|
513
|
+
if (f.exploit?.kev) intel.push(`CISA KEV since ${esc(f.exploit.kev.dateAdded)}`);
|
|
514
|
+
parts.push(`<div><h4>Advisory</h4><div class="hint" style="margin:0 0 6px">${[a.id, ...a.aliases].map(esc).join(' · ')}${intel.length ? ' — ' + intel.join(' · ') : ''}</div>
|
|
515
|
+
${a.details ? `<pre>${esc(a.details)}</pre>` : ''}
|
|
516
|
+
<div><a href="${safeUrl(a.url)}" target="_blank" rel="noopener noreferrer">osv.dev</a>${a.references.slice(0, 4).map((u) => ` · <a href="${safeUrl(u)}" target="_blank" rel="noopener noreferrer">${esc(host(u))}</a>`).join('')}</div></div>`);
|
|
517
|
+
} else if (f.cwe) {
|
|
518
|
+
parts.push(`<div><h4>Reference</h4><a href="https://cwe.mitre.org/data/definitions/${esc(f.cwe.replace('CWE-', ''))}.html" target="_blank" rel="noopener noreferrer">${esc(f.cwe)}</a> · rule <code>${esc(f.ruleId)}</code></div>`);
|
|
519
|
+
}
|
|
520
|
+
parts.push(aiBlock(f));
|
|
521
|
+
return parts.join('');
|
|
522
|
+
}
|
|
523
|
+
|
|
524
|
+
function entryHtml(f) {
|
|
525
|
+
const level = state.sort === 'severity' ? f.severity : f.risk;
|
|
526
|
+
const ctx = f.exposure?.context ?? 'unknown';
|
|
527
|
+
const subject = f.kind === 'dependency' ? `${f.package.name}@${f.package.version}` : `${f.location.file}:${f.location.line}`;
|
|
528
|
+
const reasons = (f.reasons ?? []).filter((r) => !/^AI \(/.test(r));
|
|
529
|
+
return `<article class="entry" data-id="${esc(f.id)}">
|
|
530
|
+
<div>
|
|
531
|
+
<div class="head"><span class="ico">${ICON[f.kind]}</span><span><span class="lvl ${level}">${level} –</span> <span class="kind">${KIND[f.kind]}</span></span></div>
|
|
532
|
+
<div class="subject mono">${esc(subject)}</div>
|
|
533
|
+
<div class="trio">
|
|
534
|
+
<div><small>Risk:</small><span class="v">${dot(f.risk)} ${f.riskScore.toFixed(1)}</span></div>
|
|
535
|
+
<div><small>Severity:</small><span class="v">${dot(f.severity)} ${f.severityScore.toFixed(1)}</span></div>
|
|
536
|
+
<div><small>Runs:</small><span class="v">${dot(EXPO[ctx][0])} ${EXPO[ctx][1]}</span></div>
|
|
537
|
+
</div>
|
|
538
|
+
<div class="tags">${tagsFor(f)}</div>
|
|
539
|
+
</div>
|
|
540
|
+
<div class="desc">
|
|
541
|
+
<p class="ttl">${esc(f.title)}</p>
|
|
542
|
+
${f.kind !== 'dependency' && f.location?.snippet ? `<pre>${esc(f.location.snippet)}</pre>` : ''}
|
|
543
|
+
${reasons.length ? `<ul>${reasons.map((x) => `<li>${esc(x)}</li>`).join('')}</ul>` : ''}
|
|
544
|
+
${f.fix ? `<p class="fix"><b>Fix:</b> ${esc(f.fix)}</p>` : ''}
|
|
545
|
+
${f.ai ? `<p class="fix" style="color:var(--muted)"><b style="color:var(--accent)">AI:</b> ${esc(f.ai.verdict.replace('_', ' '))} — ${esc(f.ai.reasoning.slice(0, 220))}${f.ai.reasoning.length > 220 ? '…' : ''}</p>` : ''}
|
|
546
|
+
<button class="readall" type="button">… <span>read all</span></button>
|
|
547
|
+
<div class="more" hidden></div>
|
|
548
|
+
</div>
|
|
549
|
+
</article>`;
|
|
550
|
+
}
|
|
551
|
+
|
|
552
|
+
function toggleMore(entry, open) {
|
|
553
|
+
const more = entry.querySelector('.more');
|
|
554
|
+
const btn = entry.querySelector('.readall');
|
|
555
|
+
const show = open ?? more.hidden;
|
|
556
|
+
if (show && !more.innerHTML) {
|
|
557
|
+
const f = state.report.findings.find((x) => x.id === entry.dataset.id);
|
|
558
|
+
more.innerHTML = moreHtml(f);
|
|
559
|
+
wireAi(more);
|
|
560
|
+
}
|
|
561
|
+
more.hidden = !show;
|
|
562
|
+
btn.innerHTML = show ? '<span>show less</span>' : '… <span>read all</span>';
|
|
563
|
+
}
|
|
564
|
+
|
|
565
|
+
/** Dependency findings collapse into one card per package (one upgrade fixes them all); code/secret findings stay one per card. */
|
|
566
|
+
function buildGroups(list) {
|
|
567
|
+
const byPkg = new Map();
|
|
568
|
+
const out = [];
|
|
569
|
+
for (const f of list) {
|
|
570
|
+
if (f.kind !== 'dependency') {
|
|
571
|
+
out.push({ type: 'single', key: f.id, findings: [f] });
|
|
572
|
+
continue;
|
|
573
|
+
}
|
|
574
|
+
const k = `${f.package.ecosystem}:${f.package.name}@${f.package.version}`;
|
|
575
|
+
if (!byPkg.has(k)) {
|
|
576
|
+
const g = { type: 'pkg', key: k, findings: [] };
|
|
577
|
+
byPkg.set(k, g);
|
|
578
|
+
out.push(g);
|
|
579
|
+
}
|
|
580
|
+
byPkg.get(k).findings.push(f);
|
|
581
|
+
}
|
|
582
|
+
return out; // list is sorted, so each group's first finding is its worst and groups are in priority order
|
|
583
|
+
}
|
|
584
|
+
|
|
585
|
+
function installCmd(p, target) {
|
|
586
|
+
if (p.ecosystem === 'Packagist') return `composer require ${p.name}:^${target}`;
|
|
587
|
+
if (p.ecosystem === 'PyPI') return `pip install "${p.name}>=${target}"`;
|
|
588
|
+
if (/yarn/.test(p.lockfile)) return `yarn add ${p.name}@${target}`;
|
|
589
|
+
if (/pnpm/.test(p.lockfile)) return `pnpm add ${p.name}@${target}`;
|
|
590
|
+
return `npm install ${p.name}@${target}`;
|
|
591
|
+
}
|
|
592
|
+
|
|
593
|
+
function pkgCard(g) {
|
|
594
|
+
const top = g.findings[0];
|
|
595
|
+
const p = top.package;
|
|
596
|
+
const level = lvl(top);
|
|
597
|
+
const n = g.findings.length;
|
|
598
|
+
const fixes = g.findings.map((f) => f.package.fixedIn);
|
|
599
|
+
const target = fixes.filter(Boolean).sort(cmpVer).at(-1);
|
|
600
|
+
const noFix = fixes.filter((x) => !x).length;
|
|
601
|
+
const root = !p.direct && p.path.length > 1 ? p.path[0].slice(0, p.path[0].lastIndexOf('@')) : null;
|
|
602
|
+
const ctx = top.exposure?.context ?? 'unknown';
|
|
603
|
+
const malicious = top.advisory.malicious;
|
|
604
|
+
|
|
605
|
+
const title = malicious ? `Remove ${p.name} — it is a known malicious package` : target ? `Update ${p.name} from ${p.version} to ${target}` : `Replace or patch ${p.name} ${p.version}`;
|
|
606
|
+
let how;
|
|
607
|
+
if (malicious) how = esc(top.fix);
|
|
608
|
+
else if (!target) how = `No patched version has been released yet. Consider replacing ${esc(p.name)}, or limit how your app uses it.`;
|
|
609
|
+
else if (!root) how = `Upgrade to ${esc(target)} or later:<br><code>${esc(installCmd(p, target))}</code>${noFix ? `<br>${noFix} of these issues ${noFix === 1 ? 'has' : 'have'} no fix yet, so check them after upgrading.` : ''}`;
|
|
610
|
+
else how = `You don't install ${esc(p.name)} yourself; <b>${esc(root)}</b> brings it in. Update ${esc(root)} to a release that uses ${esc(p.name)} ${esc(target)} or later, or force the version with ${p.ecosystem === 'npm' ? '<code>"overrides"</code> in package.json' : "your package manager's override setting"}.`;
|
|
611
|
+
|
|
612
|
+
const tags = ['<span class="tag">Dependency</span>'];
|
|
613
|
+
if (malicious) tags.push('<span class="tag red">Malware</span>');
|
|
614
|
+
if (g.findings.some((f) => f.exploit?.kev)) tags.push('<span class="tag red">Exploited in the wild</span>');
|
|
615
|
+
if (top.ai) tags.push(`<span class="tag">AI: ${esc(top.ai.verdict.replace('_', ' '))}</span>`);
|
|
616
|
+
|
|
617
|
+
return shell(g, level, title, n === 1 ? '1 issue' : `${n} issues`, tags,
|
|
618
|
+
`${esc(p.name)}@${esc(p.version)}${root ? ` · via ${esc(root)}` : ''}${!p.prod ? ' · dev dependency' : ''}`,
|
|
619
|
+
`<div class="k">What's wrong</div><div>${n === 1 ? 'This version has a known security issue' : `This version has <b>${n} known security issues</b>`}. The worst: “${esc(top.title)}”.</div>
|
|
620
|
+
<div class="k">What could happen</div><div>${esc(IMPACT[top.vulnClass] ?? IMPACT.other)}</div>
|
|
621
|
+
<div class="k">Where it runs</div><div>${esc(WHERE[ctx])}</div>
|
|
622
|
+
<div class="k">How to fix</div><div>${how}</div>`,
|
|
623
|
+
`Show ${n === 1 ? 'details' : `all ${n} issues and details`}`);
|
|
624
|
+
}
|
|
625
|
+
|
|
626
|
+
/** Collapsed: one colored bar. Expanded: the plain-language card underneath. */
|
|
627
|
+
function shell(g, level, title, meta, tags, loc, rows, toggleLabel) {
|
|
628
|
+
return `<article class="card ${level}" data-key="${esc(g.key)}">
|
|
629
|
+
<button class="fbar ${level}" type="button" aria-expanded="false">
|
|
630
|
+
<span class="bar-lvl">${cap(level)} · ${ACTION[level]}</span>
|
|
631
|
+
<span class="bar-title">${esc(title)}</span>
|
|
632
|
+
<span class="bar-meta">${esc(meta)}</span>
|
|
633
|
+
<span class="chev" aria-hidden="true">›</span>
|
|
634
|
+
</button>
|
|
635
|
+
<div class="body" hidden>
|
|
636
|
+
<div class="pills">${tags.join('')}</div>
|
|
637
|
+
<div class="loc mono">${loc}</div>
|
|
638
|
+
<div class="rows">${rows}</div>
|
|
639
|
+
<button class="toggle" type="button">${toggleLabel}</button>
|
|
640
|
+
<div class="details" hidden></div>
|
|
641
|
+
</div>
|
|
642
|
+
</article>`;
|
|
643
|
+
}
|
|
644
|
+
|
|
645
|
+
function openCard(card, open) {
|
|
646
|
+
const body = card.querySelector('.body');
|
|
647
|
+
const show = open ?? body.hidden;
|
|
648
|
+
body.hidden = !show;
|
|
649
|
+
card.classList.toggle('open', show);
|
|
650
|
+
card.querySelector('.fbar').setAttribute('aria-expanded', String(show));
|
|
651
|
+
}
|
|
652
|
+
|
|
653
|
+
function pkgDetails(g) {
|
|
654
|
+
const top = g.findings[0];
|
|
655
|
+
const parts = [];
|
|
656
|
+
if (top.package.path.length > 1) parts.push(`<div><h4>Why it is installed</h4><div class="path">${top.package.path.map((k) => `<code>${esc(k)}</code>`).join('<span>›</span>')}</div></div>`);
|
|
657
|
+
const reasons = (top.reasons ?? []).filter((x) => !/^AI \(/.test(x));
|
|
658
|
+
if (reasons.length) parts.push(`<div><h4>Why it is ranked ${esc(lvl(top))}</h4><ul>${reasons.map((x) => `<li>${esc(x)}</li>`).join('')}</ul></div>`);
|
|
659
|
+
parts.push(`<div><h4>${g.findings.length === 1 ? 'The issue' : `All ${g.findings.length} issues`}</h4><table class="advs"><tbody>${g.findings.map((f) => `
|
|
660
|
+
<tr><td><span class="pill ${lvl(f)}">${cap(lvl(f))}</span></td>
|
|
661
|
+
<td>${esc(f.title)}<div class="hint" style="margin:2px 0 0">${esc(IMPACT[f.vulnClass] ?? IMPACT.other)}</div></td>
|
|
662
|
+
<td class="nowrap">${f.package.fixedIn ? `Fixed in ${esc(f.package.fixedIn)}` : 'No fix yet'}</td>
|
|
663
|
+
<td><a href="${safeUrl(f.advisory.url)}" target="_blank" rel="noopener noreferrer">${esc(f.advisory.aliases.find((a) => a.startsWith('CVE-')) ?? f.advisory.id)}</a></td></tr>`).join('')}</tbody></table></div>`);
|
|
664
|
+
parts.push(aiBlock(top));
|
|
665
|
+
return parts.join('');
|
|
666
|
+
}
|
|
667
|
+
|
|
668
|
+
function singleCard(g) {
|
|
669
|
+
const f = g.findings[0];
|
|
670
|
+
const level = lvl(f);
|
|
671
|
+
const ctx = f.exposure?.context ?? 'unknown';
|
|
672
|
+
const wrong = f.kind === 'secret'
|
|
673
|
+
? 'A password, key or token is written directly in this file.'
|
|
674
|
+
: f.tainted
|
|
675
|
+
? 'This line uses data that comes straight from the request (URL, form or body) without making it safe first.'
|
|
676
|
+
: 'This line matches a risky coding pattern.';
|
|
677
|
+
const tags = [`<span class="tag">${f.kind === 'secret' ? 'Secret' : 'Your code'}</span>`];
|
|
678
|
+
if (f.tainted) tags.push('<span class="tag red">User input reaches it</span>');
|
|
679
|
+
if (f.ai) tags.push(`<span class="tag">AI: ${esc(f.ai.verdict.replace('_', ' '))}</span>`);
|
|
680
|
+
return shell(g, level, f.title, `${f.location.file}:${f.location.line}`, tags,
|
|
681
|
+
`${esc(f.location.file)} · line ${f.location.line}`,
|
|
682
|
+
`<div class="k">What's wrong</div><div>${wrong}</div>
|
|
683
|
+
<div class="k">What could happen</div><div>${esc(IMPACT[f.vulnClass] ?? IMPACT.other)}</div>
|
|
684
|
+
<div class="k">Where it runs</div><div>${esc(WHERE[ctx])}</div>
|
|
685
|
+
${f.location.snippet ? `<div class="k">The code</div><div><pre>${esc(f.location.snippet)}</pre></div>` : ''}
|
|
686
|
+
<div class="k">How to fix</div><div>${esc(f.fix)}</div>`,
|
|
687
|
+
'Show details');
|
|
688
|
+
}
|
|
689
|
+
|
|
690
|
+
function singleDetails(g) {
|
|
691
|
+
const f = g.findings[0];
|
|
692
|
+
const reasons = (f.reasons ?? []).filter((x) => !/^AI \(/.test(x));
|
|
693
|
+
return (reasons.length ? `<div><h4>Why it is ranked ${esc(lvl(f))}</h4><ul>${reasons.map((x) => `<li>${esc(x)}</li>`).join('')}</ul></div>` : '') + moreHtml(f);
|
|
694
|
+
}
|
|
695
|
+
|
|
696
|
+
function toggleCard(card, open) {
|
|
697
|
+
const box = card.querySelector('.details');
|
|
698
|
+
const btn = card.querySelector('.toggle');
|
|
699
|
+
const show = open ?? box.hidden;
|
|
700
|
+
if (show && !box.innerHTML) {
|
|
701
|
+
const g = state.groupMap.get(card.dataset.key);
|
|
702
|
+
box.innerHTML = g.type === 'pkg' ? pkgDetails(g) : singleDetails(g);
|
|
703
|
+
wireAi(box);
|
|
704
|
+
}
|
|
705
|
+
box.hidden = !show;
|
|
706
|
+
btn.dataset.label ??= btn.textContent;
|
|
707
|
+
btn.textContent = show ? 'Hide details' : btn.dataset.label;
|
|
708
|
+
}
|
|
709
|
+
|
|
710
|
+
function renderFix(list) {
|
|
711
|
+
const groups = buildGroups(list);
|
|
712
|
+
state.groupMap = new Map(groups.map((g) => [g.key, g]));
|
|
713
|
+
const deps = groups.filter((g) => g.type === 'pkg').length;
|
|
714
|
+
$('#hint').textContent = `${groups.length} things to fix, most urgent first.${deps ? ` Issues in the same package are grouped (${deps} package${deps === 1 ? '' : 's'}), because one update usually fixes all of them.` : ''}`;
|
|
715
|
+
$('#list').innerHTML = '<div class="listctl"><button type="button" data-all="1">Expand all</button><button type="button" data-all="">Collapse all</button></div>' +
|
|
716
|
+
groups.slice(0, state.shown).map((g) => (g.type === 'pkg' ? pkgCard(g) : singleCard(g))).join('') +
|
|
717
|
+
(groups.length > state.shown ? `<div class="showmore"><button class="btn" id="more">Show ${Math.min(PAGE, groups.length - state.shown)} more of ${groups.length - state.shown}</button></div>` : '');
|
|
718
|
+
$('#more')?.addEventListener('click', () => { state.shown += PAGE; render(); });
|
|
719
|
+
document.querySelectorAll('.card').forEach((c) => {
|
|
720
|
+
c.querySelector('.fbar').onclick = () => openCard(c);
|
|
721
|
+
c.querySelector('.toggle').onclick = () => toggleCard(c);
|
|
722
|
+
});
|
|
723
|
+
document.querySelectorAll('[data-all]').forEach((b) => (b.onclick = () => document.querySelectorAll('.card').forEach((c) => openCard(c, !!b.dataset.all))));
|
|
724
|
+
// Like the tree: open the most urgent issue so the list never starts fully collapsed.
|
|
725
|
+
const first = document.querySelector('#list .card');
|
|
726
|
+
if (first) openCard(first, true);
|
|
727
|
+
}
|
|
728
|
+
|
|
729
|
+
function render() {
|
|
730
|
+
const r = state.report;
|
|
731
|
+
header(r);
|
|
732
|
+
$('#hint').textContent = state.sort === 'risk'
|
|
733
|
+
? 'Worst first: severity adjusted for where the code runs, whether your code reaches it, and whether it is exploited in the wild.'
|
|
734
|
+
: 'High → low: the raw CVSS score of each advisory or the rule severity, ignoring how your app uses it.';
|
|
735
|
+
const list = filtered();
|
|
736
|
+
if (!list.length) {
|
|
737
|
+
$('#list').innerHTML = `<div class="empty">${r.findings.length ? 'No findings match these filters.' : '✓ No known vulnerabilities or insecure code patterns found.'}</div>`;
|
|
738
|
+
return;
|
|
739
|
+
}
|
|
740
|
+
if (state.view === 'fix') return renderFix(list);
|
|
741
|
+
$('#list').innerHTML = list.slice(0, state.shown).map(entryHtml).join('') +
|
|
742
|
+
(list.length > state.shown ? `<div class="showmore"><button class="btn" id="more">Show ${Math.min(PAGE, list.length - state.shown)} more of ${list.length - state.shown}</button></div>` : '');
|
|
743
|
+
$('#more')?.addEventListener('click', () => { state.shown += PAGE; render(); });
|
|
744
|
+
document.querySelectorAll('.entry').forEach((e) => (e.querySelector('.readall').onclick = () => toggleMore(e)));
|
|
745
|
+
const firstEntry = document.querySelector('#list .entry');
|
|
746
|
+
if (firstEntry) toggleMore(firstEntry, true);
|
|
747
|
+
}
|
|
748
|
+
|
|
749
|
+
function wireAi(scope) {
|
|
750
|
+
scope.querySelectorAll('[data-analyze]').forEach((b) => (b.onclick = async () => {
|
|
751
|
+
b.disabled = true; b.textContent = 'Asking AI…';
|
|
752
|
+
try {
|
|
753
|
+
const updated = await api(`/api/analyze?id=${encodeURIComponent(b.dataset.analyze)}`, { method: 'POST' });
|
|
754
|
+
const i = state.report.findings.findIndex((f) => f.id === updated.id);
|
|
755
|
+
state.report.findings[i] = updated;
|
|
756
|
+
const card = b.closest('.card');
|
|
757
|
+
if (card) {
|
|
758
|
+
// Risk may have changed, so the card can move; re-render and reopen it.
|
|
759
|
+
const key = card.dataset.key;
|
|
760
|
+
render();
|
|
761
|
+
const fresh = document.querySelector(`.card[data-key="${CSS.escape(key)}"]`);
|
|
762
|
+
if (fresh) { openCard(fresh, true); toggleCard(fresh, true); }
|
|
763
|
+
return;
|
|
764
|
+
}
|
|
765
|
+
const entry = b.closest('.entry');
|
|
766
|
+
entry.outerHTML = entryHtml(updated);
|
|
767
|
+
const fresh = document.querySelector(`[data-id="${CSS.escape(updated.id)}"]`);
|
|
768
|
+
fresh.querySelector('.readall').onclick = () => toggleMore(fresh);
|
|
769
|
+
toggleMore(fresh, true);
|
|
770
|
+
} catch (e) { b.disabled = false; b.textContent = `Failed: ${e.message} — retry`; }
|
|
771
|
+
}));
|
|
772
|
+
}
|
|
773
|
+
|
|
774
|
+
// ── dependency tree ─────────────────────────────────
|
|
775
|
+
const tree = { mode: 'vuln', q: '', graphs: [] };
|
|
776
|
+
|
|
777
|
+
/** Per lockfile: vulnerable packages, and for every node the set of vulnerable packages at or below it. */
|
|
778
|
+
function prepTree() {
|
|
779
|
+
const vuln = new Map();
|
|
780
|
+
for (const f of state.report.findings) {
|
|
781
|
+
if (f.kind !== 'dependency') continue;
|
|
782
|
+
const k = `${f.package.name}@${f.package.version}`;
|
|
783
|
+
const cur = vuln.get(k) ?? { n: 0, worst: 'low', max: -1, first: f.id, prod: f.package.prod };
|
|
784
|
+
cur.n++;
|
|
785
|
+
if (f.riskScore > cur.max) { cur.max = f.riskScore; cur.worst = f.risk; cur.first = f.id; }
|
|
786
|
+
vuln.set(k, cur);
|
|
787
|
+
}
|
|
788
|
+
tree.vuln = vuln;
|
|
789
|
+
tree.graphs = state.report.tree.map((g, gi) => {
|
|
790
|
+
const memo = new Map();
|
|
791
|
+
const below = (key, stack = new Set()) => {
|
|
792
|
+
if (memo.has(key)) return memo.get(key);
|
|
793
|
+
if (stack.has(key)) return new Set();
|
|
794
|
+
stack.add(key);
|
|
795
|
+
const s = new Set(vuln.has(key) ? [key] : []);
|
|
796
|
+
for (const d of g.nodes[key]?.deps ?? []) for (const x of below(d, stack)) s.add(x);
|
|
797
|
+
stack.delete(key);
|
|
798
|
+
memo.set(key, s);
|
|
799
|
+
return s;
|
|
800
|
+
};
|
|
801
|
+
return { ...g, gi, below };
|
|
802
|
+
});
|
|
803
|
+
}
|
|
804
|
+
|
|
805
|
+
const splitKey = (k) => { const i = k.lastIndexOf('@'); return i > 0 ? [k.slice(0, i), k.slice(i + 1)] : [k, '']; };
|
|
806
|
+
const worstOf = (keys) => LEVELS.find((l) => [...keys].some((k) => tree.vuln.get(k)?.worst === l)) ?? null;
|
|
807
|
+
const rank = (lvl) => (lvl ? LEVELS.indexOf(lvl) : LEVELS.length);
|
|
808
|
+
/** Most dangerous branch first (worst level, then how much is vulnerable), clean ones last, then by name. */
|
|
809
|
+
const byDanger = (g) => (a, b) => rank(worstOf(g.below(a))) - rank(worstOf(g.below(b))) || g.below(b).size - g.below(a).size || a.localeCompare(b);
|
|
810
|
+
|
|
811
|
+
function kidsOf(g, key, path) {
|
|
812
|
+
const deps = (g.nodes[key]?.deps ?? []).filter((d) => !path.includes(d) && g.nodes[d]);
|
|
813
|
+
const list = tree.mode === 'vuln' ? deps.filter((d) => g.below(d).size) : deps;
|
|
814
|
+
return list.sort(byDanger(g));
|
|
815
|
+
}
|
|
816
|
+
|
|
817
|
+
function nodeHtml(g, key, path, { root = null } = {}) {
|
|
818
|
+
const [name, ver] = splitKey(key);
|
|
819
|
+
const v = tree.vuln.get(key);
|
|
820
|
+
const under = g.below(key);
|
|
821
|
+
const belowOnly = under.size - (v ? 1 : 0);
|
|
822
|
+
const kids = kidsOf(g, key, path);
|
|
823
|
+
const node = g.nodes[key];
|
|
824
|
+
const worst = v ? v.worst : worstOf(under);
|
|
825
|
+
const bits = [
|
|
826
|
+
kids.length ? `<button class="tv-tog" type="button" aria-expanded="false" aria-label="Show what ${esc(name)} depends on">›</button>` : '<span class="tv-leaf"></span>',
|
|
827
|
+
dot(v ? v.worst : under.size ? 'muted' : 'ok'),
|
|
828
|
+
`<span class="tv-name">${esc(name)}</span><span class="tv-ver">@${esc(ver)}</span>`,
|
|
829
|
+
];
|
|
830
|
+
if (root?.dev || (node && !node.prod)) bits.push('<span class="tag">dev</span>');
|
|
831
|
+
if (root && root.workspace !== '.') bits.push(`<span class="tag">${esc(root.workspace)}</span>`);
|
|
832
|
+
if (v) bits.push(`<span class="pill ${v.worst}">${v.n} issue${v.n > 1 ? 's' : ''}</span><a class="tv-fix" data-fixjump="${esc(v.first)}">How to fix</a>`);
|
|
833
|
+
if (belowOnly > 0) bits.push(`<span class="tv-below">${belowOnly} vulnerable package${belowOnly > 1 ? 's' : ''} below</span>`);
|
|
834
|
+
if (root && !under.size) bits.push('<span class="tv-below">no known issues</span>');
|
|
835
|
+
const p = encodeURIComponent(JSON.stringify([...path, key]));
|
|
836
|
+
const cls = root ? `tv-root ${worst ?? ''}` : '';
|
|
837
|
+
return `<li class="${cls}" data-g="${g.gi}" data-key="${esc(key)}" data-path="${p}"><div class="tv-node ${v || under.size ? '' : 'clean'}">${bits.join('')}</div></li>`;
|
|
838
|
+
}
|
|
839
|
+
|
|
840
|
+
const CHUNK = 60;
|
|
841
|
+
function toggleNode(li, open) {
|
|
842
|
+
const btn = li.querySelector(':scope > .tv-node > .tv-tog');
|
|
843
|
+
if (!btn) return;
|
|
844
|
+
let ul = li.querySelector(':scope > ul');
|
|
845
|
+
const show = open ?? btn.getAttribute('aria-expanded') !== 'true';
|
|
846
|
+
if (show && !ul) {
|
|
847
|
+
const g = tree.graphs[Number(li.dataset.g)];
|
|
848
|
+
const path = JSON.parse(decodeURIComponent(li.dataset.path));
|
|
849
|
+
const kids = kidsOf(g, li.dataset.key, path);
|
|
850
|
+
ul = document.createElement('ul');
|
|
851
|
+
ul.innerHTML = kids.slice(0, CHUNK).map((k) => nodeHtml(g, k, path)).join('') +
|
|
852
|
+
(kids.length > CHUNK ? `<li class="tv-more">… and ${kids.length - CHUNK} more</li>` : '');
|
|
853
|
+
li.append(ul);
|
|
854
|
+
}
|
|
855
|
+
if (ul) ul.hidden = !show;
|
|
856
|
+
btn.setAttribute('aria-expanded', String(show));
|
|
857
|
+
}
|
|
858
|
+
|
|
859
|
+
/** "Why is X installed?" — shortest chain from every direct dependency that reaches X. */
|
|
860
|
+
function whyInstalled(q) {
|
|
861
|
+
const out = [];
|
|
862
|
+
for (const g of tree.graphs) {
|
|
863
|
+
const matches = Object.keys(g.nodes).filter((k) => splitKey(k)[0].toLowerCase().includes(q)).sort((a, b) => (tree.vuln.has(b) - tree.vuln.has(a)) || a.localeCompare(b)).slice(0, 15);
|
|
864
|
+
for (const target of matches) {
|
|
865
|
+
const chains = [];
|
|
866
|
+
for (const r of g.roots) {
|
|
867
|
+
const parent = new Map([[r.key, null]]);
|
|
868
|
+
const queue = [r.key];
|
|
869
|
+
for (let i = 0; i < queue.length && !parent.has(target); i++) {
|
|
870
|
+
for (const d of g.nodes[queue[i]]?.deps ?? []) if (!parent.has(d)) { parent.set(d, queue[i]); queue.push(d); }
|
|
871
|
+
}
|
|
872
|
+
if (!parent.has(target)) continue;
|
|
873
|
+
const chain = [];
|
|
874
|
+
for (let k = target; k; k = parent.get(k)) chain.unshift(k);
|
|
875
|
+
chains.push({ chain, dev: r.dev });
|
|
876
|
+
}
|
|
877
|
+
chains.sort((a, b) => a.chain.length - b.chain.length);
|
|
878
|
+
const v = tree.vuln.get(target);
|
|
879
|
+
const [name, ver] = splitKey(target);
|
|
880
|
+
out.push(`<div class="why"><div class="tv-node">${dot(v ? v.worst : 'ok')}<span class="tv-name">${esc(name)}</span><span class="tv-ver">@${esc(ver)}</span>
|
|
881
|
+
${v ? `<span class="pill ${v.worst}">${v.n} issue${v.n > 1 ? 's' : ''}</span><a class="tv-fix" data-fixjump="${esc(v.first)}">How to fix</a>` : '<span class="tv-below">no known issues</span>'}</div>
|
|
882
|
+
<div class="hint" style="margin:0">${chains.length === 0 ? 'Not reachable from any direct dependency.' : chains.length === 1 && chains[0].chain.length === 1 ? 'You install this directly.' : `Installed through ${chains.length} path${chains.length > 1 ? 's' : ''}:`}</div>
|
|
883
|
+
${chains.length && !(chains.length === 1 && chains[0].chain.length === 1) ? `<ol>${chains.slice(0, 6).map((c) => `<li><span class="chain">${c.chain.map((k) => `<code>${esc(k)}</code>`).join('<span>›</span>')}</span>${c.dev ? ' <span class="tag">dev</span>' : ''}</li>`).join('')}</ol>${chains.length > 6 ? `<div class="hint">+ ${chains.length - 6} more paths</div>` : ''}` : ''}
|
|
884
|
+
</div>`);
|
|
885
|
+
}
|
|
886
|
+
}
|
|
887
|
+
return out.length ? out.join('') : `<div class="empty">No installed package matches “${esc(q)}”.</div>`;
|
|
888
|
+
}
|
|
889
|
+
|
|
890
|
+
function renderTree() {
|
|
891
|
+
prepTree();
|
|
892
|
+
const r = state.report;
|
|
893
|
+
if (!r.tree.length) {
|
|
894
|
+
$('#tab-tree').innerHTML = '<div class="empty">No lockfile was found, so there is no dependency tree.</div>';
|
|
895
|
+
return;
|
|
896
|
+
}
|
|
897
|
+
const allVuln = [...tree.vuln.keys()];
|
|
898
|
+
const devOnly = allVuln.filter((k) => !tree.vuln.get(k).prod).length;
|
|
899
|
+
let body;
|
|
900
|
+
if (tree.q) body = whyInstalled(tree.q.toLowerCase());
|
|
901
|
+
else {
|
|
902
|
+
body = tree.graphs.map((g) => {
|
|
903
|
+
const cmp = byDanger(g);
|
|
904
|
+
const roots = [...g.roots].sort((a, b) => cmp(a.key, b.key));
|
|
905
|
+
const shown = tree.mode === 'vuln' ? roots.filter((rt) => g.below(rt.key).size) : roots;
|
|
906
|
+
const hidden = roots.length - shown.length;
|
|
907
|
+
return `<div class="tv-file">${esc(g.lockfile)}</div>
|
|
908
|
+
<ul class="tv">${shown.map((rt) => nodeHtml(g, rt.key, [], { root: rt })).join('')}</ul>
|
|
909
|
+
${hidden ? `<p class="hint">${hidden} direct ${hidden === 1 ? 'dependency has' : 'dependencies have'} no known issues — switch to “Full tree” to see ${hidden === 1 ? 'it' : 'them'}.</p>` : ''}`;
|
|
910
|
+
}).join('');
|
|
911
|
+
}
|
|
912
|
+
const dirty = tree.graphs.reduce((n, g) => n + g.roots.filter((rt) => g.below(rt.key).size).length, 0);
|
|
913
|
+
const total = tree.graphs.reduce((n, g) => n + g.roots.length, 0);
|
|
914
|
+
$('#tab-tree').innerHTML = `
|
|
915
|
+
<div class="tv-head">
|
|
916
|
+
<div class="seg" id="tvmode" role="group" aria-label="Tree mode">
|
|
917
|
+
<button data-v="vuln" class="${tree.mode === 'vuln' ? 'on' : ''}">Only vulnerable paths</button><button data-v="all" class="${tree.mode === 'all' ? 'on' : ''}">Full tree</button>
|
|
918
|
+
</div>
|
|
919
|
+
<input type="search" id="tvq" placeholder="Why is a package installed? Type its name…" value="${esc(tree.q)}">
|
|
920
|
+
</div>
|
|
921
|
+
<p class="tv-sum"><b>${dirty} of ${total}</b> direct dependencies bring in vulnerable code · <b>${allVuln.length}</b> vulnerable package${allVuln.length === 1 ? '' : 's'} installed${devOnly ? ` (${devOnly} only through dev tools)` : ''}. Expand a dependency to see the path to each problem.</p>
|
|
922
|
+
${body}`;
|
|
923
|
+
|
|
924
|
+
document.querySelectorAll('#tvmode button').forEach((b) => (b.onclick = () => { tree.mode = b.dataset.v; renderTree(); }));
|
|
925
|
+
const q = $('#tvq');
|
|
926
|
+
let t;
|
|
927
|
+
q.oninput = () => { clearTimeout(t); t = setTimeout(() => { tree.q = q.value.trim(); renderTree(); const n = $('#tvq'); n.focus(); n.setSelectionRange(n.value.length, n.value.length); }, 200); };
|
|
928
|
+
// Open the first vulnerable dependency so the tree is visible straight away.
|
|
929
|
+
const first = document.querySelector('#tab-tree .tv-root');
|
|
930
|
+
if (first && !tree.q && tree.mode === 'vuln') toggleNode(first, true);
|
|
931
|
+
}
|
|
932
|
+
|
|
933
|
+
// One delegated handler for expand buttons and "How to fix" links (works for lazily added nodes).
|
|
934
|
+
$('#tab-tree').addEventListener('click', (e) => {
|
|
935
|
+
const tog = e.target.closest('.tv-tog');
|
|
936
|
+
if (tog) return toggleNode(tog.closest('li'));
|
|
937
|
+
const fix = e.target.closest('[data-fixjump]');
|
|
938
|
+
if (fix) {
|
|
939
|
+
e.preventDefault();
|
|
940
|
+
document.querySelector('.tabs [data-tab=fix]').click();
|
|
941
|
+
jump(fix.dataset.fixjump);
|
|
942
|
+
}
|
|
943
|
+
});
|
|
944
|
+
|
|
945
|
+
function seg(id, key) {
|
|
946
|
+
document.querySelectorAll(`#${id} button`).forEach((b) => (b.onclick = () => {
|
|
947
|
+
document.querySelectorAll(`#${id} button`).forEach((x) => x.classList.toggle('on', x === b));
|
|
948
|
+
state[key] = b.dataset.v; state.shown = PAGE; render();
|
|
949
|
+
}));
|
|
950
|
+
}
|
|
951
|
+
seg('sort', 'sort');
|
|
952
|
+
seg('kind', 'kind');
|
|
953
|
+
$('#exposure').onchange = (e) => { state.exposure = e.target.value; state.shown = PAGE; render(); };
|
|
954
|
+
$('#hideDev').onchange = (e) => { state.hideDev = e.target.checked; state.shown = PAGE; render(); };
|
|
955
|
+
let qTimer;
|
|
956
|
+
$('#q').oninput = (e) => { clearTimeout(qTimer); qTimer = setTimeout(() => { state.q = e.target.value; state.shown = PAGE; render(); }, 150); };
|
|
957
|
+
document.querySelectorAll('.tabs button').forEach((b) => (b.onclick = () => {
|
|
958
|
+
document.querySelectorAll('.tabs button').forEach((x) => x.classList.toggle('on', x === b));
|
|
959
|
+
const tab = b.dataset.tab;
|
|
960
|
+
$('#tab-findings').hidden = tab === 'tree';
|
|
961
|
+
$('#tab-tree').hidden = tab !== 'tree';
|
|
962
|
+
if (tab === 'tree') renderTree();
|
|
963
|
+
else { state.view = tab; state.shown = PAGE; render(); }
|
|
964
|
+
}));
|
|
965
|
+
$('#rescan').onclick = async () => {
|
|
966
|
+
const b = $('#rescan');
|
|
967
|
+
b.disabled = true; b.textContent = 'Scanning…';
|
|
968
|
+
try { state.report = await api('/api/rescan', { method: 'POST' }); render(); if (!$('#tab-tree').hidden) renderTree(); }
|
|
969
|
+
catch (e) { alert(`Rescan failed: ${e.message}`); }
|
|
970
|
+
finally { b.disabled = false; b.textContent = 'Rescan'; }
|
|
971
|
+
};
|
|
972
|
+
|
|
973
|
+
api('/api/report').then((r) => {
|
|
974
|
+
state.report = r;
|
|
975
|
+
render();
|
|
976
|
+
if (location.hash === '#tree') document.querySelector('[data-tab=tree]').click();
|
|
977
|
+
}).catch((e) => { $('#list').innerHTML = `<div class="empty">Could not load report: ${esc(e.message)}</div>`; });
|
|
978
|
+
</script>
|
|
979
|
+
</body>
|
|
980
|
+
</html>
|