Skip to content

Commit 729a1a8

Browse files
cli: link to rule names to capa rules website (#2338)
* web: rules: redirect from various rule names to canonical rule URL closes #2319 Update index.html Co-authored-by: Moritz <[email protected]> * cli: link to rule names to capa rules website * just: make `just lint` run all steps, not fail on first error --------- Co-authored-by: Moritz <[email protected]>
1 parent db4798a commit 729a1a8

File tree

10 files changed

+219
-113
lines changed

10 files changed

+219
-113
lines changed

.justfile

+7-2
Original file line numberDiff line numberDiff line change
@@ -16,5 +16,10 @@
1616
@deptry:
1717
pre-commit run deptry --hook-stage manual --all-files
1818

19-
lint: isort black ruff flake8 mypy deptry
20-
19+
@lint:
20+
-just isort
21+
-just black
22+
-just ruff
23+
-just flake8
24+
-just mypy
25+
-just deptry

CHANGELOG.md

+1-1
Original file line numberDiff line numberDiff line change
@@ -6,11 +6,11 @@ Unlock powerful malware analysis with capa's new [VMRay sandbox](https://www.vmr
66

77
### New Features
88
- regenerate ruleset cache automatically on source change (only in dev mode) #2133 @s-ff
9-
109
- add landing page https://mandiant.github.io/capa/ @williballenthin #2310
1110
- add rules website https://mandiant.github.io/capa/rules @DeeyaSingh #2310
1211
- add .justfile @williballenthin #2325
1312
- dynamic: add support for VMRay dynamic sandbox traces #2208 @mike-hunhoff @r-sm2024 @mr-tz
13+
- cli: use modern terminal features to hyperlink to the rules website #2337 @williballenthin
1414

1515
### Breaking Changes
1616

capa/render/default.py

+146-82
Original file line numberDiff line numberDiff line change
@@ -6,18 +6,43 @@
66
# is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
77
# See the License for the specific language governing permissions and limitations under the License.
88

9+
import io
910
import collections
11+
import urllib.parse
1012

11-
import tabulate
13+
import rich
14+
import rich.table
15+
import rich.console
16+
from rich.console import Console
1217

1318
import capa.render.utils as rutils
1419
import capa.render.result_document as rd
1520
import capa.features.freeze.features as frzf
1621
from capa.rules import RuleSet
1722
from capa.engine import MatchResults
18-
from capa.render.utils import StringIO
1923

20-
tabulate.PRESERVE_WHITESPACE = True
24+
25+
def bold_markup(s) -> str:
26+
"""
27+
Generate Rich markup in a bold style.
28+
29+
The resulting string should be passed to a Rich renderable
30+
and/or printed via Rich or the markup will be visible to the user.
31+
"""
32+
return f"[cyan]{s}[/cyan]"
33+
34+
35+
def link_markup(s: str, href: str) -> str:
36+
"""
37+
Generate Rich markup for a clickable hyperlink.
38+
This works in many modern terminals.
39+
When it doesn't work, the fallback is just to show the link name (s),
40+
as if it was not a link.
41+
42+
The resulting string should be passed to a Rich renderable
43+
and/or printed via Rich or the markup will be visible to the user.
44+
"""
45+
return f"[link={href}]{s}[/link]"
2146

2247

2348
def width(s: str, character_count: int) -> str:
@@ -28,20 +53,31 @@ def width(s: str, character_count: int) -> str:
2853
return s
2954

3055

31-
def render_meta(doc: rd.ResultDocument, ostream: StringIO):
56+
def render_sample_link(hash: str) -> str:
57+
url = "https://www.virustotal.com/gui/file/" + hash
58+
return link_markup(hash, url)
59+
60+
61+
def render_meta(doc: rd.ResultDocument, console: Console):
3262
rows = [
33-
(width("md5", 22), width(doc.meta.sample.md5, 82)),
34-
("sha1", doc.meta.sample.sha1),
35-
("sha256", doc.meta.sample.sha256),
63+
("md5", render_sample_link(doc.meta.sample.md5)),
64+
("sha1", render_sample_link(doc.meta.sample.sha1)),
65+
("sha256", render_sample_link(doc.meta.sample.sha256)),
3666
("analysis", doc.meta.flavor.value),
3767
("os", doc.meta.analysis.os),
3868
("format", doc.meta.analysis.format),
3969
("arch", doc.meta.analysis.arch),
4070
("path", doc.meta.sample.path),
4171
]
4272

43-
ostream.write(tabulate.tabulate(rows, tablefmt="mixed_outline"))
44-
ostream.write("\n")
73+
table = rich.table.Table(show_header=False, min_width=100)
74+
table.add_column()
75+
table.add_column()
76+
77+
for row in rows:
78+
table.add_row(*row)
79+
80+
console.print(table)
4581

4682

4783
def find_subrule_matches(doc: rd.ResultDocument):
@@ -71,7 +107,12 @@ def rec(match: rd.Match):
71107
return matches
72108

73109

74-
def render_capabilities(doc: rd.ResultDocument, ostream: StringIO):
110+
def render_rule_name(name: str) -> str:
111+
url = f"https://mandiant.github.io/capa/rules/{urllib.parse.quote(name)}/"
112+
return bold_markup(link_markup(name, url))
113+
114+
115+
def render_capabilities(doc: rd.ResultDocument, console: Console):
75116
"""
76117
example::
77118
@@ -95,25 +136,30 @@ def render_capabilities(doc: rd.ResultDocument, ostream: StringIO):
95136

96137
count = len(rule.matches)
97138
if count == 1:
98-
capability = rutils.bold(rule.meta.name)
139+
capability = render_rule_name(rule.meta.name)
99140
else:
100-
capability = f"{rutils.bold(rule.meta.name)} ({count} matches)"
141+
capability = render_rule_name(rule.meta.name) + f" ({count} matches)"
101142
rows.append((capability, rule.meta.namespace))
102143

103144
if rows:
104-
ostream.write(
105-
tabulate.tabulate(
106-
rows,
107-
headers=[width("Capability", 50), width("Namespace", 50)],
108-
tablefmt="mixed_outline",
109-
)
110-
)
111-
ostream.write("\n")
145+
table = rich.table.Table(min_width=100)
146+
table.add_column(width("Capability", 20))
147+
table.add_column("Namespace")
148+
149+
for row in rows:
150+
table.add_row(*row)
151+
152+
console.print(table)
112153
else:
113-
ostream.writeln(rutils.bold("no capabilities found"))
154+
console.print(bold_markup("no capabilities found"))
155+
156+
157+
def render_attack_link(id: str) -> str:
158+
url = f"https://attack.mitre.org/techniques/{id.replace('.', '/')}/"
159+
return rf"\[{link_markup(id, url)}]"
114160

115161

116-
def render_attack(doc: rd.ResultDocument, ostream: StringIO):
162+
def render_attack(doc: rd.ResultDocument, console: Console):
117163
"""
118164
example::
119165
@@ -132,35 +178,36 @@ def render_attack(doc: rd.ResultDocument, ostream: StringIO):
132178
tactics = collections.defaultdict(set)
133179
for rule in rutils.capability_rules(doc):
134180
for attack in rule.meta.attack:
135-
tactics[attack.tactic].add((attack.technique, attack.subtechnique, attack.id))
181+
tactics[attack.tactic].add((attack.technique, attack.subtechnique, attack.id.strip("[").strip("]")))
136182

137183
rows = []
138184
for tactic, techniques in sorted(tactics.items()):
139185
inner_rows = []
140186
for technique, subtechnique, id in sorted(techniques):
141187
if not subtechnique:
142-
inner_rows.append(f"{rutils.bold(technique)} {id}")
188+
# example: File and Directory Discovery [T1083]
189+
inner_rows.append(f"{bold_markup(technique)} {render_attack_link(id)}")
143190
else:
144-
inner_rows.append(f"{rutils.bold(technique)}::{subtechnique} {id}")
145-
rows.append(
146-
(
147-
rutils.bold(tactic.upper()),
148-
"\n".join(inner_rows),
149-
)
150-
)
191+
# example: Code Discovery::Enumerate PE Sections [T1084.001]
192+
inner_rows.append(f"{bold_markup(technique)}::{subtechnique} {render_attack_link(id)}")
193+
194+
tactic = bold_markup(tactic.upper())
195+
technique = "\n".join(inner_rows)
196+
197+
rows.append((tactic, technique))
151198

152199
if rows:
153-
ostream.write(
154-
tabulate.tabulate(
155-
rows,
156-
headers=[width("ATT&CK Tactic", 20), width("ATT&CK Technique", 80)],
157-
tablefmt="mixed_grid",
158-
)
159-
)
160-
ostream.write("\n")
200+
table = rich.table.Table(min_width=100)
201+
table.add_column(width("ATT&CK Tactic", 20))
202+
table.add_column("ATT&CK Technique")
203+
204+
for row in rows:
205+
table.add_row(*row)
161206

207+
console.print(table)
162208

163-
def render_maec(doc: rd.ResultDocument, ostream: StringIO):
209+
210+
def render_maec(doc: rd.ResultDocument, console: Console):
164211
"""
165212
example::
166213
@@ -193,20 +240,37 @@ def render_maec(doc: rd.ResultDocument, ostream: StringIO):
193240
for category in sorted(maec_categories):
194241
values = maec_table.get(category, set())
195242
if values:
196-
rows.append((rutils.bold(category.replace("_", "-")), "\n".join(sorted(values))))
243+
rows.append((bold_markup(category.replace("_", "-")), "\n".join(sorted(values))))
197244

198245
if rows:
199-
ostream.write(
200-
tabulate.tabulate(
201-
rows,
202-
headers=[width("MAEC Category", 25), width("MAEC Value", 75)],
203-
tablefmt="mixed_grid",
204-
)
205-
)
206-
ostream.write("\n")
246+
table = rich.table.Table(min_width=100)
247+
table.add_column(width("MAEC Category", 20))
248+
table.add_column("MAEC Value")
249+
250+
for row in rows:
251+
table.add_row(*row)
252+
253+
console.print(table)
254+
255+
256+
def render_mbc_link(id: str, objective: str, behavior: str) -> str:
257+
if id[0] in {"B", "T", "E", "F"}:
258+
# behavior
259+
base_url = "https://github.com/MBCProject/mbc-markdown/blob/main"
260+
elif id[0] == "C":
261+
# micro-behavior
262+
base_url = "https://github.com/MBCProject/mbc-markdown/blob/main/micro-behaviors"
263+
else:
264+
raise ValueError("unexpected MBC prefix")
207265

266+
objective_fragment = objective.lower().replace(" ", "-")
267+
behavior_fragment = behavior.lower().replace(" ", "-")
208268

209-
def render_mbc(doc: rd.ResultDocument, ostream: StringIO):
269+
url = f"{base_url}/{objective_fragment}/{behavior_fragment}.md"
270+
return rf"\[{link_markup(id, url)}]"
271+
272+
273+
def render_mbc(doc: rd.ResultDocument, console: Console):
210274
"""
211275
example::
212276
@@ -223,48 +287,48 @@ def render_mbc(doc: rd.ResultDocument, ostream: StringIO):
223287
objectives = collections.defaultdict(set)
224288
for rule in rutils.capability_rules(doc):
225289
for mbc in rule.meta.mbc:
226-
objectives[mbc.objective].add((mbc.behavior, mbc.method, mbc.id))
290+
objectives[mbc.objective].add((mbc.behavior, mbc.method, mbc.id.strip("[").strip("]")))
227291

228292
rows = []
229293
for objective, behaviors in sorted(objectives.items()):
230294
inner_rows = []
231-
for behavior, method, id in sorted(behaviors):
232-
if not method:
233-
inner_rows.append(f"{rutils.bold(behavior)} [{id}]")
295+
for technique, subtechnique, id in sorted(behaviors):
296+
if not subtechnique:
297+
# example: File and Directory Discovery [T1083]
298+
inner_rows.append(f"{bold_markup(technique)} {render_mbc_link(id, objective, technique)}")
234299
else:
235-
inner_rows.append(f"{rutils.bold(behavior)}::{method} [{id}]")
236-
rows.append(
237-
(
238-
rutils.bold(objective.upper()),
239-
"\n".join(inner_rows),
240-
)
241-
)
300+
# example: Code Discovery::Enumerate PE Sections [T1084.001]
301+
inner_rows.append(
302+
f"{bold_markup(technique)}::{subtechnique} {render_mbc_link(id, objective, technique)}"
303+
)
304+
305+
objective = bold_markup(objective.upper())
306+
technique = "\n".join(inner_rows)
307+
308+
rows.append((objective, technique))
242309

243310
if rows:
244-
ostream.write(
245-
tabulate.tabulate(
246-
rows,
247-
headers=[width("MBC Objective", 25), width("MBC Behavior", 75)],
248-
tablefmt="mixed_grid",
249-
)
250-
)
251-
ostream.write("\n")
311+
table = rich.table.Table(min_width=100)
312+
table.add_column(width("MBC Objective", 20))
313+
table.add_column("MBC Behavior")
314+
315+
for row in rows:
316+
table.add_row(*row)
317+
318+
console.print(table)
252319

253320

254321
def render_default(doc: rd.ResultDocument):
255-
ostream = rutils.StringIO()
256-
257-
render_meta(doc, ostream)
258-
ostream.write("\n")
259-
render_attack(doc, ostream)
260-
ostream.write("\n")
261-
render_maec(doc, ostream)
262-
ostream.write("\n")
263-
render_mbc(doc, ostream)
264-
ostream.write("\n")
265-
render_capabilities(doc, ostream)
266-
267-
return ostream.getvalue()
322+
f = io.StringIO()
323+
console = rich.console.Console()
324+
325+
render_meta(doc, console)
326+
render_attack(doc, console)
327+
render_maec(doc, console)
328+
render_mbc(doc, console)
329+
render_capabilities(doc, console)
330+
331+
return f.getvalue()
268332

269333

270334
def render(meta, rules: RuleSet, capabilities: MatchResults) -> str:

tests/test_render.py

+6-3
Original file line numberDiff line numberDiff line change
@@ -5,10 +5,12 @@
55
# Unless required by applicable law or agreed to in writing, software distributed under the License
66
# is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
77
# See the License for the specific language governing permissions and limitations under the License.
8+
import io
89
import textwrap
910
from unittest.mock import Mock
1011

1112
import fixtures
13+
import rich.console
1214

1315
import capa.rules
1416
import capa.render.utils
@@ -151,9 +153,10 @@ def test_render_meta_maec():
151153
mock_rd.rules = {"test rule": rm}
152154

153155
# capture the output of render_maec
154-
output_stream = capa.render.utils.StringIO()
155-
capa.render.default.render_maec(mock_rd, output_stream)
156-
output = output_stream.getvalue()
156+
f = io.StringIO()
157+
console = rich.console.Console(file=f)
158+
capa.render.default.render_maec(mock_rd, console)
159+
output = f.getvalue()
157160

158161
assert "analysis-conclusion" in output
159162
assert analysis_conclusion in output

web/explorer/src/components/RuleMatchesTable.vue

+1-1
Original file line numberDiff line numberDiff line change
@@ -235,7 +235,7 @@ const contextMenuItems = computed(() => [
235235
label: "View rule in capa-rules",
236236
icon: "pi pi-external-link",
237237
target: "_blank",
238-
url: createCapaRulesUrl(selectedNode.value, props.data.meta.version)
238+
url: createCapaRulesUrl(selectedNode.value)
239239
},
240240
{
241241
label: "Lookup rule in VirusTotal",

web/explorer/src/utils/urlHelpers.js

+1-2
Original file line numberDiff line numberDiff line change
@@ -62,9 +62,8 @@ export function createATTACKHref(attack) {
6262
*/
6363
export function createCapaRulesUrl(node, tag) {
6464
if (!node || !node.data || !tag) return null;
65-
const namespace = node.data.namespace || "lib";
6665
const ruleName = node.data.name.toLowerCase().replace(/\s+/g, "-");
67-
return `https://github.com/mandiant/capa-rules/blob/v${tag}/${namespace}/${ruleName}.yml`;
66+
return `https://mandiant.github.io/capa/rules/${ruleName}/`;
6867
}
6968

7069
/**

0 commit comments

Comments
 (0)